"""Eval progress display built on pytui's Elm-style App.
Tails events.jsonl (written by evaluate() via logging), derives display state,
renders progress in alternate screen via a subprocess.
Usage:
    with progress_display(output_dir=output_dir):
        await evaluate(dataset, config)
Or detached mode (two terminals):
    # Terminal 1: Run eval (writes events.jsonl via logging)
    python run_eval.py --config config.py
    # Terminal 2: Watch progress
    python -m wafer_core.rollouts.progress_app /path/to/events.jsonl
"""
from __future__ import annotations
import json
import os
import re
import signal
import subprocess
import sys
import time
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
# ── ANSI colors ──────────────────────────────────────────────────────────────
DIM = "\x1b[90m"
RESET = "\x1b[0m"
GREEN = "\x1b[32m"
RED = "\x1b[31m"
YELLOW = "\x1b[33m"
CYAN = "\x1b[36m"
BOLD = "\x1b[1m"
BG_SELECTED = "\x1b[48;5;236m"  # Dark gray background for selected row
BG_CURSOR = "\x1b[48;5;236m"  # Same dark gray for viewport cursor
CURSOR_FG = "\x1b[33m"  # Yellow for cursor indicator (more compatible than RGB)
BLUE = "\x1b[34m"
# Event types the progress display understands.
# The "message" field in the JSONL record is the event type discriminator.
# Records with unrecognised message values are silently skipped.
KNOWN_EVENT_TYPES = frozenset({
    "eval_start",
    "eval_end",
    "sample_start",
    "sample_end",
    "turn",
    "modal_progress",
    "gepa_iteration",
    "gepa_accepted",
    "gepa_rejected",
    "sample_retry",
})
# Event types in per-sample JSONL files
SAMPLE_EVENT_TYPES = frozenset({
    "text_delta",
    "thinking_delta",
    "assistant_message",
    "tool_execution",
    "llm_call",
    "modal_progress",
    "turn",
    "sample_start",
    "sample_end",
})
# ── Utilities ────────────────────────────────────────────────────────────────
# Regex for matching ANSI escape codes
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
def _clamp(value: int, min_val: int, max_val: int) -> int:
    """Clamp value to [min_val, max_val]."""
    assert min_val <= max_val, f"min_val ({min_val}) > max_val ({max_val})"
    return max(min_val, min(value, max_val))
def _visible_len(s: str) -> int:
    """Return visible length of string (excluding ANSI codes)."""
    return len(_ANSI_ESCAPE.sub("", s))
def _ansi_slice(s: str, start: int) -> str:
    """Slice a string with ANSI codes from visible position `start`.
    Returns substring starting at visible character `start`, preserving
    any ANSI codes that were active at that position.
    """
    if start <= 0:
        return s
    visible_pos = 0
    i = 0
    active_codes: list[str] = []  # Track active ANSI codes
    while i < len(s) and visible_pos < start:
        match = _ANSI_ESCAPE.match(s, i)
        if match:
            code = match.group()
            if code == RESET:
                active_codes.clear()
            else:
                active_codes.append(code)
            i = match.end()
        else:
            visible_pos += 1
            i += 1
    result = s[i:]
    if active_codes:
        result = "".join(active_codes) + result
    return result
# ── State ────────────────────────────────────────────────────────────────────
@dataclass
class SampleState:
    """State of a single sample."""
    id: str
    name: str = ""
    turn: int = 0
    phase: str = ""
    score: float | None = None
    status: str = "started"  # started, complete, retry
    retry_attempt: int = 0
    start_time: float = field(default_factory=time.time)
    last_update: float = field(default_factory=time.time)
@dataclass
class RenderState:
    """Display state derived from events."""
    eval_name: str = ""
    total: int = 0
    completed: int = 0
    samples: dict[str, SampleState] = field(default_factory=dict)
    start_time: float = field(default_factory=time.time)
    gepa_iter: int | None = None
    gepa_total: int | None = None
    gepa_best: float | None = None
    scores: list[float] = field(default_factory=list)
# ── Pure functions ───────────────────────────────────────────────────────────
def _format_time(seconds: float) -> str:
    """Format seconds as H:MM:SS or M:SS."""
    h = int(seconds) // 3600
    m = int(seconds) % 3600 // 60
    s = int(seconds) % 60
    if h:
        return f"{h}:{m:02d}:{s:02d}"
    return f"{m}:{s:02d}"
def _format_bar(progress: float, width: int) -> str:
    """Render a progress bar with unicode blocks."""
    if width <= 0:
        return ""
    filled = progress * width
    full_blocks = int(filled)
    partial_idx = int(8 * filled) % 8
    partial = " ▏▎▍▌▋▊▉"[partial_idx] if partial_idx else ""
    bar = "█" * full_blocks + partial
    return bar.ljust(width)
def derive_state(events: list[dict[str, Any]]) -> RenderState:
    """Derive current display state from event stream. Stateless."""
    state = RenderState()
    for event in events:
        event_type = event.get("message")
        if event_type is None or event_type not in KNOWN_EVENT_TYPES:
            continue
        sample_id = event.get("sample_id", "")
        if event_type == "eval_start":
            state.eval_name = event.get("eval_name", "eval")
            state.total = event.get("total", 0)
            if ts := event.get("timestamp"):
                from datetime import datetime
                state.start_time = datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
            else:
                state.start_time = time.time()
        elif event_type == "sample_start":
            state.samples[sample_id] = SampleState(
                id=sample_id,
                name=event.get("sample_name", sample_id),
                last_update=time.time(),
                phase="starting",
            )
        elif event_type == "turn":
            if sample_id in state.samples:
                if "turn" in event:
                    state.samples[sample_id].turn = event["turn"]
                state.samples[sample_id].last_update = time.time()
                status = event.get("status", "running")
                state.samples[sample_id].phase = status
        elif event_type == "modal_progress":
            if sample_id in state.samples:
                state.samples[sample_id].phase = event.get("phase", "")
                state.samples[sample_id].last_update = time.time()
        elif event_type == "sample_end":
            if sample_id in state.samples:
                state.samples[sample_id].status = "complete"
                score = event.get("score")
                if score is not None:
                    state.samples[sample_id].score = score
                    state.scores.append(score)
                state.completed += 1
        elif event_type == "sample_retry":
            if sample_id in state.samples:
                state.samples[sample_id].status = "retry"
                state.samples[sample_id].retry_attempt = event.get("attempt", 1)
        elif event_type == "gepa_iteration":
            state.gepa_iter = event.get("iter")
            state.gepa_total = event.get("total")
            state.gepa_best = event.get("best")
        elif event_type == "eval_end":
            pass  # Handled by update() -> Cmd.quit()
    return state
def _format_sample_row(
    sample: SampleState,
    width: int,
    selected: bool = False,
    marquee_offset: int = 0,
) -> tuple[str, int]:
    """Format a single sample row.
    Returns (formatted_row, overflow_chars) where overflow_chars is how many
    characters extend beyond the visible width (for marquee animation).
    """
    # For selected rows, don't truncate name - show full content with marquee
    if selected:
        name = sample.name
    else:
        name = sample.name[:25].ljust(25)
    turn_info = f"T:{sample.turn}"
    if sample.status == "complete":
        if sample.score is not None:
            icon = f"{GREEN}✓{RESET}" if sample.score > 0.5 else f"{RED}✗{RESET}"
            row = f"  {name} {turn_info:>4}  {icon} score={sample.score:.2f}"
        else:
            row = f"  {name} {turn_info:>4}  {GREEN}✓{RESET} done"
    elif sample.status == "retry":
        row = f"  {name} {turn_info:>4}  {YELLOW}⟳{RESET} retry (attempt {sample.retry_attempt})"
    else:
        phase = sample.phase or "running"
        row = f"  {name} {turn_info:>4}  {CYAN}{phase}...{RESET}"
    visible_len = _visible_len(row)
    overflow = max(0, visible_len - width)
    if selected:

        if marquee_offset > 0 and overflow > 0:
            offset = min(marquee_offset, overflow)

            row = "  " + _ansi_slice(row, 2 + offset)
        row = f"{BG_SELECTED}{row}{RESET}"
    return row, overflow
# ── Viewport formatting ──────────────────────────────────────────────────────
def format_sample_event(
    event: dict[str, Any], last_turn: int | None = None
) -> tuple[str | None, int | None]:
    """Format a per-sample JSONL event into a readable line.
    Returns (formatted_line, new_turn_number).
    formatted_line is None if the event should not be displayed.
    new_turn_number is the turn number if this event changes it, else None.
    """
    msg = event.get("message", "")
    if msg == "text_delta":
        # Streaming text — return raw text for accumulation
        return event.get("text", ""), None
    elif msg == "thinking_delta":
        # Extended thinking — show dimmed
        text = event.get("text", "")
        return (f"{DIM}{text}{RESET}" if text else None), None
    elif msg == "assistant_message":
        # Final assistant message — skip if we've been streaming
        # The caller handles dedup
        return None, None
    elif msg == "tool_execution":
        tool = event.get("tool_name", "tool")
        duration = event.get("duration_ms", 0)
        summary = event.get("result_summary") or {}
        is_error = event.get("is_error", False)
        color = RED if is_error else CYAN
        # Format tool-specific summary
        if tool == "edit" and isinstance(summary, dict):
            path = summary.get("path", "")
            # Show just filename, not full path
            filename = path.split("/")[-1] if path else ""
            added = summary.get("lines_added", 0)
            removed = summary.get("lines_removed", 0)
            diff_info = f"{filename} +{added}/-{removed}"
            return f"{color}→ {tool}{RESET} ({duration / 1000:.1f}s) {diff_info}", None
        elif tool == "bash" and isinstance(summary, dict):
            cmd = summary.get("command", "")

            cmd_short = cmd[:50] + "…" if len(cmd) > 50 else cmd
            return f"{color}→ {tool}{RESET} ({duration / 1000:.1f}s) {cmd_short}", None
        elif tool == "write" and isinstance(summary, dict):
            path = summary.get("path", "")
            filename = path.split("/")[-1] if path else ""
            return f"{color}→ {tool}{RESET} ({duration / 1000:.1f}s) {filename}", None
        else:
            # Fallback: extract error or show summary
            if isinstance(summary, dict) and "error" in summary:
                status = summary["error"]
            elif isinstance(summary, dict):
                # Try to get a useful field
                status = summary.get("result") or summary.get("output") or str(summary)
            else:
                status = str(summary) if summary else ""
            # Collapse newlines to keep it on one line
            status = status.replace("\n", " ").replace("\r", "")
            return f"{color}→ {tool}{RESET} ({duration / 1000:.1f}s) {status}", None
    elif msg == "llm_call":
        provider = event.get("provider", "")
        model = event.get("model", "")
        tokens_in = event.get("tokens_in", 0)
        tokens_out = event.get("tokens_out", 0)
        duration = event.get("duration_ms", 0)
        model_short = model.split("/")[-1] if model else provider
        return (
            f"{DIM}◆ {model_short} {tokens_in}→{tokens_out} tok ({duration / 1000:.1f}s){RESET}",
            None,
        )
    elif msg == "modal_progress":
        phase = event.get("phase", "")
        return (f"{YELLOW}⟳ {phase}...{RESET}" if phase else None), None
    elif msg == "turn":
        turn = event.get("turn")
        # Only show turn separator when turn number changes
        if turn is not None and turn != last_turn:
            return f"{DIM}── turn {turn} ──{RESET}", turn
        return None, turn if turn is not None else last_turn
    return None, None
# ── Render function ──────────────────────────────────────────────────────────
def render_header(state: RenderState, width: int) -> str:
    """Render the progress header line."""
    if state.gepa_iter is not None:
        header = (
            f"GEPA iter {state.gepa_iter}/{state.gepa_total or '?'} │ best: {state.gepa_best:.0%}"
            if state.gepa_best
            else f"GEPA iter {state.gepa_iter}/{state.gepa_total or '?'}"
        )
    else:
        elapsed = time.time() - state.start_time
        if state.total > 0:
            progress = state.completed / state.total
            bar_width = min(30, width - 50)
            bar = _format_bar(progress, bar_width)
            eta = ""
            if state.completed > 0 and progress < 1.0:
                remaining = (elapsed / progress) - elapsed
                eta = f" <{_format_time(remaining)}"
            header = (
                f"{state.eval_name}: {BOLD}{state.completed}/{state.total}{RESET} "
                f"|{bar}| {100 * progress:3.0f}% "
                f"[{_format_time(elapsed)}{eta}]"
            )
        else:
            header = f"{state.eval_name}: {state.completed} samples [{_format_time(elapsed)}]"
    return header[:width]
# ── Elm architecture (subprocess entry point) ────────────────────────────────
@dataclass(frozen=True)
class Model:
    """Immutable model for the pytui App.
    Uses bubbles-style cursor tracking:
    - selected_sample_id: stable selection by ID (not index)
    - viewport_scroll: scroll offset for detail viewport
    Note: list_scroll is computed dynamically in view() to keep selection visible.
    """
    events: tuple[dict[str, Any], ...] = ()
    done: bool = False
    # Focus state
    focus: str = "list"  # "list" or "viewport"
    # Sample list state (bubbles table pattern)
    selected_sample_id: str | None = None  # Track by ID, not index
    # Marquee state for selected row (scrolls long text horizontally)
    marquee_offset: int = 0  # Current scroll offset for selected row text
    # Viewport state
    viewport_lines: tuple[str, ...] = ()  # Formatted lines for viewport
    viewport_scroll: int = 0  # Scroll position in viewport
    viewport_cursor: int = 0  # Cursor line in viewport (only this line marquees)
    viewport_auto_follow: bool = True  # Auto-scroll to bottom on new content
    # Viewport marquee state (for scrolling the cursor line only)
    viewport_marquee_offset: int = 0  # Horizontal scroll for cursor line
    # For streaming text accumulation
    streaming_text: str = ""
    # For deduping turn separators
    last_turn: int | None = None
@dataclass(frozen=True)
class NewEvent:
    """New line from events.jsonl."""
    line: str
@dataclass(frozen=True)
class SampleEvent:
    """New line from per-sample JSONL file."""
    line: str
@dataclass(frozen=True)
class MarqueeTick:
    """Periodic tick for marquee animation on selected sample row."""
    pass
@dataclass(frozen=True)
class ViewportMarqueeTick:
    """Periodic tick for marquee animation on viewport lines."""
    pass
def _parse_event(line: str) -> dict[str, Any] | None:
    try:
        return json.loads(line)
    except json.JSONDecodeError:
        return None
def _get_active_samples(events: tuple[dict[str, Any], ...]) -> list[SampleState]:
    """Get list of active (non-complete) samples from events.
    Returns samples sorted by name (stable order) not last_update (jumping order).
    """
    state = derive_state(list(events))
    active = [s for s in state.samples.values() if s.status != "complete" and s.phase]
    active.sort(key=lambda s: s.name)
    return active
def _find_sample_index(active: list[SampleState], sample_id: str | None) -> int:
    """Find index of sample_id in active list, or 0 if not found."""
    if sample_id is None:
        return 0
    for i, s in enumerate(active):
        if s.id == sample_id:
            return i
    return 0
def _get_sample_at_index(active: list[SampleState], idx: int) -> SampleState | None:
    """Get sample at index, or None if out of bounds."""
    if 0 <= idx < len(active):
        return active[idx]
    return None
def _handle_marquee_tick(model: Model) -> tuple[Model, Any]:
    """Handle marquee tick for selected row animation."""
    from ._pytui import Cmd
    if not model.selected_sample_id:
        return model, Cmd.none()
    active = _get_active_samples(model.events)
    selected_idx = _find_sample_index(active, model.selected_sample_id)
    sample = _get_sample_at_index(active, selected_idx)
    if not sample:
        return model, Cmd.none()
    _, overflow = _format_sample_row(sample, 80, selected=True, marquee_offset=0)
    if overflow <= 0:
        # No overflow, reset offset
        if model.marquee_offset != 0:
            return replace(model, marquee_offset=0), Cmd.none()
        return model, Cmd.none()
    # Advance marquee with pause at start and end
    # offset < 0: pause at start (show beginning)
    # offset 0..overflow: scrolling
    # offset > overflow: pause at end (show end)
    pause_ticks = 5  # Ticks to pause at start/end
    new_offset = model.marquee_offset + 1
    if new_offset > overflow + pause_ticks:
        new_offset = -pause_ticks  # Snap back to start pause
    return replace(model, marquee_offset=new_offset), Cmd.none()
def _get_cursor_line_overflow(model: Model, width: int) -> int:
    """Get the overflow for the cursor line only."""
    if not model.viewport_lines:
        return 0
    cursor = min(model.viewport_cursor, len(model.viewport_lines) - 1)
    if cursor < 0:
        return 0
    line = model.viewport_lines[cursor]
    visible_len = _visible_len(line)
    return max(0, visible_len - width)
def _handle_viewport_marquee_tick(model: Model) -> tuple[Model, Any]:
    """Handle marquee tick for cursor line animation only."""
    from ._pytui import Cmd
    # Only animate when viewport is focused
    if model.focus != "viewport":
        if model.viewport_marquee_offset != 0:
            return replace(model, viewport_marquee_offset=0), Cmd.none()
        return model, Cmd.none()
    cursor_overflow = _get_cursor_line_overflow(model, 80)
    if cursor_overflow <= 0:
        if model.viewport_marquee_offset != 0:
            return replace(model, viewport_marquee_offset=0), Cmd.none()
        return model, Cmd.none()
    # Advance marquee with pause at start and end
    pause_ticks = 8  # Longer pause for viewport (more content to read)
    new_offset = model.viewport_marquee_offset + 1
    if new_offset > cursor_overflow + pause_ticks:
        new_offset = -pause_ticks  # Snap back to start pause
    return replace(model, viewport_marquee_offset=new_offset), Cmd.none()
def update(model: Model, msg: object) -> tuple[Model, Any]:
    from ._pytui import Cmd, KeyPress
    match msg:
        case KeyPress(key="\x03"):  # Ctrl+C: cancel eval and quit
            # Signal parent process to cancel the eval
            os.kill(os.getppid(), signal.SIGINT)
            return model, Cmd.quit()
        case KeyPress(key="q"):  # q: detach (quit TUI, eval continues)
            return model, Cmd.quit()
        # Focus switching
        case KeyPress(key="\t"):  # Tab
            new_focus = "viewport" if model.focus == "list" else "list"
            # Reset viewport marquee when switching focus
            return replace(model, focus=new_focus, viewport_marquee_offset=-8), Cmd.none()
        # Navigation in list focus
        case KeyPress(key="j" | "\x1b[B") if model.focus == "list":  # Down
            active = _get_active_samples(model.events)
            if not active:
                return model, Cmd.none()

            current_idx = _find_sample_index(active, model.selected_sample_id)
            new_idx = _clamp(current_idx + 1, 0, len(active) - 1)
            new_sample = active[new_idx]
            # Reset viewport and marquee when selection changes
            if new_sample.id != model.selected_sample_id:
                return replace(
                    model,
                    selected_sample_id=new_sample.id,
                    marquee_offset=-5,  # Start with pause at beginning
                    viewport_lines=(),
                    viewport_cursor=0,
                    viewport_auto_follow=True,
                    viewport_marquee_offset=-8,
                    streaming_text="",
                    last_turn=None,
                ), Cmd.none()
            return model, Cmd.none()
        case KeyPress(key="k" | "\x1b[A") if model.focus == "list":  # Up
            active = _get_active_samples(model.events)
            if not active:
                return model, Cmd.none()

            current_idx = _find_sample_index(active, model.selected_sample_id)
            new_idx = _clamp(current_idx - 1, 0, len(active) - 1)
            new_sample = active[new_idx]
            if new_sample.id != model.selected_sample_id:
                return replace(
                    model,
                    selected_sample_id=new_sample.id,
                    marquee_offset=-5,  # Start with pause at beginning
                    viewport_lines=(),
                    viewport_cursor=0,
                    viewport_auto_follow=True,
                    viewport_marquee_offset=-8,
                    streaming_text="",
                    last_turn=None,
                ), Cmd.none()
            return model, Cmd.none()
        # Navigation in viewport focus - j/k moves cursor, scroll follows
        case KeyPress(key="j" | "\x1b[B") if model.focus == "viewport":  # Down
            max_cursor = max(0, len(model.viewport_lines) - 1)
            new_cursor = min(model.viewport_cursor + 1, max_cursor)
            # Reset marquee when cursor moves
            return replace(
                model,
                viewport_cursor=new_cursor,
                viewport_marquee_offset=-8,  # Pause at start
                viewport_auto_follow=False,
            ), Cmd.none()
        case KeyPress(key="k" | "\x1b[A") if model.focus == "viewport":  # Up
            new_cursor = max(0, model.viewport_cursor - 1)
            return replace(
                model,
                viewport_cursor=new_cursor,
                viewport_marquee_offset=-8,
                viewport_auto_follow=False,
            ), Cmd.none()
        case KeyPress(key="G") if model.focus == "viewport":  # Jump to bottom
            max_cursor = max(0, len(model.viewport_lines) - 1)
            return replace(
                model,
                viewport_cursor=max_cursor,
                viewport_marquee_offset=-8,
                viewport_auto_follow=True,
            ), Cmd.none()
        case KeyPress(key="g"):  # gg = top (simplified, just g for now)
            if model.focus == "viewport":
                return replace(
                    model,
                    viewport_cursor=0,
                    viewport_marquee_offset=-8,
                    viewport_auto_follow=False,
                ), Cmd.none()
        # Events from events.jsonl
        case NewEvent(line=line):
            event = _parse_event(line)
            if event is None:
                return model, Cmd.none()
            new_events = model.events + (event,)
            if event.get("message") == "eval_end":
                return replace(model, events=new_events, done=True), Cmd.quit()
            new_model = replace(model, events=new_events)
            # Auto-select first sample if none selected
            if model.selected_sample_id is None:
                active = _get_active_samples(new_events)
                if active:
                    new_model = replace(new_model, selected_sample_id=active[0].id)
            return new_model, Cmd.none()
        # Events from per-sample JSONL file
        case SampleEvent(line=line):
            event = _parse_event(line)
            if event is None:
                return model, Cmd.none()
            formatted, new_turn = format_sample_event(event, model.last_turn)
            msg_type = event.get("message", "")
            if msg_type == "text_delta":
                new_streaming = model.streaming_text + (formatted or "")
                return replace(model, streaming_text=new_streaming), Cmd.none()
            # Non-delta event: flush streaming text and add formatted line
            flushed_lines = [l for l in model.streaming_text.split("\n") if l]
            event_line = [formatted] if formatted else []
            new_lines = model.viewport_lines + tuple(flushed_lines) + tuple(event_line)
            # Auto-follow: move cursor to last line (scroll computed in view)
            new_cursor = (
                max(0, len(new_lines) - 1)
                if model.viewport_auto_follow
                else model.viewport_cursor
            )
            return replace(
                model,
                viewport_lines=new_lines,
                viewport_cursor=new_cursor,
                streaming_text="",
                last_turn=new_turn if new_turn is not None else model.last_turn,
            ), Cmd.none()
        # Marquee tick for selected row animation
        case MarqueeTick():
            return _handle_marquee_tick(model)
        # Marquee tick for viewport lines animation
        case ViewportMarqueeTick():
            return _handle_viewport_marquee_tick(model)
    return model, Cmd.none()
def _render_viewport(model: Model, width: int, viewport_height: int, list_height: int) -> list[str]:
    """Render the viewport section showing selected sample's output.
    Only the cursor line gets marquee animation and visual highlighting.
    """
    lines: list[str] = []
    if model.selected_sample_id:
        # Include any streaming text as a "virtual" line at the end
        display_lines = list(model.viewport_lines)
        if model.streaming_text:
            # Show streaming text (last 200 chars to keep it manageable)
            # Collapse newlines to keep it on one display line
            stream_preview = model.streaming_text[-200:].replace("\n", " ").replace("\r", "")
            if len(model.streaming_text) > 200:
                stream_preview = "..." + stream_preview
            display_lines.append(f"{CYAN}{stream_preview}{RESET}")
        if display_lines:
            # Clamp cursor to valid range
            cursor = _clamp(model.viewport_cursor, 0, len(display_lines) - 1)
            scroll = max(0, cursor - viewport_height // 2)
            scroll = _clamp(scroll, 0, max(0, len(display_lines) - viewport_height))
            # Only apply marquee to cursor line when viewport is focused
            marquee_offset = (
                max(0, model.viewport_marquee_offset) if model.focus == "viewport" else 0
            )
            # Render visible lines
            for i in range(scroll, min(scroll + viewport_height, len(display_lines))):
                line = display_lines[i]
                is_cursor = i == cursor
                visible_len = _visible_len(line)
                overflow = visible_len - width
                if is_cursor:
                    # Cursor line: apply marquee if overflow (no truncation - let it scroll)
                    if overflow > 0 and marquee_offset > 0:
                        offset = min(marquee_offset, overflow)
                        line = _ansi_slice(line, offset)
                    line = f"{CURSOR_FG}▸{RESET}{BG_CURSOR}{line}{RESET}"
                else:
                    # Non-cursor line: just truncate if needed
                    if overflow > 0:
                        line = line[: width + 20] + "…"
                    line = f" {line}"
                lines.append(line)
        else:
            lines.append(f"{DIM}  (waiting for events...){RESET}")
        # Pad viewport
        target_len = viewport_height
    else:
        # No sample selected
        lines.append(f"{DIM}  (select a sample to view its output){RESET}")
        target_len = viewport_height
    while len(lines) < target_len:
        lines.append("")
    # Cap at viewport_height to prevent overflow
    return lines[:viewport_height]
def view(model: Model, width: int, height: int) -> list[str]:
    """Render the split view: sample list on top, viewport on bottom.
    Uses bubbles-style bounded rendering:
    - Only render visible rows in each section
    - Keep selected item in view
    - Clamp scroll positions
    """
    state = derive_state(list(model.events))
    lines: list[str] = []
    # Header
    lines.append(render_header(state, width))
    # Sample list gets ~1/3 of space (min 4 lines), viewport gets rest
    list_height = max(4, min(height // 3, 10))
    viewport_height = height - list_height - 3  # -3 for header, separator, footer
    active = _get_active_samples(model.events)

    selected_idx = _find_sample_index(active, model.selected_sample_id)
    # Reserve last line for scroll indicator if there will be hidden items
    needs_scroll_indicator = len(active) > list_height - 1
    list_visible_height = list_height - 2 if needs_scroll_indicator else list_height - 1
    # Start with scroll that centers selection, then clamp
    list_scroll = max(0, selected_idx - list_visible_height // 2)
    list_scroll = _clamp(list_scroll, 0, max(0, len(active) - list_visible_height))
    # Render visible sample rows
    visible_end = min(list_scroll + list_visible_height, len(active))
    for i in range(list_scroll, visible_end):
        sample = active[i]
        selected = i == selected_idx
        row, _overflow = _format_sample_row(
            sample,
            width,
            selected=selected,
            marquee_offset=model.marquee_offset if selected else 0,
        )
        lines.append(row)
    # Show scroll indicator if there are hidden items (on its own line)
    hidden_above = list_scroll
    hidden_below = max(0, len(active) - visible_end)
    if hidden_above > 0 or hidden_below > 0:
        indicator_parts = []
        if hidden_above > 0:
            indicator_parts.append(f"↑{hidden_above}")
        if hidden_below > 0:
            indicator_parts.append(f"↓{hidden_below}")
        indicator = " ".join(indicator_parts)
        lines.append(f"{DIM}  ... {indicator} more{RESET}")
    # Pad list area
    while len(lines) < list_height:
        lines.append("")
    # Separator with focus indicator and selected sample name
    selected_name = ""
    if model.selected_sample_id:
        sample = _get_sample_at_index(active, selected_idx)
        if sample:
            selected_name = f" [{sample.name[:20]}]"
    if model.focus == "list":
        label = "samples"
    else:
        label = "viewport"
    prefix = f"─── {label}{selected_name} "
    fill = "─" * max(0, width - len(prefix))
    lines.append(f"{DIM}{prefix}{fill}{RESET}")
    # Viewport
    lines.extend(_render_viewport(model, width, viewport_height, list_height))
    # Footer
    if model.focus == "list":
        footer = f"{DIM}j/k: select  Tab: viewport  q: detach  ^C: cancel{RESET}"
    else:
        follow_status = "follow" if model.viewport_auto_follow else "manual"
        cursor_pos = f"{model.viewport_cursor + 1}/{len(model.viewport_lines)}" if model.viewport_lines else "0/0"
        footer = f"{DIM}j/k: cursor  G: bottom  Tab: list  [{follow_status}] {cursor_pos}  q: detach  ^C: cancel{RESET}"
    # Pad to fill screen
    while len(lines) < height - 1:
        lines.append("")
    lines.append(footer)
    return lines[:height]
# ── Context manager (spawns subprocess) ──────────────────────────────────────
@contextmanager
def progress_display(
    output_dir: Path | str,
    disable: bool = False,
    debug_log: Path | str | None = None,
) -> Generator[Path, None, None]:
    """Context manager that spawns a pytui progress display in a subprocess.
    The subprocess owns the terminal (alternate screen, raw mode via /dev/tty).
    The parent process writes events.jsonl via logging as usual.
    Args:
        output_dir: Directory containing events.jsonl
        disable: If True, skip progress display (verbose mode)
        debug_log: If set, write pytui debug events to this file
    """
    if disable:
        yield Path(output_dir)
        return
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    events_file = output_dir / "events.jsonl"
    cmd = [sys.executable, "-m", "wafer_core.rollouts.progress_app", str(events_file)]
    if debug_log is None:
        # Default: write debug log to output_dir
        debug_log = output_dir / "pytui_debug.jsonl"
    if debug_log:
        cmd.extend(["--debug-log", str(debug_log)])
    proc = subprocess.Popen(
        cmd,
        # Don't touch stdout/stderr — child inherits the terminal.
        # pytui opens /dev/tty directly for input, writes to stdout for rendering.
        #
        # We need the subprocess to stay in the same session so it can access
        # /dev/tty for keyboard input. SIGINT handling: pytui is in raw mode
        # so Ctrl+C becomes a regular keypress (^C / \x03) rather than generating
        # SIGINT. The progress app handles KeyPress("\x03") -> Cmd.quit().
        stdin=subprocess.DEVNULL,
    )
    try:
        yield output_dir
    finally:
        # Give the subprocess a chance to clean up its terminal state.
        proc.send_signal(signal.SIGTERM)
        try:
            proc.wait(timeout=5.0)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait()
        # Defensive: restore terminal to sane state in case the subprocess
        # didn't clean up (e.g. got SIGKILL, or crashed before restoring).
        # stty sane resets raw mode, re-enables echo, etc.
        tty_fd = os.open("/dev/tty", os.O_RDWR)
        try:
            subprocess.run(["stty", "sane"], stdin=tty_fd)
        finally:
            os.close(tty_fd)
# ── CLI entry point (subprocess runs this) ────────────────────────────────────
if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("events_file", help="Path to events.jsonl")
    parser.add_argument("--debug-log", help="Path to debug log file")
    args = parser.parse_args()
    from ._pytui import App, Cmd, Sub
    events_file = args.events_file
    output_dir = Path(events_file).parent
    samples_dir = output_dir / "samples"
    def _subscriptions(model: Model) -> Sub:
        if model.done:
            return Sub.none()
        subs = [Sub.file_tail(events_file, lambda line: NewEvent(line=line))]
        # Tail the selected sample's file if we have one
        if model.selected_sample_id:
            sample_file = samples_dir / f"{model.selected_sample_id}.jsonl"
            if sample_file.exists():
                subs.append(Sub.file_tail(str(sample_file), lambda line: SampleEvent(line=line)))
            # Marquee animation tick (scrolls selected row text)
            subs.append(Sub.every(0.15, lambda: MarqueeTick()))
        # Viewport marquee animation (scrolls long lines when viewport focused)
        if model.focus == "viewport" and model.viewport_lines:
            subs.append(Sub.every(0.12, lambda: ViewportMarqueeTick()))
        return Sub.batch(*subs)
    App(
        init=(Model(), Cmd.none()),
        update=update,
        view=view,
        subscriptions=_subscriptions,
        alternate_screen=False,
        debug_log=args.debug_log,
    ).run()
