Source code for ralph.display.phase_banner

"""Phase transition display for Ralph pipeline.

Renders visually distinct banners and separators at pipeline phase boundaries
so the user can easily follow the flow of planning → development → review → …
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from rich.rule import Rule
from rich.text import Text

from ralph.display.phase_status import (
    format_analysis_cycle,
    format_dev_cycle,
    format_elapsed_seconds,
    format_transition_context_items,
)

if TYPE_CHECKING:
    from rich.console import Console

    from ralph.display.context import DisplayContext
    from ralph.display.phase_lifecycle import PhaseEntryModel, PhaseExitModel
    from ralph.policy.models import PipelinePolicy

_PHASE_STYLES: dict[str, str] = {
    "execution": "theme.phase.development",
    "analysis": "theme.phase.development_analysis",
    "review": "theme.phase.review",
    "commit": "theme.phase.commit",
    "fix": "theme.phase.fix",
    "verification": "theme.phase.development_analysis",
    "terminal": "theme.phase.complete",
    "fanout_join": "theme.phase.development",
}

# Role-pair based major transitions (used when pipeline_policy is available)
MAJOR_ROLE_PAIRS: frozenset[tuple[str, str]] = frozenset(
    {
        ("execution", "analysis"),
        ("analysis", "commit"),
        ("commit", "review"),
        ("review", "analysis"),
        ("analysis", "execution"),
        ("commit", "execution"),
        ("commit", "terminal"),
        ("review", "terminal"),
        ("execution", "terminal"),
    }
)


[docs] def phase_style(phase: str, pipeline_policy: PipelinePolicy | None = None) -> str: """Return the rich style string for a phase name or role. When pipeline_policy is provided, the style is derived from the phase's declared role so renamed phases render with the correct color. Without a policy, the input is treated as a role key — canonical phase names are not recognized and return the muted default. """ if pipeline_policy is not None: phase_def = pipeline_policy.phases.get(phase) if phase_def is not None: if phase_def.display_style is not None: return phase_def.display_style role = phase_def.role or "" terminal_outcome = phase_def.terminal_outcome if role == "terminal" and terminal_outcome == "failure": return "theme.phase.failed" style = _PHASE_STYLES.get(role) if style is not None: return style return _PHASE_STYLES.get(phase, "theme.text.muted")
[docs] def phase_label(phase: str) -> str: """Return a human-readable label for a phase name. Examples: >>> _phase_label("development_analysis") 'Development Analysis' >>> _phase_label("review_commit") 'Review Commit' """ return phase.replace("_", " ").title()
def _resolve_transition_meta( from_phase: str, to_phase: str, pipeline_policy: PipelinePolicy | None, ) -> bool: """Return is_major for a phase transition. Uses role-pair tables when policy is available. Without policy, the transition is treated as minor. """ if pipeline_policy is None: return False phases = pipeline_policy.phases from_def = phases.get(from_phase) to_def = phases.get(to_phase) if from_def is None or to_def is None: return False from_role = from_def.role or "" to_role = to_def.role or "" return (from_role, to_role) in MAJOR_ROLE_PAIRS def _render_major_transition( c: Console, from_label: str, to_label: str, _style: str, context: dict[str, object] | None, arrow: str, ) -> None: """Render a major (prominent) phase transition banner.""" title = Text() title.append(from_label, style="theme.text.muted") title.append(f" {arrow} ", style="theme.text.emphasis") title.append(to_label, style=_style) if context: detail = " ".join(format_transition_context_items(context)) title.append(f" ({detail})", style="theme.text.muted") c.print(Rule(title=title, style=_style))
[docs] def show_phase_transition( from_phase: str, to_phase: str, *, context: dict[str, object] | None = None, display_context: DisplayContext, pipeline_policy: PipelinePolicy | None = None, ) -> None: """Display a visual transition between pipeline phases. Major transitions (e.g. planning → development) get a prominent banner. Minor transitions (e.g. development → development_analysis) get a simple rule. When pipeline_policy is provided, styles and descriptions are derived from declared phase roles so renamed phases render correctly. """ c = display_context.console ctx = display_context style = phase_style(to_phase, pipeline_policy) from_label = phase_label(from_phase) to_label = phase_label(to_phase) is_major = _resolve_transition_meta(from_phase, to_phase, pipeline_policy) if is_major: _render_major_transition( c, from_label, to_label, style, context, ctx.glyph_for("arrow"), ) return if ctx.mode != "compact": c.print() title = Text() arrow = ctx.glyph_for("arrow") title.append(f"{from_label} {arrow} {to_label}") c.print(Rule(title=title, style=style))
def _build_outer_iteration_suffix( iteration: int | None, cap: int | None = None, *, od_glyph: str = "⊞", qualifier: str = "", ) -> str: """Build the outer dev cycle label string.""" if iteration is None: return "" qual = f" {qualifier}" if qualifier else "" return f" {od_glyph} {format_dev_cycle(iteration, cap)}{qual}" def _build_inner_analysis_suffix( inner: int | None, max_inner: int | None = None, *, ia_glyph: str = "≴", qualifier: str = "", ) -> str: """Build the inner analysis cycle label string.""" if inner is None: return "" qual = f" {qualifier}" if qualifier else "" return f" {ia_glyph} {format_analysis_cycle(inner, max_inner)}{qual}"
[docs] def show_phase_start( phase: str, *, agent_name: str | None = None, display_context: DisplayContext, pipeline_policy: PipelinePolicy | None = None, ) -> None: """Display the start of a pipeline phase (no iteration context). For banners that carry iteration context, use :func:`show_phase_start_from_entry`. """ c = display_context.console style = phase_style(phase, pipeline_policy) label = phase_label(phase) line = Text() start_glyph = display_context.glyph_for("start") line.append(f"{start_glyph} ", style=style) line.append(label, style=style) if agent_name is not None: line.append(f" agent={agent_name}", style="theme.text.muted") c.print(line)
def _render_wide_phase_start( entry: PhaseEntryModel, style: str, label: str, display_context: DisplayContext, ) -> None: """Render the wide-mode phase-start banner (titled Rule + optional agent line).""" c = display_context.console od_glyph = display_context.glyph_for("outer_dev") ia_glyph = display_context.glyph_for("inner_analysis") start_glyph = display_context.glyph_for("start") rule_title = Text() rule_title.append(f"{start_glyph} ", style=style) rule_title.append(label, style=style) if entry.outer_dev_iteration is not None: rule_title.append( _build_outer_iteration_suffix( entry.outer_dev_iteration, entry.outer_dev_cap, od_glyph=od_glyph, qualifier="(outer)", ), style="theme.outer_dev", ) if entry.inner_analysis is not None: rule_title.append( _build_inner_analysis_suffix( entry.inner_analysis, entry.inner_analysis_cap, ia_glyph=ia_glyph, qualifier="(inner)", ), style="theme.inner_analysis", ) if entry.inner_analysis is not None and entry.inner_analysis_cap is not None: remaining = entry.inner_analysis_cap - entry.inner_analysis if remaining > 0: rule_title.append(f" [{remaining} left]", style="theme.text.muted") elif remaining == 0: rule_title.append(" [last]", style="theme.level.warn") c.print(Rule(title=rule_title, style=style)) if entry.agent_name is not None: agent_line = Text() agent_line.append(" agent: ", style="theme.text.muted") agent_line.append(entry.agent_name, style="theme.text.emphasis") c.print(agent_line)
[docs] def show_phase_start_from_entry( entry: PhaseEntryModel, *, display_context: DisplayContext, pipeline_policy: PipelinePolicy | None = None, ) -> None: """Display the start of a pipeline phase from a lifecycle entry model. Canonical model-based path for phase-start banners. Uses the entry model so iteration labels (Dev N/cap, Analysis N/cap) never diverge between phase-start and phase-close surfaces. Wide mode: a single titled Rule carries all context — start glyph, phase label, outer/inner qualifiers, and remaining-budget indicator — followed by an optional agent line. No redundant banner line is emitted after the Rule. Medium mode: blank line + banner line with qualifiers and budget indicator. Compact mode: terse banner line, no qualifiers, no Rule. """ c = display_context.console style = phase_style(entry.phase_name, pipeline_policy) label = entry.human_label() mode = display_context.mode start_glyph = display_context.glyph_for("start") od_glyph = display_context.glyph_for("outer_dev") ia_glyph = display_context.glyph_for("inner_analysis") if mode == "wide": _render_wide_phase_start(entry, style, label, display_context) return if mode == "medium": c.print() line = Text() line.append(f"{start_glyph} ", style=style) line.append(label, style=style) outer_qualifier = "(outer)" if mode == "medium" else "" inner_qualifier = "(inner)" if mode == "medium" else "" if entry.outer_dev_iteration is not None: suffix = _build_outer_iteration_suffix( entry.outer_dev_iteration, entry.outer_dev_cap, od_glyph=od_glyph, qualifier=outer_qualifier, ) line.append(suffix, style="theme.outer_dev") if entry.inner_analysis is not None: suffix = _build_inner_analysis_suffix( entry.inner_analysis, entry.inner_analysis_cap, ia_glyph=ia_glyph, qualifier=inner_qualifier, ) line.append(suffix, style="theme.inner_analysis") if ( mode == "medium" and entry.inner_analysis is not None and entry.inner_analysis_cap is not None ): remaining = entry.inner_analysis_cap - entry.inner_analysis if remaining > 0: line.append(f" [{remaining} left]", style="theme.text.muted") elif remaining == 0: line.append(" [last]", style="theme.level.warn") if entry.agent_name is not None: line.append(f" agent={entry.agent_name}", style="theme.text.muted") c.print(line)
def _build_phase_close_stats_line( exit_model: PhaseExitModel, display_context: DisplayContext, ) -> Text | None: """Build an activity-stats supplementary line for the phase-close banner. Returns None when all counters are zero or when in compact mode. In medium and wide mode surfaces content/thinking/tool/error counts so the phase-close banner gives a full picture of agent activity. """ if display_context.mode == "compact": return None total = ( exit_model.content_blocks + exit_model.thinking_blocks + exit_model.tool_calls + exit_model.errors ) if total == 0: return None stats = Text() stats.append(" ↳ stats: ", style="theme.text.muted") parts: list[tuple[str, str]] = [ (f"content={exit_model.content_blocks}", "theme.text.muted"), (f"thinking={exit_model.thinking_blocks}", "theme.text.muted"), (f"tools={exit_model.tool_calls}", "theme.text.muted"), ] if exit_model.errors > 0: parts.append((f"errors={exit_model.errors}", "theme.level.error")) for i, (part_text, part_style) in enumerate(parts): if i > 0: stats.append(" ", style="theme.text.muted") stats.append(part_text, style=part_style) return stats def _build_review_outcome_line( exit_model: PhaseExitModel, display_context: DisplayContext, ) -> Text | None: """Build a review outcome line if review_issues_found is set. Returns None when review_issues_found is None (not applicable). Review outcome is always shown regardless of display mode since it is critical UX information about whether review passed or found issues. """ if exit_model.review_issues_found is None: return None review_line = Text() review_glyph_pass = display_context.glyph_for("review_pass") review_glyph_fail = display_context.glyph_for("review_fail") if exit_model.review_issues_found: review_line.append(f" {review_glyph_fail} ", style="theme.review_fail") review_line.append("review: ", style="theme.text.muted") review_line.append("issues found", style="theme.level.error") else: review_line.append(f" {review_glyph_pass} ", style="theme.review_pass") review_line.append("review: ", style="theme.text.muted") review_line.append("clean", style="theme.status.success") return review_line def _build_debug_line( exit_model: PhaseExitModel, display_context: DisplayContext, ) -> Text | None: """Build a debug breadcrumb line if waiting status or failure category is set. Returns None when neither is set. """ if not exit_model.waiting_status_line and not exit_model.last_failure_category: return None debug_line = Text() warning_glyph = display_context.glyph_for("warning") debug_parts: list[str] = [] if exit_model.waiting_status_line: debug_parts.append(f"waiting: {exit_model.waiting_status_line[:80]}") if exit_model.last_failure_category: debug_parts.append(f"failure: {exit_model.last_failure_category}") debug_line.append(f" {warning_glyph} debug: ", style="theme.level.warn") debug_line.append(" | ".join(debug_parts), style="theme.text.muted") return debug_line def _print_wide_close_rule( style: str, console: Console, *, elapsed_seconds: float = 0.0, exit_trigger: str | None = None, arrow: str = "→", ) -> None: """Print the wide-mode trailing titled Rule as the section-close separator. When elapsed time and/or exit trigger are available, they form the Rule title so the section footer mirrors the header and is immediately readable when scrolling through output. Falls back to a plain Rule when both are absent. """ parts: list[str] = [] if elapsed_seconds > 0: parts.append(format_elapsed_seconds(elapsed_seconds)) if exit_trigger is not None: parts.append(f"{arrow} {exit_trigger}") if parts: console.print(Rule(title=" ".join(parts), style=style)) else: console.print(Rule(style=style))
[docs] def show_phase_close_banner( exit_model: PhaseExitModel, *, display_context: DisplayContext, pipeline_policy: PipelinePolicy | None = None, ) -> None: """Display the close of a pipeline phase from a lifecycle exit model. Canonical model-based path for phase-close rich banners. Symmetric with :func:`show_phase_start_from_entry`: same field ordering, same glyphs, same style keys. Appends elapsed time and exit trigger after the iteration context. In medium and wide modes an additional stats line surfaces content/thinking/ tool/error counters from the exit model so the close banner is a full phase-level performance report. """ c = display_context.console style = phase_style(exit_model.phase_name, pipeline_policy) label = phase_label(exit_model.phase_name) line = Text() success_glyph = display_context.glyph_for("success") od_glyph = display_context.glyph_for("outer_dev") ia_glyph = display_context.glyph_for("inner_analysis") arrow = display_context.glyph_for("arrow") line.append(f"{success_glyph} ", style=style) line.append(label, style=style) mode = display_context.mode outer_qualifier = "(outer)" if mode in ("medium", "wide") else "" inner_qualifier = "(inner)" if mode in ("medium", "wide") else "" if exit_model.outer_dev_iteration is not None: suffix = _build_outer_iteration_suffix( exit_model.outer_dev_iteration, exit_model.outer_dev_cap, od_glyph=od_glyph, qualifier=outer_qualifier, ) line.append(suffix, style="theme.outer_dev") if exit_model.inner_analysis is not None: suffix = _build_inner_analysis_suffix( exit_model.inner_analysis, exit_model.inner_analysis_cap, ia_glyph=ia_glyph, qualifier=inner_qualifier, ) line.append(suffix, style="theme.inner_analysis") if exit_model.elapsed_seconds > 0: line.append( f" {format_elapsed_seconds(exit_model.elapsed_seconds)}", style="theme.text.muted", ) if exit_model.exit_trigger is not None: line.append(f" {arrow} {exit_model.exit_trigger}", style="theme.text.muted") c.print(line) stats_line = _build_phase_close_stats_line(exit_model, display_context) if stats_line is not None: c.print(stats_line) if exit_model.artifact_outcome and mode != "compact": artifact_line = Text() artifact_line.append(" ↳ artifact: ", style="theme.text.muted") artifact_line.append(exit_model.artifact_outcome, style="theme.text.emphasis") c.print(artifact_line) review_line = _build_review_outcome_line(exit_model, display_context) if review_line is not None: c.print(review_line) # Routing note — explains why an adjacent phase was skipped (e.g. analysis cap reached). # Shown in all modes since it is actionable routing context, not merely decorative. if exit_model.routing_note is not None: routing_line = Text() routing_line.append(f" {arrow} ", style="theme.text.muted") routing_line.append(exit_model.routing_note, style="theme.level.warn") c.print(routing_line) debug_line = _build_debug_line(exit_model, display_context) if debug_line is not None: c.print(debug_line) # Wide mode: titled trailing Rule closes the section visually. # The title mirrors the header so the section footer is readable when scrolling. if mode == "wide": _print_wide_close_rule( style, c, elapsed_seconds=exit_model.elapsed_seconds, exit_trigger=exit_model.exit_trigger, arrow=arrow, )