From a6287436034c3cce974742bab5bade88fc71931c Mon Sep 17 00:00:00 2001 From: Shuveb Hussain Date: Fri, 15 May 2026 17:36:51 -0500 Subject: [PATCH] Run Ralph in place inside FeatureListPanel; harden run lifecycle Delete the standalone ralph_panel.py (~755 lines) and merge Ralph orchestration into the unified FeatureListPanel, which now doubles as the in-place run view. Add RalphConfig.module_ids so the TUI can scope a run to a single module without affecting the console subcommand. Robustness fixes from the PR review: - Wrap the _run_orchestrator and _run_single_retry worker bodies so a mid-run network/API failure surfaces the auth modal or a notification and returns to browsing instead of crashing the whole TUI and orphaning the agent subprocess. - Treat an empty module_ids tuple as "no restriction" (restricts_modules property); warn via the display when the filter matches nothing. - Narrow the resolve_phases fallback to typer.Exit so genuine API errors propagate to the user instead of a silent orphan-only list. - Restore a targeted single-module feature fetch for module-scope browsing instead of building the whole project list and discarding it. - kill_agent only reports success when terminate() did not raise; add debug breadcrumbs to best-effort teardown swallows; marshal the status-count update onto the main thread. - Drop the redundant _ralph_panel attribute (use _current_feature_panel). - Fix CLAUDE.md / README.md rot referencing the deleted panel. Tests cover the module_ids construction seam, retry success/failure, return-to-browsing teardown, and orchestrator empty/non-matching module scoping. Full unit suite: 766 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 7 +- README.md | 17 +- src/mfbt/commands/ralph/orchestrator.py | 28 +- src/mfbt/commands/ralph/progress.py | 106 +-- src/mfbt/commands/ralph/ralph_widgets.py | 210 +----- src/mfbt/commands/ralph/tui_display.py | 2 +- src/mfbt/commands/ralph/types.py | 19 + src/mfbt/tui/app.py | 249 +++---- src/mfbt/tui/app.tcss | 49 +- src/mfbt/tui/screens/auth_modal.py | 13 +- src/mfbt/tui/screens/feature_list.py | 849 ++++++++++++++++++++--- src/mfbt/tui/screens/module_list.py | 3 +- src/mfbt/tui/screens/phase_list.py | 3 +- src/mfbt/tui/screens/project_list.py | 3 +- src/mfbt/tui/screens/ralph_panel.py | 755 -------------------- tests/unit/ralph/test_orchestrator.py | 144 ++++ tests/unit/ralph/test_tui_display.py | 2 +- tests/unit/ralph/test_types.py | 30 + tests/unit/tui/test_ralph_panel.py | 504 +++++++++----- uv.lock | 2 +- 20 files changed, 1524 insertions(+), 1471 deletions(-) delete mode 100644 src/mfbt/tui/screens/ralph_panel.py diff --git a/CLAUDE.md b/CLAUDE.md index fc79b94..c68baa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,11 +99,12 @@ This project uses the **mfbt MCP server**, which exposes a virtual filesystem (V - **Venv:** `.venv/bin/python`, `.venv/bin/pytest` - **Run tests:** `.venv/bin/pytest tests/unit/ -v` +- **Editable install with uv:** `uv sync && uv pip install -e . && uv pip install -r requirements-dev.txt` — then `.venv/bin/mfbt ...` runs the in-development CLI with edits picked up live. Note: `uv sync` only installs runtime deps from `pyproject.toml` and will *uninstall* anything not declared there, so re-run the `requirements-dev.txt` step after a sync to restore pytest/mypy/ruff/etc. - **Config functions no longer take `project_root`** — `load_config()`, `save_config()`, `load_auth()`, `save_auth()`, `init_mfbt_dir()`, `resolve_config()` all operate on `~/.mfbt/` via `get_mfbt_dir()`. `TokenManager.__init__` takes only `base_url`. - **API response formats:** List endpoints (`/features`, `/implementations`) return **paginated** `{"items": [...], "total": N, ...}` — always extract via `body["items"]`. Some endpoints (`/brainstorming-phases`) return plain lists. See `src/mfbt/tui/data_provider.py` for the canonical parsing pattern. - **TUI navigation:** Projects > Phases > Modules > Features (4 levels). `NavigationState` stack depth: 0=projects, 1=phases, 2=modules, 3=features. Phase list shows brainstorming phases + virtual "Orphan modules" entry. -- **Ralph in TUI:** Integrated into main TUI (`r` key), not a standalone app. Uses `RalphPanel` widget in `#main-content` with `PreflightModal` for agent checks. Standalone `tui_app.py` deleted. +- **Ralph in TUI:** Integrated into main TUI (`r` key), not a standalone app. Ralph runs *in place* inside the unified `FeatureListPanel` (`tui/screens/feature_list.py`) in `#main-content` — there is no separate Ralph panel — with `PreflightModal` for agent checks. Standalone `tui_app.py` and the old `ralph_panel.py` are deleted. - **Ralph subcommand:** `src/mfbt/commands/ralph/` — orchestrator, display (console), tui_display (Textual adapter), ralph_widgets (TUI widgets), agent runner, progress (API), prompt builder, types. -- **Display duck typing:** `RalphOrchestrator.display` is typed as `Any` — both `RalphDisplay` and `RalphTUIDisplay` are structurally compatible (same 8 methods). -- **Key new files:** `auth_flow.py` (shared OAuth), `coding_agents.py` (agent pre-flight checks), `tui/screens/phase_list.py`, `tui/screens/preflight_modal.py`, `tui/screens/ralph_panel.py`, `commands/ralph/ralph_widgets.py`. +- **Display duck typing:** `RalphOrchestrator.display` is typed as `Any` — both `RalphDisplay` and `RalphTUIDisplay` are structurally compatible (same display protocol). +- **Key files:** `auth_flow.py` (shared OAuth), `coding_agents.py` (agent pre-flight checks), `tui/screens/phase_list.py`, `tui/screens/preflight_modal.py`, `tui/screens/feature_list.py` (unified browse + in-place Ralph panel), `commands/ralph/ralph_widgets.py`. - **TUI shortcuts:** `r` = Ralph, `ctrl+r` = Refresh, `d` = Describe, `enter` = Open/Detail, `esc` = Back, `?` = Help, `q` = Quit. diff --git a/README.md b/README.md index 538d18f..7391c20 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,23 @@ pip install -r requirements-dev.txt # Run tests pytest tests/ -v +``` + +### Using uv + +If you prefer [uv](https://docs.astral.sh/uv/), do an editable install into the project's `.venv`: + +```bash +uv sync # create .venv and install runtime deps from uv.lock +uv pip install -e . # editable install of the package +uv pip install -r requirements-dev.txt # dev tools (pytest, mypy, ruff, etc.) -# Code quality +.venv/bin/mfbt --help # run the in-development CLI +``` + +### Code quality + +```bash black --check src/ tests/ ruff check src/ tests/ mypy src/ diff --git a/src/mfbt/commands/ralph/orchestrator.py b/src/mfbt/commands/ralph/orchestrator.py index 9c6a3a4..0ffae31 100644 --- a/src/mfbt/commands/ralph/orchestrator.py +++ b/src/mfbt/commands/ralph/orchestrator.py @@ -50,7 +50,9 @@ def __init__( self._client = client self._display = display self._logger = logger - self._agent = AgentRunner(config.coding_agent, config.max_turns, config.special_instructions) + self._agent = AgentRunner( + config.coding_agent, config.max_turns, config.special_instructions + ) self._results: list[FeatureResult] = [] self._session_start: float = 0.0 self._stop_event = threading.Event() @@ -116,6 +118,30 @@ def run(self) -> SessionSummary: all_features = build_global_feature_list( self._client, self._config.project_id, list(phases), all_progress ) + + # Restrict to specific modules when requested (single-module TUI + # runs). An empty tuple is treated as "no restriction" (see + # RalphConfig.restricts_modules). The "module_id" key is produced by + # build_global_feature_list (see progress.get_module_feature_list / + # get_all_feature_list); orphan features may carry module_id "". + if self._config.restricts_modules: + module_ids = set(self._config.module_ids or ()) + before = len(all_features) + all_features = [ + f for f in all_features if f.get("module_id") in module_ids + ] + if before > 0 and not all_features: + logger.warning( + "module_ids filter %s matched none of %d features", + sorted(module_ids), + before, + ) + self._display.warn( + "No features matched the selected module(s) — nothing " + "to run. The module may have no spec'd features or its " + "id changed." + ) + self._display.populate_features(all_features) # Count actionable features for display diff --git a/src/mfbt/commands/ralph/progress.py b/src/mfbt/commands/ralph/progress.py index 2f977c9..3a86891 100644 --- a/src/mfbt/commands/ralph/progress.py +++ b/src/mfbt/commands/ralph/progress.py @@ -161,6 +161,60 @@ def get_all_feature_list( return result +def get_module_feature_list( + client: APIClient, + project_id: str, + module_id: str, + module_key: str = "", +) -> list[dict[str, str]]: + """Fetch all features for a single module via a targeted API call. + + Returns the same normalized dict format as get_all_feature_list(), + sorted by feature_key ascending. ``phase_id``/``phase_title`` are left + empty (the caller scopes by module, not phase). + """ + if not module_id: + return [] + + resp = client.get( + f"/api/v1/projects/{project_id}/features", + params={ + "module_id": module_id, + "limit": 500, + }, + ) + body = resp.body + if isinstance(body, dict) and "items" in body: + features = body["items"] + elif isinstance(body, list): + features = body + else: + features = [] + + result = [] + for f in features: + if f.get("feature_type") != "implementation": + continue + result.append( + { + "id": f.get("id", ""), + "feature_key": f.get("feature_key", ""), + "title": f.get("title", ""), + "module_key": module_key, + "module_id": module_id, + "priority": f.get("priority", ""), + "phase_id": "", + "phase_title": "", + "completion_status": f.get("completion_status", "pending"), + "has_spec": f.get("has_spec", False), + "has_prompt_plan": f.get("has_prompt_plan", False), + "has_notes": f.get("has_notes", False), + } + ) + result.sort(key=lambda x: x["feature_key"]) + return result + + def get_orphan_module_features( client: APIClient, project_id: str, @@ -170,47 +224,16 @@ def get_orphan_module_features( Returns the same dict format as get_all_feature_list(). """ - result = [] + result: list[dict[str, str]] = [] for m in orphan_modules: - mid = m.get("module_id", "") - mkey = m.get("module_key", "") - if not mid: - continue - - resp = client.get( - f"/api/v1/projects/{project_id}/features", - params={ - "module_id": mid, - "limit": 500, - }, - ) - body = resp.body - if isinstance(body, dict) and "items" in body: - features = body["items"] - elif isinstance(body, list): - features = body - else: - features = [] - - for f in features: - if f.get("feature_type") != "implementation": - continue - result.append( - { - "id": f.get("id", ""), - "feature_key": f.get("feature_key", ""), - "title": f.get("title", ""), - "module_key": mkey, - "module_id": mid, - "priority": f.get("priority", ""), - "phase_id": "", - "phase_title": "", - "completion_status": f.get("completion_status", "pending"), - "has_spec": f.get("has_spec", False), - "has_prompt_plan": f.get("has_prompt_plan", False), - "has_notes": f.get("has_notes", False), - } + result.extend( + get_module_feature_list( + client, + project_id, + m.get("module_id", ""), + m.get("module_key", ""), ) + ) result.sort(key=lambda x: x["feature_key"]) return result @@ -253,7 +276,10 @@ def build_global_feature_list( phase_module_ids.add(mid) orphans = [ - {"module_id": m.get("id", ""), "module_key": m.get("module_key", m.get("key", ""))} + { + "module_id": m.get("id", ""), + "module_key": m.get("module_key", m.get("key", "")), + } for m in all_modules if not m.get("brainstorming_phase_id") and m.get("id", "") not in phase_module_ids diff --git a/src/mfbt/commands/ralph/ralph_widgets.py b/src/mfbt/commands/ralph/ralph_widgets.py index 3901c0d..76c01c0 100644 --- a/src/mfbt/commands/ralph/ralph_widgets.py +++ b/src/mfbt/commands/ralph/ralph_widgets.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import Any from rich.text import Text from textual.app import ComposeResult @@ -16,18 +16,16 @@ from textual.widgets import Button, DataTable, Label, RichLog, Static from mfbt.tui.colors import priority_color -from mfbt.tui.widgets.resource_table import ResourceTable - -if TYPE_CHECKING: - from mfbt.commands.ralph.types import RalphConfig class RalphStage(Enum): - """Lifecycle stages for the Ralph panel.""" + """Lifecycle stages for the unified feature panel. + + BROWSING is the default (normal feature list, no orchestration running). + RUNNING / PAUSED are entered when a Ralph run is started in place. + """ - LOADING = "loading" - OVERVIEW = "overview" - FEATURE_LIST = "feature_list" + BROWSING = "browsing" RUNNING = "running" PAUSED = "paused" @@ -49,76 +47,6 @@ class RalphStage(Enum): # --------------------------------------------------------------------------- -_STATUS_COLUMNS: list[tuple[str, str]] = [ - ("module", "MODULE"), - ("done", "DONE"), - ("in_progress", "IN PROGRESS"), - ("pending", "PENDING"), - ("progress", "PROGRESS"), -] - -_STATUS_COLUMNS_MULTI: list[tuple[str, str]] = [ - ("phase", "PHASE"), - ("module", "MODULE"), - ("done", "DONE"), - ("in_progress", "IN PROGRESS"), - ("pending", "PENDING"), - ("progress", "PROGRESS"), -] - - -def _format_status_module_row(data: dict[str, Any]) -> tuple[str, ...]: - """Format a module progress dict into table cells for the status summary.""" - is_total = data.get("_is_total", False) - is_orphan = data.get("_is_orphan", False) - multi_phase = data.get("_multi_phase", False) - - done = data.get("completed_features", 0) - in_prog = data.get("in_progress_features", 0) - pending = data.get("pending_features", 0) - pct = data.get("progress_percent", 0.0) - - if is_total: - done_str = f"[bold green]{done}[/bold green]" if done else "[dim]0[/dim]" - in_prog_str = f"[bold yellow]{in_prog}[/bold yellow]" if in_prog else "[dim]0[/dim]" - pending_str = f"[dim]{pending}[/dim]" - pct_str = f"[bold]{pct:.0f}%[/bold]" - row: list[str] = [] - if multi_phase: - row.append("") - row.extend(["[bold]Total[/bold]", done_str, in_prog_str, pending_str, pct_str]) - return tuple(row) - - key = data.get("module_key", "") - title = data.get("title", "") - label = f"{key} \u2014 {title}" if title else key - if pct >= 100: - label = f"[green]{label}[/green]" - elif done > 0 or in_prog > 0: - label = f"[yellow]{label}[/yellow]" - else: - label = f"[red]{label}[/red]" - - if is_orphan: - label = f"[italic]{label}[/italic]" - - done_str = f"[green]{done}[/green]" if done else "[dim]0[/dim]" - in_prog_str = f"[yellow]{in_prog}[/yellow]" if in_prog else "[dim]0[/dim]" - pending_str = f"[dim]{pending}[/dim]" - pct_str = f"{pct:.0f}%" - - row = [] - if multi_phase: - phase_title = data.get("_phase_title", "") - if is_orphan: - phase_title = "[dim italic]No Phase[/dim italic]" - row.append(phase_title) - row.extend([label, done_str, in_prog_str, pending_str, pct_str]) - return tuple(row) - - - - def _format_spn(has_spec: bool, has_plan: bool, has_notes: bool, dim_all: bool = False) -> str: """Format the SPN (Spec/PromptPlan/Notes) indicator.""" if dim_all: @@ -182,121 +110,6 @@ def _format_unified_feature_row(data: dict[str, Any], multi_phase: bool = False) return tuple(row) -class StatusSummary(Widget): - """Pre-confirmation status screen showing phase progress summary.""" - - def __init__( - self, - config: RalphConfig, - phases_progress: dict[str, dict], - orphan_modules: list[dict[str, Any]] | None = None, - all_features: list[dict[str, Any]] | None = None, - **kwargs: Any, - ) -> None: - super().__init__(**kwargs) - self._config = config - self._phases_progress = phases_progress - self._orphan_modules = orphan_modules or [] - self._all_features = all_features or [] - - def compose(self) -> ComposeResult: - phases = self._config.phases - multi_phase = len(phases) > 1 - - if multi_phase: - header_text = f"[bold cyan]\\[ralph][/bold cyan] Phases: [bold]{len(phases)} active[/bold]" - else: - header_text = f"[bold cyan]\\[ralph][/bold cyan] Phase: [bold]{phases[0].phase_title}[/bold]" - - yield Label(header_text, id="ralph-summary-header") - yield Label( - f" Agent: [bold]{self._config.coding_agent.value}[/bold]" - f" | Mode: [bold]{self._config.mode.value}[/bold]", - id="ralph-summary-agent", - ) - yield ResourceTable(id="ralph-summary-table") - - next_text = self._build_next_text() - yield Label(next_text, id="ralph-summary-next") - yield Label( - " Press [bold]c[/bold] to continue, [bold]esc[/bold] to go back", - id="ralph-summary-hint", - ) - - def on_mount(self) -> None: - table = self.query_one("#ralph-summary-table", ResourceTable) - rows = self._build_table_data() - multi_phase = len(self._config.phases) > 1 - columns = _STATUS_COLUMNS_MULTI if multi_phase else _STATUS_COLUMNS - table.set_rows(rows, columns, _format_status_module_row) - table.focus() - - def _build_table_data(self) -> list[dict[str, Any]]: - """Build the list of row dicts for the status table.""" - phases = self._config.phases - multi_phase = len(phases) > 1 - rows: list[dict[str, Any]] = [] - - total_done = 0 - total_in_progress = 0 - total_pending = 0 - total_features = 0 - - for phase in phases: - progress = self._phases_progress.get(phase.phase_id, {}) - modules: list[dict] = progress.get("modules", []) - - for m in modules: - row = dict(m) - row["_multi_phase"] = multi_phase - row["_phase_title"] = phase.phase_title - rows.append(row) - - total_done += m.get("completed_features", 0) - total_in_progress += m.get("in_progress_features", 0) - total_pending += m.get("pending_features", 0) - total_features += m.get("total_features", 0) - - # Orphan modules - for m in self._orphan_modules: - row = dict(m) - row["_multi_phase"] = multi_phase - row["_is_orphan"] = True - rows.append(row) - - total_done += m.get("completed_features", 0) - total_in_progress += m.get("in_progress_features", 0) - total_pending += m.get("pending_features", 0) - total_features += m.get("total_features", 0) - - # Total row - if total_features > 0: - overall_pct = (total_done / total_features * 100) if total_features > 0 else 0.0 - rows.append({ - "_is_total": True, - "_multi_phase": multi_phase, - "completed_features": total_done, - "in_progress_features": total_in_progress, - "pending_features": total_pending, - "progress_percent": overall_pct, - }) - - return rows - - def _build_next_text(self) -> str: - """Build the 'Next: ' label text from the local feature list.""" - for f in self._all_features: - if f.get("completion_status") == "completed": - continue - if not f.get("has_spec", False) or not f.get("has_prompt_plan", False): - continue - key = f.get("feature_key", "") - module = f.get("module_key", "") - suffix = f" ({module})" if module else "" - return f" Next: [bold]{key}[/bold]{suffix}" - return " [dim]All features completed.[/dim]" - - class RalphHeader(Widget): """Top header showing phase, agent, mode, and progress.""" @@ -559,15 +372,12 @@ def _build_help_text(self) -> str: def _row(key: str, desc: str) -> str: return f" [bold #326CE5]{key:>10}[/bold #326CE5] {desc}" - if self._stage == RalphStage.OVERVIEW: - lines.append("[bold]Overview[/bold]") - lines.append(_row("c", "Continue to feature list")) - lines.append(_row("esc", "Exit ralph")) - elif self._stage == RalphStage.FEATURE_LIST: + if self._stage == RalphStage.BROWSING: lines.append("[bold]Feature List[/bold]") lines.append(_row("r", "Run orchestration")) lines.append(_row("enter", "View feature details")) - lines.append(_row("esc", "Back to overview")) + lines.append(_row("d", "Describe feature")) + lines.append(_row("esc", "Back")) elif self._stage == RalphStage.RUNNING: lines.append("[bold]Running[/bold]") lines.append(_row("enter", "View feature details")) diff --git a/src/mfbt/commands/ralph/tui_display.py b/src/mfbt/commands/ralph/tui_display.py index d83b6ce..58404a1 100644 --- a/src/mfbt/commands/ralph/tui_display.py +++ b/src/mfbt/commands/ralph/tui_display.py @@ -103,7 +103,7 @@ def _update() -> None: # -- Feature list --------------------------------------------------------- def populate_features(self, features: list[dict[str, str]]) -> None: - """No-op: table is pre-populated by RalphPanel.populate_all().""" + """No-op: table is pre-populated by FeatureListPanel before the run.""" pass # -- Feature lifecycle --------------------------------------------------- diff --git a/src/mfbt/commands/ralph/types.py b/src/mfbt/commands/ralph/types.py index c029bd6..db64cc1 100644 --- a/src/mfbt/commands/ralph/types.py +++ b/src/mfbt/commands/ralph/types.py @@ -47,11 +47,30 @@ class RalphConfig: quiet: bool base_url: str special_instructions: str | None = None + module_ids: tuple[str, ...] | None = None + """When set, restrict orchestration to features whose ``module_id`` is in + this collection. + + ``None`` (or an empty tuple) means no module filtering — every module + within the resolved phase/project scope is processed. A non-empty tuple + restricts the run to those module IDs. The TUI currently passes a + single-module tuple for in-place single-module runs; the console + subcommand leaves this ``None``. See ``restricts_modules``. + """ @property def phase_ids(self) -> list[str]: return [p.phase_id for p in self.phases] + @property + def restricts_modules(self) -> bool: + """True when the run is scoped to a specific, non-empty module set. + + An empty tuple is treated identically to ``None`` (no restriction), + so this is the single source of truth for the sentinel logic. + """ + return bool(self.module_ids) + @property def is_single_phase(self) -> bool: return len(self.phases) == 1 diff --git a/src/mfbt/tui/app.py b/src/mfbt/tui/app.py index 0abc9ff..d22ead8 100644 --- a/src/mfbt/tui/app.py +++ b/src/mfbt/tui/app.py @@ -9,6 +9,7 @@ from __future__ import annotations +from contextlib import suppress from typing import TYPE_CHECKING, Any from textual.app import App, ComposeResult @@ -59,7 +60,8 @@ ("q", "Quit"), ("?", "Help"), ("ctrl+r", "Refresh"), - ("r", "Ralph"), + ("r", "Run"), + ("i", "Instr"), ("enter", "Detail"), ("d", "Describe"), ("esc", "Back"), @@ -80,7 +82,6 @@ class MFBTApp(App): Binding("backspace", "go_back", "Back", show=False), Binding("ctrl+r", "refresh", "Refresh", show=False), Binding("r", "launch_ralph", "Ralph", show=False), - Binding("c", "ralph_confirm", "Confirm", show=False), Binding("d", "describe", "Describe", show=False), Binding("p", "ralph_pause", "Pause", show=False), Binding("k", "ralph_kill", "Kill", show=False), @@ -100,8 +101,11 @@ def __init__( self.data_provider = TUIDataProvider(api_client) self.nav_state = NavigationState() self._token_monitor: Any = None - self._ralph_active: bool = False - self._ralph_panel: Any = None + # True when the feature panel was mounted as a transient global Ralph + # view from a higher nav level (exiting returns to that level). The + # panel itself is found via _current_feature_panel() — there is no + # separate cached reference to drift out of sync. + self._ralph_return: bool = False def compose(self) -> ComposeResult: yield K9sHeader(id="k9s-header") @@ -143,10 +147,24 @@ def _clear_main_content(self) -> None: container = self.query_one("#main-content", Container) container.remove_children() + def _current_feature_panel(self) -> Any: + """Return the FeatureListPanel mounted in #main-content, or None.""" + from mfbt.tui.screens.feature_list import FeatureListPanel + + try: + container = self.query_one("#main-content", Container) + except Exception: + return None + for child in container.children: + if isinstance(child, FeatureListPanel): + return child + return None + def _show_project_list(self) -> None: """Mount the project list panel.""" from mfbt.tui.screens.project_list import ProjectListPanel + self._ralph_return = False self._clear_main_content() container = self.query_one("#main-content", Container) container.mount(ProjectListPanel(self.data_provider)) @@ -161,6 +179,7 @@ def _show_phase_list( """Mount the phase list panel for a project.""" from mfbt.tui.screens.phase_list import PhaseListPanel + self._ralph_return = False self._clear_main_content() container = self.query_one("#main-content", Container) container.mount( @@ -187,6 +206,7 @@ def _show_module_list( """Mount the module list panel for a project, optionally filtered by phase.""" from mfbt.tui.screens.module_list import ModuleListPanel + self._ralph_return = False self._clear_main_content() container = self.query_one("#main-content", Container) container.mount( @@ -214,22 +234,33 @@ def _show_feature_list( module_key: str, module_metadata: dict[str, Any], ) -> None: - """Mount the feature list panel for a module.""" + """Mount the unified feature list panel for a module (browse scope).""" + from mfbt.config import get_mfbt_dir from mfbt.tui.screens.feature_list import FeatureListPanel + from mfbt.tui.screens.phase_list import _VIRTUAL_ORPHAN_PHASE_ID + + phase_id = self.nav_state.current_phase_id + if phase_id == _VIRTUAL_ORPHAN_PHASE_ID: + phase_id = None self._clear_main_content() container = self.query_one("#main-content", Container) - container.mount( - FeatureListPanel( - data_provider=self.data_provider, - project_id=project_id, - project_name=project_name, - module_id=module_id, - module_name=module_name, - module_key=module_key, - module_metadata=module_metadata, - ) + panel = FeatureListPanel( + data_provider=self.data_provider, + api_client=self.api_client, + project_id=project_id, + project_name=project_name, + mfbt_dir=get_mfbt_dir(), + config=self.config, + phase_id=phase_id, + scope="module", + module_id=module_id, + module_name=module_name, + module_key=module_key, + module_metadata=module_metadata, ) + container.mount(panel) + self._ralph_return = False # ---- Message handlers from content panels -------------------------- @@ -372,21 +403,6 @@ def on_module_list_panel_module_selected( ) self._update_nav_display() - def on_feature_list_panel_feature_selected( - self, event: Any - ) -> None: - """Handle feature selection: open detail modal.""" - from mfbt.tui.screens.feature_detail import FeatureDetailScreen - - feature_id = event.data.get("id", "") - self.push_screen( - FeatureDetailScreen( - data_provider=self.data_provider, - feature_id=feature_id, - feature_data=event.data, - ) - ) - # ---- Describe handlers from content panels ------------------------- def _handle_describe(self, data: dict[str, Any] | None, resource_type: str) -> None: @@ -400,12 +416,15 @@ def _handle_describe(self, data: dict[str, Any] | None, resource_type: str) -> N def action_go_back(self) -> None: """Navigate back one level.""" - # If ralph is active, try going back within ralph first - if self._ralph_active: - if self._ralph_panel is not None and self._ralph_panel.go_back(): - return # Stayed within ralph (e.g. feature list → overview) - self._stop_ralph() - return + panel = self._current_feature_panel() + if panel is not None: + if panel.go_back(): + return # handled within the panel (running / paused) + if self._ralph_return: + # Transient global Ralph view — return to the launch level. + self._stop_ralph() + return + # Normal drill-down feature list — fall through to nav up. level = self.nav_state.level if level == NavigationLevel.PROJECTS: @@ -463,9 +482,15 @@ def action_refresh(self) -> None: from mfbt.tui.screens.phase_list import PhaseListPanel from mfbt.tui.screens.project_list import ProjectListPanel + panel_types = ( + ProjectListPanel, + PhaseListPanel, + ModuleListPanel, + FeatureListPanel, + ) container = self.query_one("#main-content", Container) for child in container.children: - if isinstance(child, (ProjectListPanel, PhaseListPanel, ModuleListPanel, FeatureListPanel)): + if isinstance(child, panel_types): child.refresh_data() self.notify("Refreshed", timeout=3) break @@ -495,132 +520,101 @@ def action_describe(self) -> None: # ---- Ralph integration ------------------------------------------- def action_launch_ralph(self) -> None: - """Launch Ralph orchestration view, or run if already on feature list.""" - if self._ralph_active: - # Re-use 'r' to start the run from the feature list stage - if self._ralph_panel is not None: - self._ralph_panel.confirm_and_start() + """Press 'r': run Ralph on the current feature list, or open it. + + On a feature list → start/retry the run in place. From a higher nav + level → mount the unified feature list scoped to that level (user + presses 'r' again there to actually run). + """ + panel = self._current_feature_panel() + if panel is not None: + panel.start_ralph() return - from mfbt.tui.screens.ralph_panel import RalphPanel + from mfbt.tui.screens.project_list import ProjectListPanel # Determine project scope project_id = self.nav_state.current_project_id + project_name = "" + if self.nav_state.stack: + project_name = self.nav_state.stack[0].name if project_id is None: - # At project list — need a highlighted project - from mfbt.tui.screens.project_list import ProjectListPanel - container = self.query_one("#main-content", Container) for child in container.children: if isinstance(child, ProjectListPanel): data = child.get_selected_data() if data: project_id = data.get("id") + project_name = data.get("name", data.get("title", "")) break if project_id is None: self.notify("Select a project first", severity="warning") return - # Determine phase scope + from mfbt.config import get_mfbt_dir + from mfbt.tui.screens.feature_list import FeatureListPanel from mfbt.tui.screens.phase_list import _VIRTUAL_ORPHAN_PHASE_ID phase_id = self.nav_state.current_phase_id if phase_id == _VIRTUAL_ORPHAN_PHASE_ID: phase_id = None # Virtual phase — run ralph at project scope - from mfbt.config import get_mfbt_dir - - self._ralph_active = True - panel = RalphPanel( + self._clear_main_content() + container = self.query_one("#main-content", Container) + new_panel = FeatureListPanel( + data_provider=self.data_provider, api_client=self.api_client, project_id=project_id, + project_name=project_name, mfbt_dir=get_mfbt_dir(), config=self.config, phase_id=phase_id, + scope="global", ) - self._ralph_panel = panel - self._clear_main_content() - container = self.query_one("#main-content", Container) - container.mount(panel) - - # Update hints for ralph mode - try: - command_bar = self.query_one("#command-bar", CommandBar) - command_bar.hints = ( - ("c", "Continue"), - ("esc", "Back"), - ("?", "Help"), - ("q", "Quit"), - ) - except Exception: - pass - - try: - header = self.query_one("#k9s-header", K9sHeader) - header.hints = ( - ("c", "Continue"), - ("esc", "Back"), - ("?", "Help"), - ("q", "Quit"), - ) - header.ralph_active = True - # Show project name in ralph header - project_name = "" - if self.nav_state.stack: - project_name = self.nav_state.stack[0].name - elif project_id: - # At project list — try to get name from highlighted row - from mfbt.tui.screens.project_list import ProjectListPanel - - container = self.query_one("#main-content", Container) - for child in container.children: - if isinstance(child, ProjectListPanel): - data = child.get_selected_data() - if data: - project_name = data.get("name", data.get("title", "")) - break - header.ralph_project_name = project_name - except Exception: - pass + container.mount(new_panel) + self._ralph_return = True - def action_ralph_confirm(self) -> None: - """Delegate 'c' key to the RalphPanel.""" - if self._ralph_active and self._ralph_panel is not None: - self._ralph_panel.confirm_and_start() + hints = _LEVEL_HINTS[NavigationLevel.FEATURES] + for widget_id, cls in ( + ("#command-bar", CommandBar), + ("#k9s-header", K9sHeader), + ): + with suppress(Exception): + self.query_one(widget_id, cls).hints = hints def action_ralph_pause(self) -> None: - """Delegate 'p' key to the RalphPanel.""" - if self._ralph_active and self._ralph_panel is not None: - self._ralph_panel.pause_orchestrator() + """Delegate 'p' key to the feature panel.""" + panel = self._current_feature_panel() + if panel is not None: + panel.pause_orchestrator() def action_ralph_kill(self) -> None: - """Delegate 'k' key to the RalphPanel.""" - if self._ralph_active and self._ralph_panel is not None: - self._ralph_panel.kill_agent() + """Delegate 'k' key to the feature panel.""" + panel = self._current_feature_panel() + if panel is not None: + panel.kill_agent() def action_ralph_instructions(self) -> None: """Open special instructions modal for the current project.""" - if not self._ralph_active or self._ralph_panel is None: - return from mfbt.commands.ralph.ralph_widgets import RalphStage - if self._ralph_panel.stage not in (RalphStage.OVERVIEW, RalphStage.FEATURE_LIST): + panel = self._current_feature_panel() + if panel is None or panel.stage != RalphStage.BROWSING: return from mfbt.config import load_special_instructions, save_special_instructions from mfbt.tui.screens.instructions_modal import InstructionsModal - project_id = self._ralph_panel._project_id + project_id = panel.project_id current_text = load_special_instructions(project_id) def _on_result(result: str | None) -> None: if result is None: return # Cancelled save_special_instructions(project_id, result if result else None) - if self._ralph_panel is not None: - self._ralph_panel.update_special_instructions( - result if result.strip() else None - ) + panel.update_special_instructions( + result if result.strip() else None + ) self.push_screen( InstructionsModal(project_id=project_id, current_text=current_text), @@ -628,11 +622,11 @@ def _on_result(result: str | None) -> None: ) def _stop_ralph(self) -> None: - """Stop ralph orchestrator and restore the previous panel.""" - if self._ralph_panel is not None: - self._ralph_panel.stop() - self._ralph_panel = None - self._ralph_active = False + """Stop a transient global Ralph view and restore the launch level.""" + panel = self._current_feature_panel() + if panel is not None: + panel.stop() + self._ralph_return = False try: header = self.query_one("#k9s-header", K9sHeader) @@ -701,6 +695,10 @@ def show_auth_required_modal(self) -> None: """Push the auth-required modal and handle the result.""" from mfbt.tui.screens.auth_modal import AuthRequiredModal + # Multiple panel workers can fail at once — only show one modal. + if isinstance(self.screen, AuthRequiredModal): + return + def _on_result(authenticate: bool) -> None: if authenticate: self._do_interactive_auth() @@ -813,17 +811,24 @@ def on_resize(self) -> None: self.remove_class("narrow") async def action_quit(self) -> None: - if self._ralph_active and self._ralph_panel is not None: - self._ralph_panel.stop() + panel = self._current_feature_panel() + if panel is not None: + panel.stop() if self._token_monitor is not None: self._token_monitor.stop() await super().action_quit() def action_show_help(self) -> None: - if self._ralph_active and self._ralph_panel is not None: + from mfbt.commands.ralph.ralph_widgets import RalphStage + + panel = self._current_feature_panel() + if panel is not None and panel.stage in ( + RalphStage.RUNNING, + RalphStage.PAUSED, + ): from mfbt.commands.ralph.ralph_widgets import RalphHelpScreen - self.push_screen(RalphHelpScreen(self._ralph_panel.stage)) + self.push_screen(RalphHelpScreen(panel.stage)) return from mfbt.tui.screens.help_screen import HelpScreen diff --git a/src/mfbt/tui/app.tcss b/src/mfbt/tui/app.tcss index 80dac21..067fe28 100644 --- a/src/mfbt/tui/app.tcss +++ b/src/mfbt/tui/app.tcss @@ -68,7 +68,7 @@ K9sHeader { /* ─── Content Panels (fill main-content) ─── */ -ProjectListPanel, PhaseListPanel, ModuleListPanel, FeatureListPanel, RalphPanel { +ProjectListPanel, PhaseListPanel, ModuleListPanel, FeatureListPanel { height: 1fr; } @@ -204,52 +204,7 @@ HelpScreen { padding: 1 2; } -/* ─── Ralph Widgets (merged from tui_app.tcss) ─── */ - -StatusSummary { - width: 100%; - height: 1fr; -} - -#ralph-summary-header { - height: 1; - padding: 1 1 0 1; -} - -#ralph-summary-agent { - height: 1; - padding: 0 1; - color: $text-dim; -} - -#ralph-summary-table { - height: 1fr; - margin: 1 0; -} - -#ralph-summary-next { - height: 1; - padding: 0 1; -} - -#ralph-summary-hint { - height: 1; - padding: 0 1; - color: $text-dim; -} - -#feature-list-title { - height: 1; - background: $surface; - padding: 0 1; - color: $accent; - text-style: bold; -} - -#feature-list-legend { - height: 1; - padding: 0 1; -} +/* ─── Ralph Widgets (revealed in-place during a run) ─── */ RalphHeader { height: auto; diff --git a/src/mfbt/tui/screens/auth_modal.py b/src/mfbt/tui/screens/auth_modal.py index d88521f..ebf573f 100644 --- a/src/mfbt/tui/screens/auth_modal.py +++ b/src/mfbt/tui/screens/auth_modal.py @@ -28,20 +28,15 @@ def compose(self) -> ComposeResult: ) yield Label("") yield Static( - "Your session has expired or your credentials are invalid.\n" - "Please re-authenticate to continue.", - ) - yield Label("") - yield Static( - "[dim]You can also authenticate from the command line:\n" - " mfbt auth login[/dim]", + "You are not signed in, or your session has expired.\n" + "Log in to open your browser and authenticate.", ) yield Label("") with Horizontal(id="auth-modal-buttons"): yield Button( - "Authenticate", id="auth-authenticate", variant="success" + "Log In", id="auth-authenticate", variant="success" ) - yield Button("Cancel", id="auth-cancel") + yield Button("Quit", id="auth-cancel", variant="error") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "auth-authenticate": diff --git a/src/mfbt/tui/screens/feature_list.py b/src/mfbt/tui/screens/feature_list.py index 6c62bfd..2d502ec 100644 --- a/src/mfbt/tui/screens/feature_list.py +++ b/src/mfbt/tui/screens/feature_list.py @@ -1,138 +1,251 @@ -"""Feature list panel for the TUI. +"""Unified feature list panel for the TUI. -A Widget that mounts inside #main-content, not a pushed Screen. -This keeps the chrome (header, breadcrumb, command bar, status bar) visible. +A single Widget that mounts inside #main-content and serves both roles: + +* **Browsing** — the normal feature list (replaces the old per-module list + and the old standalone Ralph overview/feature-list screens). +* **Running** — Ralph orchestration runs *in place* in the same panel: + a header, the same feature table (now updating live), an agent-output + log and a status bar are revealed below the list. + +Scope is decided at construction: + +* ``scope="module"`` — features for a single module (normal drill-down). +* ``scope="global"`` — every feature across the resolved phases/orphan + modules of the project (Ralph launched from a higher nav level). + +The chrome (header, command bar, status bar) stays mounted by the app; this +panel only owns #main-content. The duck-typed ``RalphTUIDisplay`` drives the +Ralph sub-widgets via this panel's attributes and widget ids. """ from __future__ import annotations import logging +import time +from contextlib import suppress +from pathlib import Path from typing import TYPE_CHECKING, Any +from rich.text import Text from textual.app import ComposeResult -from textual.message import Message from textual.widget import Widget -from textual.widgets import Label +from textual.widgets import DataTable, Static +from mfbt.commands.ralph.ralph_widgets import ( + _GLISTEN_COLORS, + AgentOutputLog, + FeatureProgressTable, + LogPaneHeader, + RalphHeader, + RalphStage, + RalphStatusBar, +) +from mfbt.exceptions import AuthenticationRequired from mfbt.token_manager import TokenError -from mfbt.tui.colors import bool_indicator, priority_color, status_color -from mfbt.tui.widgets.resource_table import ResourceTable if TYPE_CHECKING: + from mfbt.api_client import APIClient from mfbt.tui.data_provider import TUIDataProvider logger = logging.getLogger("mfbt.tui.screens.feature_list") -PAGE_SIZE = 25 - -_COLUMNS: list[tuple[str, str]] = [ - ("key", "KEY"), - ("name", "NAME"), - ("priority", "PRIORITY"), - ("status", "STATUS"), - ("spec", "SPEC"), - ("plan", "PLAN"), - ("impls", "IMPLS"), - ("category", "CATEGORY"), -] - - -def _format_feature_row(data: dict[str, Any]) -> tuple[str, ...]: - """Format a feature dict into table cells.""" - key = data.get("feature_key", data.get("key", "")) - name = data.get("title", data.get("name", "Unnamed")) - priority = data.get("priority", "") - status = data.get("completion_status", data.get("status", "")) - has_spec = data.get("has_spec", False) - has_plan = data.get("has_prompt_plan", False) - impl_count = data.get("implementation_count", data.get("impl_count", "")) - category = data.get("category", "") +# Hints shown while browsing the feature list (matches the app's FEATURES +# level hints, plus the Ralph instructions key). +_BROWSING_HINTS: tuple[tuple[str, str], ...] = ( + ("q", "Quit"), + ("?", "Help"), + ("ctrl+r", "Refresh"), + ("r", "Run"), + ("i", "Instr"), + ("enter", "Detail"), + ("d", "Describe"), + ("esc", "Back"), +) + + +def _is_actionable(f: dict[str, Any]) -> bool: + """A feature Ralph can act on: pending, with spec and prompt plan.""" return ( - f"[dim]{key}[/dim]", - f"[bold]{name}[/bold]", - priority_color(priority) if priority else "[dim]-[/dim]", - status_color(status) if status else "[dim]-[/dim]", - bool_indicator(has_spec), - bool_indicator(has_plan), - str(impl_count) if impl_count else "[dim]0[/dim]", - str(category) if category else "[dim]-[/dim]", + f.get("completion_status", "pending") != "completed" + and f.get("has_spec", False) + and f.get("has_prompt_plan", False) ) class FeatureListPanel(Widget): - """Content panel showing features for a selected module.""" - - class FeatureSelected(Message): - """Fired when a feature is selected (for detail modal).""" - - def __init__(self, data: dict[str, Any]) -> None: - super().__init__() - self.data = data + """Content panel showing features for a module or whole project scope. - class DescribeRequested(Message): - """Fired when describe is requested on a feature.""" - - def __init__(self, data: dict[str, Any]) -> None: - super().__init__() - self.data = data + Doubles as the Ralph orchestration view (running in place). + """ def __init__( self, data_provider: TUIDataProvider, + api_client: APIClient, project_id: str, - project_name: str, - module_id: str, - module_name: str, + mfbt_dir: Path, + config: dict[str, Any], + project_name: str = "", + phase_id: str | None = None, + scope: str = "global", + module_id: str = "", + module_name: str = "", module_key: str = "", module_metadata: dict[str, Any] | None = None, ) -> None: super().__init__() self._data_provider = data_provider + self._api_client = api_client self.project_id = project_id self.project_name = project_name + self._mfbt_dir = mfbt_dir + self._config = config + self._phase_id = phase_id + self._scope = scope self.module_id = module_id self.module_name = module_name self.module_key = module_key self.module_metadata = module_metadata or {} - self._offset = 0 - self._has_more = False + + # Ralph lifecycle / orchestration state + self._stage: RalphStage = RalphStage.BROWSING + self._orchestrator: Any = None + self._orchestrator_client: APIClient | None = None + self._ralph_config: Any = None + self._phases: list[Any] = [] + self._phases_progress: dict[str, dict] = {} + self._pending_features: list[dict[str, Any]] = [] + self._multi_phase: bool = False + self._special_instructions: str | None = None + + # Timers / counters used by RalphTUIDisplay + the running view + self._session_start: float = 0.0 + self._paused_accumulated: float = 0.0 + self._pause_start: float = 0.0 + self._feature_start: float = 0.0 + self._feature_pause_snapshot: float = 0.0 + self._session_stopped: bool = False + self.session_log_dir: Path | None = None + self._running_feature_key: str | None = None + self._glisten_frame: int = 0 + self._tick_timer: Any = None + self._glisten_timer: Any = None + + # ---- Properties ---------------------------------------------------- + + @property + def stage(self) -> RalphStage: + return self._stage + + # ---- Compose / load ------------------------------------------------ def compose(self) -> ComposeResult: - yield Label( - f" [bold #326CE5]Features[/bold #326CE5] [dim]— {self.module_name}[/dim]", + title = self.module_name if self._scope == "module" else self.project_name + yield Static( + f" [bold #326CE5]Features[/bold #326CE5] [dim]— {title}[/dim]", id="screen-title", ) - yield ResourceTable(id="resource-table") + yield RalphHeader(id="ralph-header", classes="-hidden") + yield FeatureProgressTable(id="feature-table") + yield LogPaneHeader(id="log-pane-header", classes="-hidden") + yield AgentOutputLog(id="agent-output", classes="-hidden") + yield RalphStatusBar(id="ralph-status-bar", classes="-hidden") def on_mount(self) -> None: - self._offset = 0 - self._load_data() - self.query_one("#resource-table", ResourceTable).focus() + self.run_worker(self._load_features, thread=True) + + def _load_features(self) -> None: + """Resolve scope, fetch the feature list, build the RalphConfig.""" + import typer - def _load_data(self) -> None: - self.run_worker(self._fetch_and_display, thread=True) + from mfbt.commands.ralph.progress import ( + build_global_feature_list, + get_module_feature_list, + get_phase_progress, + resolve_phases, + ) + from mfbt.commands.ralph.types import ( + AgentType, + OrchestrationMode, + RalphConfig, + ) + from mfbt.config import load_special_instructions - def _fetch_and_display(self) -> None: try: - page = self._data_provider.fetch_features( - self.project_id, - module_id=self.module_id, - limit=PAGE_SIZE, - offset=self._offset, - ) - table = self.query_one("#resource-table", ResourceTable) - if self._offset == 0: - self.app.call_from_thread( - table.set_rows, page.items, _COLUMNS, _format_feature_row + self._special_instructions = load_special_instructions(self.project_id) + + # resolve_phases raises typer.Exit when the project has no active + # phases (or a phase hint doesn't resolve); fall back to an + # orphan-only list. Auth errors propagate to the auth handler + # below; any *other* API error propagates to the outer handler so + # the user is told the fetch failed instead of silently seeing an + # empty list. + try: + phases = resolve_phases( + self._api_client, self.project_id, self._phase_id + ) + except (TokenError, AuthenticationRequired): + raise + except typer.Exit: + logger.info( + "resolve_phases returned no phases for project %s " + "(phase_id=%s); falling back to orphan modules only", + self.project_id, + self._phase_id, + ) + phases = [] + self._phases = list(phases) + + if self._scope == "module" and self.module_id: + # Single-module browse/run: one targeted call for just this + # module's features instead of building the whole project + # feature list and discarding all but one module. phases is + # still resolved above so the orchestrator (which re-applies + # module_ids over the global list at run time) stays scoped. + module_ids: tuple[str, ...] | None = (self.module_id,) + self._phases_progress = {} + all_features = get_module_feature_list( + self._api_client, + self.project_id, + self.module_id, + self.module_key, ) + self._multi_phase = False else: - self.app.call_from_thread( - table.append_rows, page.items, _format_feature_row + module_ids = None + self._phases_progress = { + p.phase_id: get_phase_progress(self._api_client, p.phase_id) + for p in phases + } + all_features = build_global_feature_list( + self._api_client, + self.project_id, + list(phases), + self._phases_progress, ) - self._has_more = page.has_more - self.app.call_from_thread(setattr, table, "has_more", page.has_more) - self._update_status_count(page.total) - except TokenError: + self._multi_phase = len(phases) > 1 + + self._pending_features = all_features + + base_url = self._config.get("base_url", "https://app.mfbt.ai") + self._ralph_config = RalphConfig( + project_id=self.project_id, + phases=tuple(phases), + coding_agent=AgentType.CLAUDE, + mode=OrchestrationMode.FEATURE_BY_FEATURE, + max_turns=None, + quiet=False, + base_url=base_url, + special_instructions=self._special_instructions, + module_ids=module_ids, + ) + + self.app.call_from_thread(self._populate_table) + self.app.call_from_thread( + self._update_status_count, len(all_features) + ) + except (TokenError, AuthenticationRequired): logger.warning("Authentication required while fetching features") self.app.call_from_thread(self.app.show_auth_required_modal) except Exception as exc: @@ -143,30 +256,576 @@ def _fetch_and_display(self) -> None: severity="error", ) + def _populate_table(self) -> None: + """Fill the table on the main thread (called via call_from_thread).""" + try: + table = self.query_one("#feature-table", FeatureProgressTable) + except Exception: + return + table.populate_all(self._pending_features, multi_phase=self._multi_phase) + if self._stage == RalphStage.BROWSING: + table.scroll_to_pending() + table.focus() + def _update_status_count(self, count: int) -> None: + """Update the status-bar resource count. + + Must be called on the main thread (callers marshal via + ``call_from_thread``); mutating the widget off-thread is a race. + """ try: from mfbt.tui.widgets.status_bar import StatusBar status_bar = self.app.query_one("#status-bar", StatusBar) - self.app.call_from_thread(setattr, status_bar, "resource_count", count) + status_bar.resource_count = count + except Exception: + logger.debug("Failed to update status count", exc_info=True) + + # ---- Ralph: start / lifecycle ------------------------------------- + + def start_ralph(self) -> None: + """Triggered by the 'r' key. + + BROWSING -> preflight modal -> running. + PAUSED -> retry the selected feature. + RUNNING -> no-op. + """ + if self._stage == RalphStage.PAUSED: + self.retry_feature() + return + if self._stage != RalphStage.BROWSING: + return + if self._ralph_config is None: + self.app.notify("Still loading features…", severity="information") + return + + if not self._pending_features: + self.app.notify( + "No features found for this " + f"{'module' if self._scope == 'module' else 'scope'}.", + severity="warning", + timeout=5, + ) + return + + actionable = sum(1 for f in self._pending_features if _is_actionable(f)) + if actionable == 0: + self.app.notify( + "All features have already been implemented.", + severity="information", + timeout=5, + ) + return + + from mfbt.tui.screens.preflight_modal import PreflightModal + + self.app.push_screen( + PreflightModal(project_dir=Path.cwd()), + callback=self._on_preflight_complete, + ) + + def _on_preflight_complete(self, agent_type: Any) -> None: + if agent_type is None: + return # cancelled + + from dataclasses import replace + + from mfbt.commands.ralph.types import AgentType + + if isinstance(agent_type, AgentType) and self._ralph_config is not None: + self._ralph_config = replace(self._ralph_config, coding_agent=agent_type) + + self._start_running() + + def _start_running(self) -> None: + """Reveal the Ralph chrome and kick off the orchestrator worker.""" + self._stage = RalphStage.RUNNING + + for widget_id in ( + "#ralph-header", + "#log-pane-header", + "#agent-output", + "#ralph-status-bar", + ): + with suppress(Exception): + self.query_one(widget_id).remove_class("-hidden") + + self._set_header_ralph(True) + + self._session_start = time.monotonic() + self._paused_accumulated = 0.0 + self._pause_start = 0.0 + self._feature_start = 0.0 + self._feature_pause_snapshot = 0.0 + self._session_stopped = False + self._cancel_timers() + self._tick_timer = self.set_interval(1.0, self._tick_elapsed) + self._glisten_timer = self.set_interval(0.2, self._glisten_tick) + self._set_running_hints() + self.run_worker(self._run_orchestrator, thread=True) + + def go_back(self) -> bool: + """Handle ESC. + + Returns True when handled within the panel (stay here), False to let + the app perform normal navigation / exit a transient global view. + + * RUNNING — swallowed (use 'p' to pause, 'q' to quit). + * PAUSED — stop and return to the in-place browsing list. + * BROWSING — not handled (app decides). + """ + if self._stage == RalphStage.RUNNING: + return True + if self._stage == RalphStage.PAUSED: + self._return_to_browsing() + return True + return False + + def _return_to_browsing(self) -> None: + """Tear down a finished/stopped run and show the fresh list.""" + self.stop() + self._stage = RalphStage.BROWSING + self._running_feature_key = None + self._feature_start = 0.0 + self._session_stopped = True + self._cancel_timers() + for widget_id in ( + "#ralph-header", + "#log-pane-header", + "#agent-output", + "#ralph-status-bar", + ): + with suppress(Exception): + self.query_one(widget_id).add_class("-hidden") + self._set_header_ralph(False) + self._update_hints(*_BROWSING_HINTS) + # Reload to reflect features completed during the run. + self.run_worker(self._load_features, thread=True) + + # ---- Ralph: pause / kill / retry ---------------------------------- + + def stop(self) -> None: + """Stop the orchestrator and release its dedicated client.""" + self._cancel_timers() + if self._orchestrator is not None: + try: + self._orchestrator.stop() + except Exception: + # Best-effort teardown, but leave a breadcrumb: a failed + # stop() can mean an orphaned coding-agent subprocess. + logger.debug( + "orchestrator.stop() failed during teardown", + exc_info=True, + ) + if self._orchestrator_client is not None: + try: + self._orchestrator_client.close() + except Exception: + logger.debug( + "orchestrator client close() failed during teardown", + exc_info=True, + ) + self._orchestrator_client = None + + def pause_orchestrator(self) -> None: + if self._stage != RalphStage.RUNNING: + return + + from mfbt.commands.ralph.ralph_widgets import PauseModal + + self._stage = RalphStage.PAUSED + self._pause_start = time.monotonic() + + def _on_pause_result(result: str | None) -> None: + if result == "stop": + if self._orchestrator is not None: + self._orchestrator.stop() + if self._pause_start > 0: + self._paused_accumulated += time.monotonic() - self._pause_start + self._pause_start = 0.0 + self._set_paused_hints() + try: + log = self.query_one("#agent-output", AgentOutputLog) + log.write( + Text.from_markup( + "[yellow]Paused — will stop after current feature. " + "Press [bold]r[/bold] to retry, " + "[bold]esc[/bold] to go back.[/yellow]" + ) + ) + except Exception: + logger.debug("Could not write pause notice", exc_info=True) + else: + if self._pause_start > 0: + self._paused_accumulated += time.monotonic() - self._pause_start + self._pause_start = 0.0 + self._stage = RalphStage.RUNNING + self._set_running_hints() + + self.app.push_screen(PauseModal(), callback=_on_pause_result) + + def kill_agent(self) -> None: + if self._stage not in (RalphStage.RUNNING, RalphStage.PAUSED): + return + if self._orchestrator is None: + return + try: + self._orchestrator._agent.terminate() + except Exception: + logger.exception("Failed to terminate agent process") + self.app.notify( + "Could not confirm the agent was killed — check for stray " + "processes", + severity="error", + ) + return + try: + log = self.query_one("#agent-output", AgentOutputLog) + log.write(Text.from_markup("[red bold]Agent killed by user[/red bold]")) + except Exception: + logger.debug("Could not write kill notice to log", exc_info=True) + self.app.notify("Agent process killed", severity="warning") + + def retry_feature(self) -> None: + if self._stage != RalphStage.PAUSED: + return + if self._orchestrator is None: + return + + try: + table = self.query_one("#feature-table", FeatureProgressTable) + row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key + feature_key = str(row_key.value) except Exception: - pass + return - def on_resource_table_resource_selected( - self, event: ResourceTable.ResourceSelected + if not feature_key: + return + + feature_info = None + for f in self._pending_features: + if f.get("feature_key") == feature_key: + feature_info = f + break + + if feature_info is None: + self.app.notify("Feature not found", severity="warning") + return + + from mfbt.commands.ralph.types import FeatureOutcome + + try: + status_bar = self.query_one("#ralph-status-bar", RalphStatusBar) + prev_results = [ + r for r in self._orchestrator._results + if r.feature.feature_key == feature_key + ] + if prev_results: + last = prev_results[-1] + if last.outcome == FeatureOutcome.SUCCESS: + status_bar.succeeded = max(0, status_bar.succeeded - 1) + elif last.outcome == FeatureOutcome.FAILED: + status_bar.failed = max(0, status_bar.failed - 1) + status_bar.pending += 1 + except Exception: + logger.debug("Could not adjust status bar for retry", exc_info=True) + + try: + table.update_feature( + feature_key, status="[dim]pending[/dim]", time_str="-", attempts=0 + ) + except Exception: + logger.debug("Could not reset feature row for retry", exc_info=True) + + self._stage = RalphStage.RUNNING + self._set_running_hints() + self.run_worker( + lambda: self._run_single_retry(feature_info, feature_key), + thread=True, + ) + + def _restore_paused(self) -> None: + """Return to the PAUSED stage (main thread); used after a retry.""" + self._stage = RalphStage.PAUSED + self._set_paused_hints() + + def _run_single_retry(self, feature_info: dict, feature_key: str) -> None: + from mfbt.commands.ralph.progress import resolve_feature_task + from mfbt.commands.ralph.types import FeatureOutcome + + try: + feature = resolve_feature_task( + self._orchestrator_client, feature_info + ) + if feature is None: + def _notify_error() -> None: + self.app.notify( + f"Could not resolve {feature_key}", severity="error" + ) + self._restore_paused() + self.app.call_from_thread(_notify_error) + return + + self._orchestrator._stop_event.clear() + result = self._orchestrator._implement_feature(feature, 0, 0) + self._orchestrator._results.append(result) + + if result.outcome == FeatureOutcome.SUCCESS: + feature_info["completion_status"] = "completed" + + def _back_to_paused() -> None: + self._stage = RalphStage.PAUSED + try: + header = self.query_one("#ralph-header", RalphHeader) + header.session_state = "complete" + except Exception: + logger.debug( + "Could not update ralph header", exc_info=True + ) + self._set_paused_hints() + self.app.call_from_thread(_back_to_paused) + except (TokenError, AuthenticationRequired): + logger.warning( + "Authentication required during retry of %s", feature_key + ) + self.app.call_from_thread(self.app.show_auth_required_modal) + self.app.call_from_thread(self._restore_paused) + except Exception as exc: + logger.exception("Retry of %s failed", feature_key) + self.app.call_from_thread( + self.app.notify, + f"Retry of {feature_key} failed: {exc}", + severity="error", + ) + self.app.call_from_thread(self._restore_paused) + + # ---- Row selection / logs ----------------------------------------- + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Enter on a feature row — open the feature detail modal.""" + feature_key = str(event.row_key.value) + try: + table = self.query_one("#feature-table", FeatureProgressTable) + data = table.get_feature_data(feature_key) + except Exception: + return + if data and data.get("id"): + from mfbt.tui.screens.feature_detail import FeatureDetailScreen + + self.app.push_screen( + FeatureDetailScreen( + data_provider=self._data_provider, + feature_id=data["id"], + feature_data=data, + ) + ) + + def on_feature_progress_table_show_logs( + self, event: FeatureProgressTable.ShowLogs ) -> None: - """Open feature detail modal.""" - self.post_message(self.FeatureSelected(event.data)) + if self._stage not in (RalphStage.RUNNING, RalphStage.PAUSED): + return + self._show_log_modal(event.feature_key) + + def _show_log_modal(self, feature_key: str) -> None: + from mfbt.commands.ralph.ralph_widgets import LogViewerModal + + if self.session_log_dir is None or not self.session_log_dir.exists(): + self.app.notify("No logs available yet", severity="warning") + return + + from mfbt.commands.ralph.log_capture import _sanitize_key + + sanitized = _sanitize_key(feature_key) + log_files = sorted(self.session_log_dir.glob(f"{sanitized}_attempt-*.log")) + + if not log_files: + self.app.notify(f"No logs for {feature_key} yet", severity="warning") + return + + content_parts = [] + for lf in log_files: + try: + content_parts.append(lf.read_text()) + except OSError: + content_parts.append(f"(could not read {lf.name})") + + content = "\n\n".join(content_parts) + self.app.push_screen(LogViewerModal(content, f" Logs: {feature_key}")) + + # ---- Timers / animation ------------------------------------------- + + def _cancel_timers(self) -> None: + for timer in (self._tick_timer, self._glisten_timer): + if timer is not None: + with suppress(Exception): + timer.stop() + self._tick_timer = None + self._glisten_timer = None + + def _tick_elapsed(self) -> None: + # Runs on a 1s interval; a transient query miss during a + # mount/unmount race is expected and harmless. + with suppress(Exception): + status_bar = self.query_one("#ralph-status-bar", RalphStatusBar) + now = time.monotonic() + + if not self._session_stopped: + paused = self._paused_accumulated + if self._pause_start > 0: + paused += now - self._pause_start + status_bar.elapsed_seconds = now - self._session_start - paused + + if self._feature_start > 0: + paused = self._paused_accumulated + if self._pause_start > 0: + paused += now - self._pause_start + feature_paused = paused - self._feature_pause_snapshot + status_bar.feature_elapsed_seconds = ( + now - self._feature_start - feature_paused + ) + status_bar.show_feature_timer = True + else: + status_bar.show_feature_timer = False + + def _glisten_tick(self) -> None: + key = self._running_feature_key + if key is None: + return + self._glisten_frame = (self._glisten_frame + 1) % len(_GLISTEN_COLORS) + color = _GLISTEN_COLORS[self._glisten_frame] + with suppress(Exception): + table = self.query_one("#feature-table", FeatureProgressTable) + table.update_cell( + key, "status", Text.from_markup(f"[{color}]⟳ running[/{color}]") + ) + with suppress(Exception): + header = self.query_one("#log-pane-header", LogPaneHeader) + header.update( + Text.from_markup(f" [{color}]⟳ Implementing {key}[/{color}]") + ) + + # ---- Orchestrator wiring ------------------------------------------ + + def _build_uncached_client(self) -> APIClient: + """Create an APIClient without response caching for the orchestrator. + + The orchestrator needs fresh API responses (especially for + verify_feature_completed) because feature status is mutated + externally by the coding agent via MCP. + """ + from mfbt.api_client import APIClient + from mfbt.token_manager import TokenManager + + base_url = self._config.get("base_url", "https://app.mfbt.ai") + tm = TokenManager(base_url) + + def _on_auth_required() -> bool: + # The interactive browser OAuth flow can't run from inside this + # orchestration worker, so we can't re-auth in place. Returning + # False makes APIClient raise AuthenticationRequired; + # _run_orchestrator / _run_single_retry catch it, surface the + # auth modal and tear the run down cleanly — so the user sees an + # auth prompt instead of a misdiagnosed "feature failed". + return False + + return APIClient(base_url, tm, cache=None, on_auth_required=_on_auth_required) + + def _run_orchestrator(self) -> None: + from mfbt.commands.ralph.log_capture import RalphLogger + from mfbt.commands.ralph.orchestrator import RalphOrchestrator + from mfbt.commands.ralph.tui_display import RalphTUIDisplay + + try: + display = RalphTUIDisplay(self) + + log_base_dir = self._mfbt_dir / "logs" + ralph_logger = RalphLogger( + log_base_dir, self._ralph_config.coding_agent + ) + + self._orchestrator_client = self._build_uncached_client() + self._orchestrator = RalphOrchestrator( + self._ralph_config, + self._orchestrator_client, + display, + logger=ralph_logger, + ) + self._orchestrator.run() + except (TokenError, AuthenticationRequired): + # Token expired mid-run (1hr access tokens, long runs). Surface + # the auth modal and tear down instead of crashing the whole TUI + # and orphaning the agent subprocess. + logger.warning("Authentication required during Ralph run") + self.app.call_from_thread(self.app.show_auth_required_modal) + self.app.call_from_thread(self._return_to_browsing) + except Exception as exc: + logger.exception("Ralph orchestration failed") + self.app.call_from_thread( + self.app.notify, + f"Ralph run failed: {exc}", + severity="error", + ) + self.app.call_from_thread(self._return_to_browsing) + + # ---- Hints / header helpers --------------------------------------- + + def _update_hints(self, *hints: tuple[str, str]) -> None: + from mfbt.tui.widgets.command_bar import CommandBar + from mfbt.tui.widgets.k9s_header import K9sHeader + + with suppress(Exception): + command_bar = self.app.query_one("#command-bar", CommandBar) + command_bar.hints = hints + with suppress(Exception): + header = self.app.query_one("#k9s-header", K9sHeader) + header.hints = hints + + def _set_running_hints(self) -> None: + self._update_hints( + ("p", "Pause"), ("enter", "Detail"), ("l", "Logs"), + ("k", "Kill"), ("?", "Help"), ("q", "Quit"), + ) + + def _set_paused_hints(self) -> None: + self._update_hints( + ("r", "Retry"), ("enter", "Detail"), ("l", "Logs"), + ("?", "Help"), ("q", "Quit"), + ) + + def _set_header_ralph(self, active: bool) -> None: + from mfbt.tui.widgets.k9s_header import K9sHeader + + with suppress(Exception): + header = self.app.query_one("#k9s-header", K9sHeader) + header.ralph_active = active + header.ralph_project_name = self.project_name if active else "" + + def update_special_instructions(self, text: str | None) -> None: + """Update cached instructions (called from the app's modal callback).""" + self._special_instructions = text + if self._ralph_config is not None: + from dataclasses import replace + + self._ralph_config = replace( + self._ralph_config, special_instructions=text + ) + + # ---- App-facing helpers ------------------------------------------- def refresh_data(self) -> None: - """Reload data from the API.""" - self._offset = 0 - self._load_data() + """Reload the feature list (only meaningful while browsing).""" + if self._stage != RalphStage.BROWSING: + return + self.run_worker(self._load_features, thread=True) def get_selected_data(self) -> dict[str, Any] | None: - """Return the data for the currently highlighted row.""" + """Return the data dict for the currently highlighted feature row.""" try: - table = self.query_one("#resource-table", ResourceTable) - return table.get_selected_data() + table = self.query_one("#feature-table", FeatureProgressTable) + if table.row_count == 0: + return None + row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key + return table.get_feature_data(str(row_key.value)) except Exception: return None diff --git a/src/mfbt/tui/screens/module_list.py b/src/mfbt/tui/screens/module_list.py index bf3e2e5..306139b 100644 --- a/src/mfbt/tui/screens/module_list.py +++ b/src/mfbt/tui/screens/module_list.py @@ -14,6 +14,7 @@ from textual.widget import Widget from textual.widgets import Label +from mfbt.exceptions import AuthenticationRequired from mfbt.token_manager import TokenError from mfbt.tui.widgets.resource_table import ResourceTable @@ -180,7 +181,7 @@ def _fetch_and_display(self) -> None: ) self.app.call_from_thread(setattr, table, "has_more", page.has_more) self._update_status_count(page.total) - except TokenError: + except (TokenError, AuthenticationRequired): logger.warning("Authentication required while fetching modules") self.app.call_from_thread(self.app.show_auth_required_modal) except Exception as exc: diff --git a/src/mfbt/tui/screens/phase_list.py b/src/mfbt/tui/screens/phase_list.py index 3317ec8..ff7c6f5 100644 --- a/src/mfbt/tui/screens/phase_list.py +++ b/src/mfbt/tui/screens/phase_list.py @@ -14,6 +14,7 @@ from textual.widget import Widget from textual.widgets import Label +from mfbt.exceptions import AuthenticationRequired from mfbt.token_manager import TokenError from mfbt.tui.widgets.resource_table import ResourceTable @@ -174,7 +175,7 @@ def _fetch_and_display(self) -> None: table.set_rows, phases, _COLUMNS, _format_phase_row ) self._update_status_count(len(phases)) - except TokenError: + except (TokenError, AuthenticationRequired): logger.warning("Authentication required while fetching phases") self.app.call_from_thread(self.app.show_auth_required_modal) except Exception as exc: diff --git a/src/mfbt/tui/screens/project_list.py b/src/mfbt/tui/screens/project_list.py index 6d661b8..cc395de 100644 --- a/src/mfbt/tui/screens/project_list.py +++ b/src/mfbt/tui/screens/project_list.py @@ -14,6 +14,7 @@ from textual.widget import Widget from textual.widgets import Label +from mfbt.exceptions import AuthenticationRequired from mfbt.token_manager import TokenError from mfbt.tui.colors import status_color from mfbt.tui.widgets.resource_table import ResourceTable @@ -94,7 +95,7 @@ def _fetch_and_display(self) -> None: ) self.app.call_from_thread(setattr, table, "has_more", page.has_more) self._update_status_count(page.total) - except TokenError: + except (TokenError, AuthenticationRequired): logger.warning("Authentication required while fetching projects") self.app.call_from_thread(self.app.show_auth_required_modal) except Exception as exc: diff --git a/src/mfbt/tui/screens/ralph_panel.py b/src/mfbt/tui/screens/ralph_panel.py deleted file mode 100644 index 09efc2a..0000000 --- a/src/mfbt/tui/screens/ralph_panel.py +++ /dev/null @@ -1,755 +0,0 @@ -"""Ralph orchestration panel for the main TUI. - -A Widget that mounts inside #main-content, containing all Ralph sub-widgets. -Manages the confirmation → running lifecycle within the main TUI chrome. -""" - -from __future__ import annotations - -import time -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from rich.text import Text -from textual.app import ComposeResult -from textual.widget import Widget -from textual.widgets import DataTable, Static - -if TYPE_CHECKING: - from mfbt.api_client import APIClient - -from mfbt.commands.ralph.ralph_widgets import ( - AgentOutputLog, - FeatureProgressTable, - LogPaneHeader, - LogViewerModal, - RalphHeader, - RalphStage, - RalphStatusBar, - StatusSummary, - _GLISTEN_COLORS, -) - - -class RalphPanel(Widget): - """Embedded Ralph orchestration view for the main TUI. - - Lifecycle: - 1. LOADING: resolves phases, fetches progress in a worker thread - 2. OVERVIEW: shows StatusSummary widget with module progress table - 3. FEATURE_LIST: shows unified FeatureProgressTable with all features - 4. RUNNING: hides pre-confirmation widgets, shows orchestration widgets - """ - - def __init__( - self, - api_client: APIClient, - project_id: str, - mfbt_dir: Path, - config: dict[str, Any], - phase_id: str | None = None, - ) -> None: - super().__init__() - self._api_client = api_client - self._project_id = project_id - self._mfbt_dir = mfbt_dir - self._config = config - self._phase_id = phase_id - self._orchestrator: Any = None - self._orchestrator_client: APIClient | None = None - self._ralph_config: Any = None - self._phases_progress: dict[str, dict] = {} - self._orphan_modules: list[dict[str, Any]] = [] - self._pending_features: list[dict[str, Any]] = [] - self._stage: RalphStage = RalphStage.LOADING - self._session_start: float = 0.0 - self._paused_accumulated: float = 0.0 - self._pause_start: float = 0.0 - self._feature_start: float = 0.0 - self._feature_pause_snapshot: float = 0.0 - self._session_stopped: bool = False - self.session_log_dir: Path | None = None - self._running_feature_key: str | None = None - self._glisten_frame: int = 0 - self._special_instructions: str | None = None - - @property - def stage(self) -> RalphStage: - return self._stage - - def _instructions_hint_label(self) -> str: - """Return the hint label for the instructions key.""" - return "[green]\u2713[/green] Instr" if self._special_instructions else "Instr" - - def update_special_instructions(self, text: str | None) -> None: - """Update cached instructions (called from app callback).""" - self._special_instructions = text - if self._ralph_config is not None: - from dataclasses import replace - - self._ralph_config = replace( - self._ralph_config, special_instructions=text - ) - # Refresh hints to show/hide checkmark - if self._stage == RalphStage.OVERVIEW: - self._update_hints( - ("c", "Continue"), ("i", self._instructions_hint_label()), - ("esc", "Back"), ("?", "Help"), ("q", "Quit"), - ) - elif self._stage == RalphStage.FEATURE_LIST: - self._update_hints( - ("r", "Run"), ("i", self._instructions_hint_label()), - ("enter", "Detail"), ("esc", "Back"), ("?", "Help"), ("q", "Quit"), - ) - - def compose(self) -> ComposeResult: - yield Static("[dim]Loading phase data...[/dim]", id="ralph-loading") - - def on_mount(self) -> None: - self.run_worker(self._resolve_and_show_status, thread=True) - - def _resolve_and_show_status(self) -> None: - """Resolve phases and fetch progress, then show the status summary.""" - from mfbt.commands.ralph.progress import ( - build_global_feature_list, - get_module_progress, - get_phase_progress, - resolve_phases, - ) - from mfbt.commands.ralph.types import AgentType, OrchestrationMode, RalphConfig - from mfbt.config import load_special_instructions - - try: - self._special_instructions = load_special_instructions(self._project_id) - - phases = resolve_phases( - self._api_client, self._project_id, self._phase_id - ) - - self._phases_progress = { - p.phase_id: get_phase_progress(self._api_client, p.phase_id) - for p in phases - } - - # Fetch orphan modules (modules not associated with any phase) - try: - resp = self._api_client.get( - f"/api/v1/projects/{self._project_id}/modules" - ) - all_modules = resp.body if isinstance(resp.body, list) else [] - # If paginated response - if isinstance(resp.body, dict) and "items" in resp.body: - all_modules = resp.body["items"] - - # Collect all module IDs from phase progress - phase_module_ids: set[str] = set() - for progress in self._phases_progress.values(): - for m in progress.get("modules", []): - mid = m.get("module_id", "") - if mid: - phase_module_ids.add(mid) - - orphans = [ - m for m in all_modules - if not m.get("brainstorming_phase_id") - and m.get("id", "") not in phase_module_ids - ] - - # Fetch progress for each orphan module - orphan_progress: list[dict[str, Any]] = [] - for m in orphans: - mid = m.get("id", "") - if mid: - prog = get_module_progress(self._api_client, mid) - orphan_row = { - "module_id": mid, - "module_key": m.get("module_key", m.get("key", "")), - "title": m.get("title", ""), - "completed_features": prog.get("completed_features", 0), - "in_progress_features": prog.get("in_progress_features", 0), - "pending_features": prog.get("pending_features", 0), - "total_features": prog.get("total_features", 0), - "progress_percent": prog.get("progress_percent", 0.0), - } - orphan_progress.append(orphan_row) - self._orphan_modules = orphan_progress - except Exception: - self._orphan_modules = [] - - # Build global feature list using shared function - all_features = build_global_feature_list( - self._api_client, self._project_id, list(phases), self._phases_progress - ) - self._pending_features = all_features - - base_url = self._config.get("base_url", "https://app.mfbt.ai") - self._ralph_config = RalphConfig( - project_id=self._project_id, - phases=tuple(phases), - coding_agent=AgentType.CLAUDE, - mode=OrchestrationMode.FEATURE_BY_FEATURE, - max_turns=None, - quiet=False, - base_url=base_url, - special_instructions=self._special_instructions, - ) - - multi_phase = len(phases) > 1 - - def _show() -> None: - try: - loading = self.query_one("#ralph-loading") - loading.remove() - except Exception: - pass - - # Count actionable features for the title - actionable = sum( - 1 for f in self._pending_features - if f.get("completion_status", "pending") != "completed" - and f.get("has_spec", False) - and f.get("has_prompt_plan", False) - ) - total = len(self._pending_features) - - self.mount( - StatusSummary( - self._ralph_config, - self._phases_progress, - orphan_modules=self._orphan_modules, - all_features=self._pending_features, - id="status-summary", - ) - ) - self.mount(Static( - f" [bold #326CE5]Features[/bold #326CE5]" - f" [dim]\u2014 {total} total, {actionable} to implement[/dim]", - id="feature-list-title", - classes="-hidden", - )) - self.mount(Static( - " [dim]SPN = [bold]S[/bold]pec / [bold]P[/bold]rompt plan / [bold]N[/bold]otes" - " \u2014 features without spec and prompt plan will be skipped[/dim]", - id="feature-list-legend", - classes="-hidden", - )) - self.mount(RalphHeader(id="ralph-header", classes="-hidden")) - table = FeatureProgressTable(id="feature-table", classes="-hidden") - self.mount(table) - table.populate_all(self._pending_features, multi_phase=multi_phase) - self.mount(LogPaneHeader(id="log-pane-header", classes="-hidden")) - self.mount(AgentOutputLog(id="agent-output", classes="-hidden")) - self.mount(RalphStatusBar(id="ralph-status-bar", classes="-hidden")) - self._stage = RalphStage.OVERVIEW - self._update_hints( - ("c", "Continue"), ("i", self._instructions_hint_label()), - ("esc", "Back"), ("?", "Help"), ("q", "Quit"), - ) - - self.app.call_from_thread(_show) - - except Exception as exc: - def _show_error() -> None: - try: - loading = self.query_one("#ralph-loading") - loading.update( - f"[red]Failed to load Ralph data: {exc}[/red]\n" - "[dim]Press esc to go back[/dim]" - ) - except Exception: - pass - - self.app.call_from_thread(_show_error) - - def confirm_and_start(self) -> None: - """Handle 'c' key — two-step flow: OVERVIEW → FEATURE_LIST → RUNNING.""" - if self._ralph_config is None: - return - - if self._stage == RalphStage.OVERVIEW: - # Step 1: show feature list (all features, scrolled to first pending) - self._stage = RalphStage.FEATURE_LIST - try: - self.query_one("#status-summary").add_class("-hidden") - except Exception: - pass - for widget_id in ("#feature-list-title", "#feature-list-legend", "#feature-table"): - try: - self.query_one(widget_id).remove_class("-hidden") - except Exception: - pass - try: - table = self.query_one("#feature-table", FeatureProgressTable) - table.focus() - table.scroll_to_pending() - except Exception: - pass - # Update hints to show "Run" instead of "Continue" - self._update_hints( - ("r", "Run"), ("i", self._instructions_hint_label()), - ("enter", "Detail"), ("esc", "Back"), ("?", "Help"), ("q", "Quit"), - ) - return - - if self._stage == RalphStage.FEATURE_LIST: - # Check if there are any actionable features to implement - actionable = sum( - 1 for f in self._pending_features - if f.get("completion_status", "pending") != "completed" - and f.get("has_spec", False) - and f.get("has_prompt_plan", False) - ) - if actionable == 0: - self.app.notify( - "All features have already been implemented.", - severity="information", - timeout=5, - ) - return - - # Step 2: show preflight modal before starting - from mfbt.tui.screens.preflight_modal import PreflightModal - - project_dir = Path.cwd() - self.app.push_screen( - PreflightModal(project_dir=project_dir), - callback=self._on_preflight_complete, - ) - return - - if self._stage == RalphStage.PAUSED: - # Retry selected feature after stop/completion - self.retry_feature() - return - - def go_back(self) -> bool: - """Handle back navigation within ralph stages. - - Returns True if we handled the back (stayed within ralph), - False if the app should exit ralph entirely. - """ - if self._stage == RalphStage.FEATURE_LIST: - # Go back to overview - self._stage = RalphStage.OVERVIEW - for widget_id in ("#feature-list-title", "#feature-list-legend", "#feature-table"): - try: - self.query_one(widget_id).add_class("-hidden") - except Exception: - pass - try: - self.query_one("#status-summary").remove_class("-hidden") - except Exception: - pass - try: - from mfbt.tui.widgets.resource_table import ResourceTable - self.query_one("#ralph-summary-table", ResourceTable).focus() - except Exception: - pass - # Restore "Continue" hints - self._update_hints( - ("c", "Continue"), ("i", self._instructions_hint_label()), - ("esc", "Back"), ("?", "Help"), ("q", "Quit"), - ) - return True - if self._stage in (RalphStage.RUNNING, RalphStage.PAUSED): - # Swallow ESC during running/paused — use 'p' to pause or 'q' to quit - return True - return False - - def _on_preflight_complete(self, agent_type: Any) -> None: - """Handle result from the preflight modal.""" - if agent_type is None: - return # User cancelled - - from dataclasses import replace - - from mfbt.commands.ralph.types import AgentType - - # Update ralph config with the selected agent - if isinstance(agent_type, AgentType) and self._ralph_config is not None: - self._ralph_config = replace(self._ralph_config, coding_agent=agent_type) - - self._start_running() - - def _start_running(self) -> None: - """Transition from FEATURE_LIST to RUNNING stage.""" - self._stage = RalphStage.RUNNING - - # Hide title/legend (table stays visible from FEATURE_LIST stage) - for widget_id in ("#feature-list-title", "#feature-list-legend"): - try: - self.query_one(widget_id).add_class("-hidden") - except Exception: - pass - - # Show running-stage widgets (feature-table is already visible) - for widget_id in ( - "#ralph-header", - "#feature-table", - "#log-pane-header", - "#agent-output", - "#ralph-status-bar", - ): - try: - self.query_one(widget_id).remove_class("-hidden") - except Exception: - pass - - self._session_start = time.monotonic() - self._paused_accumulated = 0.0 - self._pause_start = 0.0 - self._feature_start = 0.0 - self._feature_pause_snapshot = 0.0 - self._session_stopped = False - self.set_interval(1.0, self._tick_elapsed) - self.set_interval(0.2, self._glisten_tick) - self._update_hints(("p", "Pause"), ("enter", "Detail"), ("l", "Logs"), ("k", "Kill"), ("?", "Help"), ("q", "Quit")) - self.run_worker(self._run_orchestrator, thread=True) - - def _update_hints(self, *hints: tuple[str, str]) -> None: - """Update command bar and header hints.""" - from mfbt.tui.widgets.command_bar import CommandBar - from mfbt.tui.widgets.k9s_header import K9sHeader - - try: - command_bar = self.app.query_one("#command-bar", CommandBar) - command_bar.hints = hints - except Exception: - pass - try: - header = self.app.query_one("#k9s-header", K9sHeader) - header.hints = hints - except Exception: - pass - - def stop(self) -> None: - """Stop the orchestrator if running.""" - if self._orchestrator is not None: - try: - self._orchestrator.stop() - except Exception: - pass - if self._orchestrator_client is not None: - try: - self._orchestrator_client.close() - except Exception: - pass - self._orchestrator_client = None - - def pause_orchestrator(self) -> None: - """Show pause modal — lets user continue or stop the orchestration loop.""" - if self._stage != RalphStage.RUNNING: - return - - from mfbt.commands.ralph.ralph_widgets import PauseModal - - self._stage = RalphStage.PAUSED - self._pause_start = time.monotonic() - - def _on_pause_result(result: str | None) -> None: - if result == "stop": - # Signal orchestrator to stop after current feature - if self._orchestrator is not None: - self._orchestrator.stop() - # Accumulate pause time so far (timer stays frozen) - if self._pause_start > 0: - self._paused_accumulated += time.monotonic() - self._pause_start - self._pause_start = 0.0 - self._update_hints(("r", "Retry"), ("enter", "Detail"), ("l", "Logs"), ("?", "Help"), ("q", "Quit")) - try: - log = self.query_one("#agent-output", AgentOutputLog) - log.write( - Text.from_markup( - "[yellow]Paused — will stop after current feature. " - "Press [bold]r[/bold] to restart.[/yellow]" - ) - ) - except Exception: - pass - # Stay in PAUSED — user can press 'r' to restart - else: - # Continue running — accumulate pause duration - if self._pause_start > 0: - self._paused_accumulated += time.monotonic() - self._pause_start - self._pause_start = 0.0 - self._stage = RalphStage.RUNNING - self._update_hints(("p", "Pause"), ("enter", "Detail"), ("l", "Logs"), ("k", "Kill"), ("?", "Help"), ("q", "Quit")) - - self.app.push_screen(PauseModal(), callback=_on_pause_result) - - def kill_agent(self) -> None: - """Kill the currently running coding agent subprocess immediately.""" - if self._stage not in (RalphStage.RUNNING, RalphStage.PAUSED): - return - if self._orchestrator is None: - return - try: - self._orchestrator._agent.terminate() - except Exception: - pass - try: - log = self.query_one("#agent-output", AgentOutputLog) - log.write( - Text.from_markup("[red bold]Agent killed by user[/red bold]") - ) - except Exception: - pass - self.app.notify("Agent process killed", severity="warning") - - def retry_feature(self) -> None: - """Retry implementation for the currently selected feature.""" - if self._stage != RalphStage.PAUSED: - return - if self._orchestrator is None: - return - - # Get selected feature key from table - try: - table = self.query_one("#feature-table", FeatureProgressTable) - row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key - feature_key = str(row_key.value) - except Exception: - return - - if not feature_key: - return - - # Find feature data in pending list - feature_info = None - for f in self._pending_features: - if f.get("feature_key") == feature_key: - feature_info = f - break - - if feature_info is None: - self.app.notify("Feature not found", severity="warning") - return - - # Undo status bar counts from previous result for this feature - from mfbt.commands.ralph.types import FeatureOutcome - - try: - status_bar = self.query_one("#ralph-status-bar", RalphStatusBar) - prev_results = [ - r for r in self._orchestrator._results - if r.feature.feature_key == feature_key - ] - if prev_results: - last = prev_results[-1] - if last.outcome == FeatureOutcome.SUCCESS: - status_bar.succeeded = max(0, status_bar.succeeded - 1) - elif last.outcome == FeatureOutcome.FAILED: - status_bar.failed = max(0, status_bar.failed - 1) - status_bar.pending += 1 - except Exception: - pass - - # Reset the feature row in the table - table.update_feature( - feature_key, - status="[dim]pending[/dim]", - time_str="-", - attempts=0, - ) - - # Transition to RUNNING for the duration of the retry - self._stage = RalphStage.RUNNING - self._update_hints( - ("p", "Pause"), ("enter", "Detail"), ("l", "Logs"), - ("k", "Kill"), ("?", "Help"), ("q", "Quit"), - ) - - self.run_worker( - lambda: self._run_single_retry(feature_info, feature_key), - thread=True, - ) - - def _run_single_retry( - self, feature_info: dict, feature_key: str - ) -> None: - """Retry a single feature implementation in a worker thread.""" - from mfbt.commands.ralph.progress import resolve_feature_task - from mfbt.commands.ralph.types import FeatureOutcome - - feature = resolve_feature_task(self._orchestrator_client, feature_info) - if feature is None: - def _notify_error() -> None: - self.app.notify( - f"Could not resolve {feature_key}", severity="error" - ) - self._stage = RalphStage.PAUSED - self._update_hints( - ("r", "Retry"), ("enter", "Detail"), ("l", "Logs"), - ("?", "Help"), ("q", "Quit"), - ) - self.app.call_from_thread(_notify_error) - return - - # Clear stop event so the orchestrator can run - self._orchestrator._stop_event.clear() - - # Run the single feature - result = self._orchestrator._implement_feature(feature, 0, 0) - - # Append result - self._orchestrator._results.append(result) - - # Update local feature state - if result.outcome == FeatureOutcome.SUCCESS: - feature_info["completion_status"] = "completed" - - # Transition back to PAUSED for further retries - def _back_to_paused() -> None: - self._stage = RalphStage.PAUSED - try: - header = self.query_one("#ralph-header", RalphHeader) - header.session_state = "complete" - except Exception: - pass - self._update_hints( - ("r", "Retry"), ("enter", "Detail"), ("l", "Logs"), - ("?", "Help"), ("q", "Quit"), - ) - self.app.call_from_thread(_back_to_paused) - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Handle Enter on a feature row — open feature detail modal.""" - if self._stage not in (RalphStage.FEATURE_LIST, RalphStage.RUNNING, RalphStage.PAUSED): - return - feature_key = str(event.row_key.value) - try: - table = self.query_one("#feature-table", FeatureProgressTable) - data = table.get_feature_data(feature_key) - except Exception: - return - if data and data.get("id"): - from mfbt.tui.screens.feature_detail import FeatureDetailScreen - - self.app.push_screen( - FeatureDetailScreen( - data_provider=self.app.data_provider, - feature_id=data["id"], - feature_data=data, - ) - ) - - def on_feature_progress_table_show_logs( - self, event: FeatureProgressTable.ShowLogs - ) -> None: - """Handle 'l' key — show logs for the selected feature.""" - if self._stage not in (RalphStage.RUNNING, RalphStage.PAUSED): - return - self._show_log_modal(event.feature_key) - - def _show_log_modal(self, feature_key: str) -> None: - """Find log files for a feature and display them in a modal.""" - if self.session_log_dir is None or not self.session_log_dir.exists(): - self.app.notify("No logs available yet", severity="warning") - return - - from mfbt.commands.ralph.log_capture import _sanitize_key - - sanitized = _sanitize_key(feature_key) - log_files = sorted(self.session_log_dir.glob(f"{sanitized}_attempt-*.log")) - - if not log_files: - self.app.notify(f"No logs for {feature_key} yet", severity="warning") - return - - content_parts = [] - for lf in log_files: - try: - content_parts.append(lf.read_text()) - except OSError: - content_parts.append(f"(could not read {lf.name})") - - content = "\n\n".join(content_parts) - self.app.push_screen(LogViewerModal(content, f" Logs: {feature_key}")) - - def _tick_elapsed(self) -> None: - """Update elapsed time display every second.""" - try: - status_bar = self.query_one("#ralph-status-bar", RalphStatusBar) - now = time.monotonic() - - # Overall timer: only update while session is running - if not self._session_stopped: - paused = self._paused_accumulated - # If currently paused, also subtract the ongoing pause duration - if self._pause_start > 0: - paused += now - self._pause_start - status_bar.elapsed_seconds = now - self._session_start - paused - - # Feature timer: always update if a feature is active (supports retry) - if self._feature_start > 0: - paused = self._paused_accumulated - if self._pause_start > 0: - paused += now - self._pause_start - feature_paused = paused - self._feature_pause_snapshot - status_bar.feature_elapsed_seconds = now - self._feature_start - feature_paused - status_bar.show_feature_timer = True - else: - status_bar.show_feature_timer = False - except Exception: - pass - - def _glisten_tick(self) -> None: - """Cycle the shimmer effect on the running feature status + log header.""" - key = self._running_feature_key - if key is None: - return - self._glisten_frame = (self._glisten_frame + 1) % len(_GLISTEN_COLORS) - color = _GLISTEN_COLORS[self._glisten_frame] - try: - table = self.query_one("#feature-table", FeatureProgressTable) - table.update_cell( - key, "status", Text.from_markup(f"[{color}]\u27f3 running[/{color}]") - ) - except Exception: - pass - try: - header = self.query_one("#log-pane-header", LogPaneHeader) - header.update( - Text.from_markup( - f" [{color}]\u27f3 Implementing {key}[/{color}]" - ) - ) - except Exception: - pass - - def _build_uncached_client(self) -> APIClient: - """Create an APIClient without response caching for the orchestrator. - - The orchestrator needs fresh API responses (especially for - verify_feature_completed) because feature status is mutated - externally by the coding agent via MCP. - """ - from mfbt.api_client import APIClient - from mfbt.token_manager import TokenManager - - base_url = self._config.get("base_url", "https://app.mfbt.ai") - tm = TokenManager(base_url) - - def _on_auth_required() -> bool: - return False - - return APIClient(base_url, tm, cache=None, on_auth_required=_on_auth_required) - - def _run_orchestrator(self) -> None: - """Run the orchestrator in a worker thread.""" - from mfbt.commands.ralph.log_capture import RalphLogger - from mfbt.commands.ralph.orchestrator import RalphOrchestrator - from mfbt.commands.ralph.tui_display import RalphTUIDisplay - - display = RalphTUIDisplay(self) - - log_base_dir = self._mfbt_dir / "logs" - ralph_logger = RalphLogger( - log_base_dir, self._ralph_config.coding_agent - ) - - self._orchestrator_client = self._build_uncached_client() - self._orchestrator = RalphOrchestrator( - self._ralph_config, self._orchestrator_client, display, logger=ralph_logger - ) - self._orchestrator.run() diff --git a/tests/unit/ralph/test_orchestrator.py b/tests/unit/ralph/test_orchestrator.py index 9e610f9..6615d2f 100644 --- a/tests/unit/ralph/test_orchestrator.py +++ b/tests/unit/ralph/test_orchestrator.py @@ -748,3 +748,147 @@ def test_success_updates_local_status( # The original dict should have been mutated assert info1["completion_status"] == "completed" + + +class TestOrchestratorModuleScope: + """config.module_ids restricts the run to features in those modules.""" + + @patch("mfbt.commands.ralph.orchestrator.AgentRunner") + @patch("mfbt.commands.ralph.orchestrator.verify_feature_completed") + @patch("mfbt.commands.ralph.orchestrator.resolve_feature_task") + @patch("mfbt.commands.ralph.orchestrator.build_global_feature_list") + @patch("mfbt.commands.ralph.orchestrator.get_phase_progress") + def test_module_ids_filters_other_modules( + self, + mock_progress: MagicMock, + mock_build: MagicMock, + mock_resolve: MagicMock, + mock_verify: MagicMock, + mock_runner_cls: MagicMock, + ) -> None: + mock_progress.return_value = PROGRESS + info_a = _make_feature_info("MCLI-031") + info_a["module_id"] = "mod-A" + info_b = _make_feature_info("MCLI-040") + info_b["module_id"] = "mod-B" + mock_build.return_value = [info_a, info_b] + # Real get_next_from_list runs over the *filtered* list. + mock_resolve.side_effect = lambda _c, info: _make_feature( + info["feature_key"] + ) + mock_verify.return_value = True + + mock_runner = MagicMock() + mock_runner.run.return_value = _agent_result() + mock_runner_cls.return_value = mock_runner + + config = _make_config(module_ids=("mod-A",)) + orch = RalphOrchestrator(config, MagicMock(), _make_display()) + summary = orch.run() + + assert summary.total == 1 + assert summary.results[0].feature.feature_key == "MCLI-031" + + @patch("mfbt.commands.ralph.orchestrator.AgentRunner") + @patch("mfbt.commands.ralph.orchestrator.verify_feature_completed") + @patch("mfbt.commands.ralph.orchestrator.resolve_feature_task") + @patch("mfbt.commands.ralph.orchestrator.build_global_feature_list") + @patch("mfbt.commands.ralph.orchestrator.get_phase_progress") + def test_none_module_ids_processes_all( + self, + mock_progress: MagicMock, + mock_build: MagicMock, + mock_resolve: MagicMock, + mock_verify: MagicMock, + mock_runner_cls: MagicMock, + ) -> None: + mock_progress.return_value = PROGRESS + info_a = _make_feature_info("MCLI-031") + info_a["module_id"] = "mod-A" + info_b = _make_feature_info("MCLI-040") + info_b["module_id"] = "mod-B" + mock_build.return_value = [info_a, info_b] + mock_resolve.side_effect = lambda _c, info: _make_feature( + info["feature_key"] + ) + mock_verify.return_value = True + + mock_runner = MagicMock() + mock_runner.run.return_value = _agent_result() + mock_runner_cls.return_value = mock_runner + + config = _make_config() # module_ids defaults to None + orch = RalphOrchestrator(config, MagicMock(), _make_display()) + summary = orch.run() + + assert summary.total == 2 + + @patch("mfbt.commands.ralph.orchestrator.AgentRunner") + @patch("mfbt.commands.ralph.orchestrator.verify_feature_completed") + @patch("mfbt.commands.ralph.orchestrator.resolve_feature_task") + @patch("mfbt.commands.ralph.orchestrator.build_global_feature_list") + @patch("mfbt.commands.ralph.orchestrator.get_phase_progress") + def test_empty_module_ids_processes_all( + self, + mock_progress: MagicMock, + mock_build: MagicMock, + mock_resolve: MagicMock, + mock_verify: MagicMock, + mock_runner_cls: MagicMock, + ) -> None: + # An empty tuple must be treated identically to None (no + # restriction) — not as "match nothing". Guards the footgun. + mock_progress.return_value = PROGRESS + info_a = _make_feature_info("MCLI-031") + info_a["module_id"] = "mod-A" + info_b = _make_feature_info("MCLI-040") + info_b["module_id"] = "mod-B" + mock_build.return_value = [info_a, info_b] + mock_resolve.side_effect = lambda _c, info: _make_feature( + info["feature_key"] + ) + mock_verify.return_value = True + + mock_runner = MagicMock() + mock_runner.run.return_value = _agent_result() + mock_runner_cls.return_value = mock_runner + + config = _make_config(module_ids=()) + orch = RalphOrchestrator(config, MagicMock(), _make_display()) + summary = orch.run() + + assert summary.total == 2 + + @patch("mfbt.commands.ralph.orchestrator.AgentRunner") + @patch("mfbt.commands.ralph.orchestrator.verify_feature_completed") + @patch("mfbt.commands.ralph.orchestrator.resolve_feature_task") + @patch("mfbt.commands.ralph.orchestrator.build_global_feature_list") + @patch("mfbt.commands.ralph.orchestrator.get_phase_progress") + def test_nonmatching_module_ids_runs_nothing_and_warns( + self, + mock_progress: MagicMock, + mock_build: MagicMock, + mock_resolve: MagicMock, + mock_verify: MagicMock, + mock_runner_cls: MagicMock, + ) -> None: + # A module id that matches no feature must yield zero results AND + # surface a warning, not a silent success-with-nothing-done. + mock_progress.return_value = PROGRESS + info_a = _make_feature_info("MCLI-031") + info_a["module_id"] = "mod-A" + mock_build.return_value = [info_a] + mock_resolve.side_effect = lambda _c, info: _make_feature( + info["feature_key"] + ) + mock_verify.return_value = True + mock_runner_cls.return_value = MagicMock() + + display = MagicMock() + config = _make_config(module_ids=("mod-ZZZ",)) + orch = RalphOrchestrator(config, MagicMock(), display) + summary = orch.run() + + assert summary.total == 0 + mock_resolve.assert_not_called() + display.warn.assert_called_once() diff --git a/tests/unit/ralph/test_tui_display.py b/tests/unit/ralph/test_tui_display.py index 2b894af..f599586 100644 --- a/tests/unit/ralph/test_tui_display.py +++ b/tests/unit/ralph/test_tui_display.py @@ -117,7 +117,7 @@ def query_one(selector, cls): class TestTUIDisplayPopulateFeatures: def test_populate_features_is_noop(self) -> None: - """populate_features() is a no-op — table is pre-populated by RalphPanel.""" + """populate_features() is a no-op — table pre-populated by the panel.""" app = _make_mock_container() display = RalphTUIDisplay.__new__(RalphTUIDisplay) display._container = app diff --git a/tests/unit/ralph/test_types.py b/tests/unit/ralph/test_types.py index 50f0226..75441ac 100644 --- a/tests/unit/ralph/test_types.py +++ b/tests/unit/ralph/test_types.py @@ -62,6 +62,36 @@ def test_construction(self) -> None: assert cfg.max_turns is None assert cfg.quiet is False assert cfg.base_url == "https://app.mfbt.ai" + assert cfg.module_ids is None + + def test_module_ids_scope(self) -> None: + cfg = RalphConfig( + project_id="proj-1", + phases=(PhaseInfo("phase-1", "Phase 1"),), + coding_agent=AgentType.CLAUDE, + mode=OrchestrationMode.FEATURE_BY_FEATURE, + max_turns=None, + quiet=False, + base_url="https://app.mfbt.ai", + module_ids=("mod-A",), + ) + assert cfg.module_ids == ("mod-A",) + assert cfg.restricts_modules is True + + def test_restricts_modules_sentinel(self) -> None: + base = dict( + project_id="proj-1", + phases=(PhaseInfo("phase-1", "Phase 1"),), + coding_agent=AgentType.CLAUDE, + mode=OrchestrationMode.FEATURE_BY_FEATURE, + max_turns=None, + quiet=False, + base_url="https://app.mfbt.ai", + ) + # None and empty-tuple both mean "no restriction". + assert RalphConfig(**base).restricts_modules is False + assert RalphConfig(**base, module_ids=()).restricts_modules is False + assert RalphConfig(**base, module_ids=("m",)).restricts_modules is True def test_multi_phase(self) -> None: phases = (PhaseInfo("p-1", "Phase 1"), PhaseInfo("p-2", "Phase 2")) diff --git a/tests/unit/tui/test_ralph_panel.py b/tests/unit/tui/test_ralph_panel.py index d0a04ac..55269bc 100644 --- a/tests/unit/tui/test_ralph_panel.py +++ b/tests/unit/tui/test_ralph_panel.py @@ -1,233 +1,186 @@ -"""Tests for RalphPanel construction and lifecycle.""" +"""Tests for the unified FeatureListPanel's Ralph lifecycle. + +(Ralph mode no longer has a separate panel — it runs in place inside the +feature list. These tests cover that merged lifecycle.) +""" from __future__ import annotations +from contextlib import contextmanager from pathlib import Path from unittest.mock import MagicMock, patch from mfbt.commands.ralph.ralph_widgets import RalphStage -class TestRalphPanelInit: - def test_panel_stores_config(self, tmp_path: Path) -> None: - from mfbt.tui.screens.ralph_panel import RalphPanel +def _make_panel( + tmp_path: Path, + scope: str = "global", + module_id: str = "", +) -> object: + """Create a FeatureListPanel with widget/worker methods mocked out.""" + from mfbt.tui.screens.feature_list import FeatureListPanel - mock_client = MagicMock() - panel = RalphPanel( - api_client=mock_client, - project_id="p1", - mfbt_dir=tmp_path, - config={"base_url": "https://app.mfbt.ai"}, - phase_id="ph1", - ) - assert panel._project_id == "p1" - assert panel._phase_id == "ph1" - assert panel._stage == RalphStage.LOADING + panel = FeatureListPanel( + data_provider=MagicMock(), + api_client=MagicMock(), + project_id="p1", + mfbt_dir=tmp_path, + config={"base_url": "https://app.mfbt.ai"}, + project_name="Proj", + phase_id="ph1", + scope=scope, + module_id=module_id, + ) + panel.query_one = MagicMock() + panel._update_hints = MagicMock() + panel._set_header_ralph = MagicMock() + panel.set_interval = MagicMock() + panel.run_worker = MagicMock() + panel._cancel_timers = MagicMock() + return panel - def test_panel_stores_none_phase(self, tmp_path: Path) -> None: - from mfbt.tui.screens.ralph_panel import RalphPanel - mock_client = MagicMock() - panel = RalphPanel( - api_client=mock_client, - project_id="p1", - mfbt_dir=tmp_path, - config={"base_url": "https://app.mfbt.ai"}, - ) - assert panel._phase_id is None +class TestInit: + def test_panel_stores_scope_and_ids(self, tmp_path: Path) -> None: + panel = _make_panel(tmp_path, scope="module", module_id="m1") + assert panel.project_id == "p1" + assert panel._phase_id == "ph1" + assert panel._scope == "module" + assert panel.module_id == "m1" - def test_panel_initial_stage_is_loading(self, tmp_path: Path) -> None: - from mfbt.tui.screens.ralph_panel import RalphPanel + def test_initial_stage_is_browsing(self, tmp_path: Path) -> None: + panel = _make_panel(tmp_path) + assert panel.stage == RalphStage.BROWSING - mock_client = MagicMock() - panel = RalphPanel( - api_client=mock_client, + def test_default_phase_is_none(self, tmp_path: Path) -> None: + from mfbt.tui.screens.feature_list import FeatureListPanel + + panel = FeatureListPanel( + data_provider=MagicMock(), + api_client=MagicMock(), project_id="p1", mfbt_dir=tmp_path, config={}, ) - assert panel.stage == RalphStage.LOADING + assert panel._phase_id is None + assert panel._scope == "global" -class TestRalphPanelStop: +class TestStop: def test_stop_calls_orchestrator_stop(self, tmp_path: Path) -> None: - from mfbt.tui.screens.ralph_panel import RalphPanel - - mock_client = MagicMock() - panel = RalphPanel( - api_client=mock_client, - project_id="p1", - mfbt_dir=tmp_path, - config={}, - ) + panel = _make_panel(tmp_path) mock_orch = MagicMock() panel._orchestrator = mock_orch panel.stop() mock_orch.stop.assert_called_once() def test_stop_tolerates_no_orchestrator(self, tmp_path: Path) -> None: - from mfbt.tui.screens.ralph_panel import RalphPanel - - mock_client = MagicMock() - panel = RalphPanel( - api_client=mock_client, - project_id="p1", - mfbt_dir=tmp_path, - config={}, - ) - panel.stop() # Should not raise - - -def _make_panel(tmp_path: Path) -> MagicMock: - """Create a RalphPanel with mocked widget methods.""" - from mfbt.tui.screens.ralph_panel import RalphPanel - - mock_client = MagicMock() - panel = RalphPanel( - api_client=mock_client, - project_id="p1", - mfbt_dir=tmp_path, - config={}, - ) - panel.query_one = MagicMock() - panel._update_hints = MagicMock() - panel.set_interval = MagicMock() - panel.run_worker = MagicMock() - return panel - - -class TestRalphPanelGoBack: - def test_go_back_from_feature_list_returns_true(self, tmp_path: Path) -> None: panel = _make_panel(tmp_path) - panel._stage = RalphStage.FEATURE_LIST + panel.stop() # should not raise - result = panel.go_back() - assert result is True - assert panel._stage == RalphStage.OVERVIEW - - def test_go_back_from_feature_list_hides_feature_list(self, tmp_path: Path) -> None: - panel = _make_panel(tmp_path) - panel._stage = RalphStage.FEATURE_LIST - - panel.go_back() - # Should have called query_one to hide/show widgets - assert panel.query_one.call_count >= 1 - def test_go_back_from_overview_returns_false(self, tmp_path: Path) -> None: +class TestGoBack: + def test_go_back_from_running_is_swallowed(self, tmp_path: Path) -> None: panel = _make_panel(tmp_path) - panel._stage = RalphStage.OVERVIEW + panel._stage = RalphStage.RUNNING result = panel.go_back() - assert result is False - assert panel._stage == RalphStage.OVERVIEW + assert result is True + assert panel._stage == RalphStage.RUNNING - def test_go_back_from_running_is_swallowed(self, tmp_path: Path) -> None: + def test_go_back_from_paused_returns_to_browsing(self, tmp_path: Path) -> None: panel = _make_panel(tmp_path) - panel._stage = RalphStage.RUNNING + panel._stage = RalphStage.PAUSED result = panel.go_back() - assert result is True # ESC is swallowed during running - assert panel._stage == RalphStage.RUNNING + assert result is True + assert panel._stage == RalphStage.BROWSING + # Reloads the list to reflect completed features. + panel.run_worker.assert_called_once() - def test_go_back_from_loading_returns_false(self, tmp_path: Path) -> None: + def test_go_back_from_browsing_returns_false(self, tmp_path: Path) -> None: panel = _make_panel(tmp_path) - assert panel._stage == RalphStage.LOADING + assert panel._stage == RalphStage.BROWSING result = panel.go_back() assert result is False + assert panel._stage == RalphStage.BROWSING -class TestRalphPanelConfirmAndStart: - def test_confirm_does_nothing_without_config(self, tmp_path: Path) -> None: +class TestStartRalph: + def test_start_without_config_notifies(self, tmp_path: Path) -> None: panel = _make_panel(tmp_path) - panel._stage = RalphStage.OVERVIEW panel._ralph_config = None - panel.confirm_and_start() - assert panel._stage == RalphStage.OVERVIEW - - def test_confirm_from_overview_transitions_to_feature_list(self, tmp_path: Path) -> None: + mock_app = MagicMock() + with patch.object( + type(panel), "app", new_callable=lambda: property(lambda self: mock_app) + ): + panel.start_ralph() + mock_app.notify.assert_called_once() + mock_app.push_screen.assert_not_called() + assert panel._stage == RalphStage.BROWSING + + def test_start_with_no_actionable_features_notifies( + self, tmp_path: Path + ) -> None: panel = _make_panel(tmp_path) - panel._stage = RalphStage.OVERVIEW panel._ralph_config = MagicMock() + panel._pending_features = [ + {"completion_status": "completed", "has_spec": True, "has_prompt_plan": True}, + ] - panel.confirm_and_start() - assert panel._stage == RalphStage.FEATURE_LIST - - def test_confirm_from_feature_list_shows_preflight_modal(self, tmp_path: Path) -> None: + mock_app = MagicMock() + with patch.object( + type(panel), "app", new_callable=lambda: property(lambda self: mock_app) + ): + panel.start_ralph() + mock_app.notify.assert_called_once() + mock_app.push_screen.assert_not_called() + assert panel._stage == RalphStage.BROWSING + + def test_start_with_actionable_features_shows_preflight( + self, tmp_path: Path + ) -> None: panel = _make_panel(tmp_path) - panel._stage = RalphStage.FEATURE_LIST + panel._ralph_config = MagicMock() panel._pending_features = [ {"completion_status": "pending", "has_spec": True, "has_prompt_plan": True}, ] - mock_config = MagicMock() - mock_config.phases = (MagicMock(),) - panel._ralph_config = mock_config mock_app = MagicMock() - with patch.object(type(panel), "app", new_callable=lambda: property(lambda self: mock_app)): - panel.confirm_and_start() - # Should push the preflight modal instead of transitioning directly + with patch.object( + type(panel), "app", new_callable=lambda: property(lambda self: mock_app) + ): + panel.start_ralph() mock_app.push_screen.assert_called_once() - assert panel._stage == RalphStage.FEATURE_LIST # Not yet RUNNING - - def test_preflight_complete_transitions_to_running(self, tmp_path: Path) -> None: - from mfbt.commands.ralph.types import ( - AgentType, - OrchestrationMode, - PhaseInfo, - RalphConfig, - ) + assert panel._stage == RalphStage.BROWSING # not yet RUNNING + def test_start_from_running_is_noop(self, tmp_path: Path) -> None: panel = _make_panel(tmp_path) - panel._stage = RalphStage.FEATURE_LIST - panel._ralph_config = RalphConfig( - project_id="p1", - phases=(PhaseInfo(phase_id="ph1", phase_title="Phase 1"),), - coding_agent=AgentType.CLAUDE, - mode=OrchestrationMode.FEATURE_BY_FEATURE, - max_turns=None, - quiet=False, - base_url="https://app.mfbt.ai", - ) - - # Simulate preflight completing successfully - panel._on_preflight_complete(AgentType.CLAUDE) - assert panel._stage == RalphStage.RUNNING - - def test_preflight_cancel_stays_at_feature_list(self, tmp_path: Path) -> None: - panel = _make_panel(tmp_path) - panel._stage = RalphStage.FEATURE_LIST - mock_config = MagicMock() - mock_config.phases = (MagicMock(),) - panel._ralph_config = mock_config - - # Simulate user cancelling preflight - panel._on_preflight_complete(None) - assert panel._stage == RalphStage.FEATURE_LIST - - def test_confirm_from_loading_does_nothing(self, tmp_path: Path) -> None: - panel = _make_panel(tmp_path) - panel._stage = RalphStage.LOADING + panel._stage = RalphStage.RUNNING panel._ralph_config = MagicMock() - panel.confirm_and_start() - # LOADING is neither OVERVIEW nor FEATURE_LIST, so nothing happens - assert panel._stage == RalphStage.LOADING + panel.start_ralph() + assert panel._stage == RalphStage.RUNNING - def test_confirm_from_running_does_nothing(self, tmp_path: Path) -> None: + def test_start_from_paused_delegates_to_retry(self, tmp_path: Path) -> None: panel = _make_panel(tmp_path) - panel._stage = RalphStage.RUNNING - panel._ralph_config = MagicMock() + panel._stage = RalphStage.PAUSED + panel.retry_feature = MagicMock() - panel.confirm_and_start() - assert panel._stage == RalphStage.RUNNING + panel.start_ralph() + # 'r' from PAUSED must dispatch to retry_feature (not preflight). + panel.retry_feature.assert_called_once_with() + assert panel._stage == RalphStage.PAUSED -class TestRalphStageRoundTrip: - """Test full stage cycle: OVERVIEW → FEATURE_LIST → OVERVIEW → FEATURE_LIST → preflight → RUNNING.""" - def test_full_round_trip(self, tmp_path: Path) -> None: +class TestPreflightComplete: + def test_preflight_complete_transitions_to_running( + self, tmp_path: Path + ) -> None: from mfbt.commands.ralph.types import ( AgentType, OrchestrationMode, @@ -236,6 +189,7 @@ def test_full_round_trip(self, tmp_path: Path) -> None: ) panel = _make_panel(tmp_path) + panel._stage = RalphStage.BROWSING panel._ralph_config = RalphConfig( project_id="p1", phases=(PhaseInfo(phase_id="ph1", phase_title="Phase 1"),), @@ -246,39 +200,205 @@ def test_full_round_trip(self, tmp_path: Path) -> None: base_url="https://app.mfbt.ai", ) - # Populate pending features so the actionable check passes - panel._pending_features = [ - {"completion_status": "pending", "has_spec": True, "has_prompt_plan": True}, - ] + panel._on_preflight_complete(AgentType.CLAUDE) + assert panel._stage == RalphStage.RUNNING + panel.run_worker.assert_called_once() - # Start at OVERVIEW - panel._stage = RalphStage.OVERVIEW + def test_preflight_cancel_stays_browsing(self, tmp_path: Path) -> None: + panel = _make_panel(tmp_path) + panel._stage = RalphStage.BROWSING + panel._ralph_config = MagicMock() - # Press c → FEATURE_LIST - panel.confirm_and_start() - assert panel._stage == RalphStage.FEATURE_LIST + panel._on_preflight_complete(None) + assert panel._stage == RalphStage.BROWSING + panel.run_worker.assert_not_called() - # Press esc → back to OVERVIEW - result = panel.go_back() - assert result is True - assert panel._stage == RalphStage.OVERVIEW - # Press c again → FEATURE_LIST - panel.confirm_and_start() - assert panel._stage == RalphStage.FEATURE_LIST +def _sync_app() -> MagicMock: + """A mock app whose call_from_thread runs the callable synchronously.""" + app = MagicMock() + app.call_from_thread.side_effect = lambda fn, *a, **k: fn(*a, **k) + return app - # Press c again → preflight modal is shown (stage stays FEATURE_LIST) - mock_app = MagicMock() - with patch.object(type(panel), "app", new_callable=lambda: property(lambda self: mock_app)): - panel.confirm_and_start() - mock_app.push_screen.assert_called_once() - assert panel._stage == RalphStage.FEATURE_LIST - # Simulate preflight completing → RUNNING - panel._on_preflight_complete(AgentType.CLAUDE) - assert panel._stage == RalphStage.RUNNING +@contextmanager +def _attach_app(panel: object, app: MagicMock): + """Patch the read-only Widget.app property for the panel's lifetime.""" + p = patch.object( + type(panel), "app", new_callable=lambda: property(lambda self: app) + ) + p.start() + try: + yield app + finally: + p.stop() + + +@contextmanager +def _progress_patches(): + """Patch the four progress fns + special instructions _load_features uses.""" + with patch("mfbt.commands.ralph.progress.resolve_phases") as phases, \ + patch( + "mfbt.commands.ralph.progress.get_phase_progress" + ) as phase_progress, \ + patch( + "mfbt.commands.ralph.progress.build_global_feature_list" + ) as global_list, \ + patch( + "mfbt.commands.ralph.progress.get_module_feature_list" + ) as module_list, \ + patch( + "mfbt.config.load_special_instructions", return_value=None + ): + yield phases, phase_progress, global_list, module_list + + +class TestLoadFeatures: + """The integration seam the module_ids feature exists for.""" + + def test_module_scope_builds_single_module_config( + self, tmp_path: Path + ) -> None: + from mfbt.commands.ralph.types import PhaseInfo + + panel = _make_panel(tmp_path, scope="module", module_id="m1") + panel.module_key = "MOD" + feats = [{"feature_key": "K-1", "module_id": "m1"}] + with _progress_patches() as (m_phases, _pp, m_global, m_module), \ + _attach_app(panel, _sync_app()): + m_phases.return_value = [PhaseInfo("ph1", "Phase 1")] + m_module.return_value = feats + panel._load_features() + + assert panel._ralph_config.module_ids == ("m1",) + assert panel._pending_features == feats + assert panel._multi_phase is False + m_module.assert_called_once_with(panel._api_client, "p1", "m1", "MOD") + m_global.assert_not_called() + + def test_global_scope_leaves_module_ids_none( + self, tmp_path: Path + ) -> None: + from mfbt.commands.ralph.types import PhaseInfo + + panel = _make_panel(tmp_path, scope="global") + feats = [ + {"feature_key": "K-1", "module_id": "m1"}, + {"feature_key": "K-2", "module_id": "m2"}, + ] + with _progress_patches() as (m_phases, m_pp, m_global, m_module), \ + _attach_app(panel, _sync_app()): + m_phases.return_value = [PhaseInfo("ph1", "Phase 1")] + m_pp.return_value = {} + m_global.return_value = feats + panel._load_features() + + assert panel._ralph_config.module_ids is None + assert panel._pending_features == feats + m_global.assert_called_once() + m_module.assert_not_called() + + def test_module_scope_without_module_id_uses_global_path( + self, tmp_path: Path + ) -> None: + from mfbt.commands.ralph.types import PhaseInfo + + panel = _make_panel(tmp_path, scope="module", module_id="") + with _progress_patches() as (m_phases, m_pp, m_global, m_module), \ + _attach_app(panel, _sync_app()): + m_phases.return_value = [PhaseInfo("ph1", "Phase 1")] + m_pp.return_value = {} + m_global.return_value = [] + panel._load_features() + + assert panel._ralph_config.module_ids is None + m_module.assert_not_called() + + def test_no_phases_falls_back_without_raising( + self, tmp_path: Path + ) -> None: + import typer + + panel = _make_panel(tmp_path, scope="global") + app = _sync_app() + with _progress_patches() as (m_phases, _pp, m_global, _m), \ + _attach_app(panel, app): + m_phases.side_effect = typer.Exit(code=1) + m_global.return_value = [] + panel._load_features() + + assert panel._phases == [] + assert panel._ralph_config.phases == () + # typer.Exit is swallowed as the orphan-only fallback, not surfaced. + app.show_auth_required_modal.assert_not_called() + + +class TestReturnToBrowsing: + def test_paused_go_back_tears_down_orchestrator( + self, tmp_path: Path + ) -> None: + panel = _make_panel(tmp_path) + panel._stage = RalphStage.PAUSED + panel._running_feature_key = "K-1" + mock_orch = MagicMock() + mock_client = MagicMock() + panel._orchestrator = mock_orch + panel._orchestrator_client = mock_client - # go_back from RUNNING is swallowed (ESC doesn't exit ralph) - result = panel.go_back() - assert result is True - assert panel._stage == RalphStage.RUNNING + handled = panel.go_back() + + assert handled is True + mock_orch.stop.assert_called_once() + mock_client.close.assert_called_once() + assert panel._orchestrator_client is None + assert panel._stage == RalphStage.BROWSING + assert panel._session_stopped is True + assert panel._running_feature_key is None + # Reloads the list to reflect features completed during the run. + panel.run_worker.assert_called_once() + + +class TestRunSingleRetry: + def test_success_marks_feature_completed(self, tmp_path: Path) -> None: + from mfbt.commands.ralph.types import FeatureOutcome + + panel = _make_panel(tmp_path) + panel._stage = RalphStage.RUNNING + panel._orchestrator_client = MagicMock() + orch = MagicMock() + result = MagicMock() + result.outcome = FeatureOutcome.SUCCESS + orch._implement_feature.return_value = result + orch._results = [] + panel._orchestrator = orch + + info = {"feature_key": "K-1", "id": "feat-1"} + with _attach_app(panel, _sync_app()), patch( + "mfbt.commands.ralph.progress.resolve_feature_task", + return_value=MagicMock(), + ): + panel._run_single_retry(info, "K-1") + + orch._implement_feature.assert_called_once() + assert orch._results == [result] + assert info["completion_status"] == "completed" + assert panel._stage == RalphStage.PAUSED + + def test_exception_restores_paused_and_notifies( + self, tmp_path: Path + ) -> None: + panel = _make_panel(tmp_path) + panel._stage = RalphStage.RUNNING + panel._orchestrator = MagicMock() + panel._orchestrator_client = MagicMock() + app = _sync_app() + + with _attach_app(panel, app), patch( + "mfbt.commands.ralph.progress.resolve_feature_task", + side_effect=RuntimeError("boom"), + ): + panel._run_single_retry({"feature_key": "K-1"}, "K-1") + + # Failure must not crash the worker — it restores PAUSED + notifies. + assert panel._stage == RalphStage.PAUSED + app.notify.assert_called_once() diff --git a/uv.lock b/uv.lock index 2ef3221..5273ad2 100644 --- a/uv.lock +++ b/uv.lock @@ -164,7 +164,7 @@ wheels = [ [[package]] name = "mfbt-cli" -version = "0.1.6" +version = "0.1.9" source = { editable = "." } dependencies = [ { name = "httpx" },