"""Textual terminal UI for GenericAgent.

Run from the project root:

    python frontends/tuiapp.py

Useful options:

    python frontends/tuiapp.py --help

MVP design notes:
- One TUI manages multiple GenericAgent instances.
- GenericAgent.put_task() returns a per-task display_queue; the TUI records a task_id for every submit.
- Agent.run() and display_queue.get() run in daemon threads; UI updates are posted via App.call_from_thread().
- Multiple sessions may run concurrently, but GenericAgent still shares project temp/memory/tool globals.
"""
from __future__ import annotations

import argparse
import os
import queue
import re
import sys
import threading
import time
from dataclasses import dataclass, field
from itertools import count
from typing import Any, Callable, Optional

try:
    from rich.markdown import Markdown
    from rich.panel import Panel
    from rich.text import Text
    from textual import events
    from textual.app import App, ComposeResult
    from textual.binding import Binding
    from textual.containers import Horizontal, Vertical
    from textual.message import Message
    from textual.widgets import Footer, Header, RichLog, Static, TextArea
except ModuleNotFoundError as exc:  # pragma: no cover - exercised by manual missing-dep path
    if exc.name == "textual":
        print("Textual is required. Install with: pip install textual", file=sys.stderr)
    else:
        print(f"Missing dependency: {exc.name}", file=sys.stderr)
    raise SystemExit(2) from exc

ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT_DIR not in sys.path:
    sys.path.insert(0, ROOT_DIR)

AgentFactory = Callable[[], Any]


@dataclass
class ChatMessage:
    role: str
    content: str
    task_id: Optional[int] = None
    done: bool = True
    _rendered_panel: Any = field(default=None, repr=False)


@dataclass
class AgentSession:
    agent_id: int
    name: str
    agent: Any
    thread: Optional[threading.Thread] = None
    status: str = "idle"
    messages: list[ChatMessage] = field(default_factory=list)
    task_seq: int = 0
    current_task_id: Optional[int] = None
    current_display_queue: Optional[queue.Queue] = None
    buffer: str = ""


def fold_turns(text: str) -> list[dict[str, str]]:
    """Split GenericAgent turn output into text/fold segments.

    Completed turns become ``{'type': 'fold', 'title': ..., 'content': ...}``.
    The latest/incomplete turn remains ``type='text'`` for streaming refresh.
    """
    placeholders: list[str] = []

    def stash(match: re.Match[str]) -> str:
        placeholders.append(match.group(0))
        return f"\x00PH{len(placeholders) - 1}\x00"

    # Line-anchored fence matcher — see tuiapp_v2.fold_turns for rationale.
    # Unanchored variant mis-paired backticks embedded in file_read output
    # with later real fences, swallowing turn markers and ballooning the
    # final "text" segment to MBs (1.85s markdown render on /continue).
    safe = re.sub(r"^`{4,}.*?^`{4,}\n?", stash, text, flags=re.DOTALL | re.MULTILINE)
    parts = re.split(r"(\**LLM Running \(Turn \d+\) \.\.\.\**)", safe)

    def restore(part: str) -> str:
        return re.sub(r"\x00PH(\d+)\x00", lambda m: placeholders[int(m.group(1))], part)

    parts = [restore(p) for p in parts]
    if len(parts) < 4:
        return [{"type": "text", "content": text}]

    segments: list[dict[str, str]] = []
    if parts[0].strip():
        segments.append({"type": "text", "content": parts[0]})

    turns: list[tuple[str, str]] = []
    for i in range(1, len(parts), 2):
        marker = parts[i]
        content = parts[i + 1] if i + 1 < len(parts) else ""
        turns.append((marker, content))

    for idx, (marker, content) in enumerate(turns):
        if idx < len(turns) - 1:
            cleaned = re.sub(r"`{3,}.*?`{3,}|<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
            matches = re.findall(r"<summary>\s*((?:(?!<summary>).)*?)\s*</summary>", cleaned, re.DOTALL)
            if matches:
                title = matches[0].strip().split("\n", 1)[0]
            else:
                title = cleaned.strip().split("\n", 1)[0] or marker.strip("*")
                # Strip trailing args portion from tool-call lines
                title = re.sub(r",?\s*args:.*$", "", title)
            if len(title) > 72:
                title = title[:72] + "..."
            segments.append({"type": "fold", "title": title, "content": content})
        else:
            segments.append({"type": "text", "content": marker + content})
    return segments


def render_folded_text(text: str) -> str:
    """Render fold segments as terminal-friendly Markdown text.

    Textual's interactive Collapsible widgets are best for static layouts; the MVP uses
    a RichLog and re-renders compact summaries for completed turns to keep streaming cheap.
    """
    rendered: list[str] = []
    for seg in fold_turns(text):
        if seg["type"] == "fold":
            rendered.append(f"\n▸ {seg.get('title') or 'completed turn'}\n\n")
        else:
            rendered.append(seg.get("content", ""))
    return "".join(rendered)


def parse_local_command(raw: str) -> tuple[str, list[str]] | None:
    """Return (command, args) for TUI-owned slash commands; unknown slash is passthrough."""
    text = (raw or "").strip()
    if not text.startswith("/"):
        return None
    name, *rest = text.split(maxsplit=1)
    cmd = name[1:].lower()
    args = rest[0].split() if rest else []
    if cmd in {"help", "status", "new", "switch", "sessions", "stop", "llm", "branch", "rewind", "clear", "close", "quit", "exit"}:
        return cmd, args
    return None


def default_agent_factory() -> Any:
    from agentmain import GenericAgent

    agent = GenericAgent()
    agent.inc_out = True
    return agent


class PromptInput(TextArea):
    """Multi-line input: Enter submits, Ctrl+Enter (ctrl+j) inserts newline, paste never auto-submits."""

    BINDINGS = [
        Binding("ctrl+j", "newline", "Newline", show=False),
    ]

    class Submitted(Message):
        """Posted when the user presses Enter to submit."""
        def __init__(self, value: str) -> None:
            super().__init__()
            self.value = value

    def __init__(self, placeholder: str = "", **kwargs) -> None:
        super().__init__(language=None, show_line_numbers=False, compact=True, placeholder=placeholder, **kwargs)

    def _on_key(self, event: events.Key) -> None:
        if event.key == "enter":
            # Enter → submit
            event.stop()
            event.prevent_default()
            value = self.text.rstrip()
            self.clear()
            self.post_message(self.Submitted(value))
        elif event.key == "ctrl+j":
            # Ctrl+Enter (ctrl+j) → insert newline
            event.stop()
            event.prevent_default()
            start, end = self.selection
            self._replace_via_keyboard("\n", start, end)
        else:
            super()._on_key(event)


class GenericAgentTUI(App[None]):
    """Textual app that manages multiple GenericAgent sessions."""

    CSS = """
    Screen { layout: vertical; }
    #body { height: 1fr; }
    #sidebar { width: 30; min-width: 24; border: solid $accent; padding: 0 1; overflow-x: hidden; }
    #main { width: 1fr; }
    #status { height: 3; border: solid $primary; padding: 0 1; }
    #log { height: 1fr; border: solid $primary; padding: 0 1; }
    #prompt { dock: bottom; height: auto; min-height: 1; max-height: 8; margin-bottom: 1; }
    .hint { color: $text-muted; }
    """

    BINDINGS = [
        ("ctrl+n", "new_session", "New session"),
        ("ctrl+s", "stop_current", "Stop"),
        ("ctrl+f", "toggle_fold", "Fold/Unfold"),
        ("ctrl+q", "quit", "Quit"),
        Binding("ctrl+left", "prev_session", "←Prev", show=True, priority=True),
        Binding("ctrl+right", "next_session", "Next→", show=True, priority=True),
    ]

    def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None:
        super().__init__()
        self.agent_factory: AgentFactory = agent_factory or default_agent_factory
        self.sessions: dict[int, AgentSession] = {}
        self.current_id: Optional[int] = None
        self._ids = count(1)
        self.fold_mode: bool = True
        self._last_stream_refresh: float = 0.0
        self._stream_throttle_ms: float = 0.15  # seconds between streaming UI refreshes

    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        with Horizontal(id="body"):
            yield Static("", id="sidebar")
            with Vertical(id="main"):
                yield Static("", id="status")
                yield RichLog(id="log", wrap=True, highlight=True, markup=True)
        yield PromptInput(placeholder="Message, or /help  /new  /branch  /rewind  /switch  /clear  /close  /stop  /llm  /resume", id="prompt")
        yield Footer()

    def on_mount(self) -> None:
        self.add_session("main")
        self._system("Welcome to GenericAgent TUI. Type /help for commands.")
        self.query_one("#prompt", PromptInput).focus()

    def on_resize(self, event) -> None:
        narrow = self.size.width < 70
        self.query_one("#sidebar").styles.display = "none" if narrow else "block"

    @property
    def current(self) -> AgentSession:
        if self.current_id is None:
            raise RuntimeError("no active session")
        return self.sessions[self.current_id]

    def add_session(self, name: Optional[str] = None) -> AgentSession:
        agent_id = next(self._ids)
        agent = self.agent_factory()
        try:
            agent.inc_out = True
        except Exception:
            pass
        session = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent)
        thread = threading.Thread(target=agent.run, name=f"ga-tui-agent-{agent_id}", daemon=True)
        thread.start()
        session.thread = thread
        self.sessions[agent_id] = session
        self.current_id = agent_id
        self._refresh_all()
        return session

    def action_prev_session(self) -> None:
        """Switch to previous session."""
        ids = sorted(self.sessions.keys())
        if len(ids) <= 1:
            return
        idx = ids.index(self.current_id)
        self.current_id = ids[(idx - 1) % len(ids)]
        self._refresh_all()

    def action_next_session(self) -> None:
        """Switch to next session."""
        ids = sorted(self.sessions.keys())
        if len(ids) <= 1:
            return
        idx = ids.index(self.current_id)
        self.current_id = ids[(idx + 1) % len(ids)]
        self._refresh_all()

    def action_switch_session(self, n: int) -> None:
        """Switch to session by id (used by /switch command)."""
        if n in self.sessions:
            self.current_id = n
            self._refresh_all()
        else:
            self.notify(f"Session #{n} does not exist.", severity="warning")

    def action_new_session(self) -> None:
        self.add_session()
        self._system(f"Created and switched to session #{self.current_id}.")

    def action_stop_current(self) -> None:
        self._cmd_stop([])

    def on_prompt_input_submitted(self, event: PromptInput.Submitted) -> None:
        value = event.value.rstrip()
        if not value:
            self._system("Empty input ignored. Type /help for commands.")
            return
        parsed = parse_local_command(value)
        if parsed:
            cmd, args = parsed
            self._dispatch_command(cmd, args)
            return
        self.submit_user_message(value)

    def _dispatch_command(self, cmd: str, args: list[str]) -> None:
        handlers = {
            "help": self._cmd_help,
            "status": self._cmd_status,
            "new": self._cmd_new,
            "switch": self._cmd_switch,
            "sessions": self._cmd_sessions,
            "stop": self._cmd_stop,
            "llm": self._cmd_llm,
            "branch": self._cmd_branch,
            "rewind": self._cmd_rewind,
            "clear": self._cmd_clear,
            "close": self._cmd_close,
            "quit": lambda _args: self.exit(),
            "exit": lambda _args: self.exit(),
        }
        handlers[cmd](args)

    def submit_user_message(self, text: str) -> int:
        session = self.current
        if session.status == "running":
            self._system(f"Session #{session.agent_id} is already running; wait or /stop before submitting another task.")
            return -1
        session.task_seq += 1
        task_id = session.task_seq
        session.current_task_id = task_id
        session.buffer = ""
        session.status = "running"
        session.messages.append(ChatMessage("user", text))
        session.messages.append(ChatMessage("assistant", "", task_id=task_id, done=False))
        self._refresh_all()
        try:
            display_queue = session.agent.put_task(text, source="user")
        except Exception as exc:
            session.status = "error"
            self._set_assistant_message(session.agent_id, task_id, f"[ERROR] put_task failed: {exc}", done=True)
            return task_id
        session.current_display_queue = display_queue
        threading.Thread(
            target=self._consume_display_queue,
            args=(session.agent_id, task_id, display_queue),
            name=f"ga-tui-consumer-{session.agent_id}-{task_id}",
            daemon=True,
        ).start()
        return task_id

    def _consume_display_queue(self, agent_id: int, task_id: int, display_queue: queue.Queue) -> None:
        buffer = ""
        while True:
            try:
                item = display_queue.get(timeout=0.25)
            except queue.Empty:
                continue
            if "next" in item:
                buffer += str(item.get("next") or "")
                self.call_from_thread(self._on_stream_update, agent_id, task_id, buffer, False)
            if "done" in item:
                done_text = str(item.get("done") or buffer)
                self.call_from_thread(self._on_stream_update, agent_id, task_id, done_text, True)
                return

    def _on_stream_update(self, agent_id: int, task_id: int, text: str, done: bool) -> None:
        session = self.sessions.get(agent_id)
        if not session:
            return
        if session.current_task_id != task_id:
            session.messages.append(ChatMessage("system", f"Stale update ignored for task {task_id}.", done=True))
            return
        session.buffer = text
        if done:
            session.status = "idle"
            session.current_display_queue = None
        self._set_assistant_message(agent_id, task_id, text, done=done)

    def _set_assistant_message(self, agent_id: int, task_id: int, text: str, *, done: bool) -> None:
        session = self.sessions.get(agent_id)
        if not session:
            return
        for msg in reversed(session.messages):
            if msg.role == "assistant" and msg.task_id == task_id:
                msg.content = text
                msg.done = done
                break
        else:
            session.messages.append(ChatMessage("assistant", text, task_id=task_id, done=done))
        if agent_id == self.current_id:
            self._refresh_all()
        else:
            self._refresh_sidebar()

    def _cmd_help(self, args: list[str]) -> None:
        self._system(
            "Commands:\n"
            "/help - show this help\n"
            "/new [name] - create and switch to a new agent session\n"
            "/branch [name] - fork current session (copies LLM history + display)\n"
            "/rewind - list rewindable turns; /rewind <n> to truncate history\n"
            "/switch <id|name> - switch active session\n"
            "/sessions - list sessions\n"
            "/status - show current/all status\n"
            "/stop - abort current session task\n"
            "/clear - clear chat display (keeps LLM history)\n"
            "/close - close current session (cannot close last)\n"
            "/llm - list models for current session\n"
            "/llm <n> - switch model for current session\n"
            "/quit - exit TUI\n\n"
            "Unknown slash commands (for example /session.x=... or /resume) are sent to GenericAgent."
        )

    def _cmd_new(self, args: list[str]) -> None:
        name = " ".join(args).strip() or None
        session = self.add_session(name)
        self._system(f"Created session #{session.agent_id} {session.name!r}. Shared temp/memory are not isolated.")

    def _cmd_branch(self, args: list[str]) -> None:
        import copy
        old_session = self.current
        name = " ".join(args).strip() or f"{old_session.name}-branch"
        new_session = self.add_session(name)
        # Copy LLM backend history
        try:
            new_session.agent.llmclient.backend.history = copy.deepcopy(
                old_session.agent.llmclient.backend.history
            )
        except Exception as e:
            self._system(f"Branch warning: failed to copy history: {e}")
            return
        # Copy TUI display messages
        new_session.messages = copy.deepcopy(old_session.messages)
        new_session.task_seq = old_session.task_seq
        n = len(new_session.agent.llmclient.backend.history)
        self._system(f"Branched from #{old_session.agent_id} → #{new_session.agent_id} ({n} messages inherited).")

    def _cmd_rewind(self, args: list[str]) -> None:
        session = self.current
        if session.status == "running":
            self._system("Cannot rewind while running. /stop first.")
            return
        history = session.agent.llmclient.backend.history
        # Find real user turn boundaries — skip tool_result messages
        turns = []  # list of (index_in_history, preview_text)
        for i, msg in enumerate(history):
            if msg.get("role") != "user":
                continue
            content = msg.get("content")
            # Pure string content is always a real user message
            if isinstance(content, str):
                turns.append((i, content[:60]))
                continue
            if isinstance(content, list):
                # Skip if content is purely tool_result blocks
                has_tool_result = any(b.get("type") == "tool_result" for b in content if isinstance(b, dict))
                if has_tool_result:
                    continue
                texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
                if texts and any(t.strip() for t in texts):
                    turns.append((i, (texts[0] or "")[:60]))
        if not turns:
            self._system("No rewindable turns in history.")
            return
        # Reverse numbering: 1 = most recent turn, 2 = second most recent, etc.
        # /rewind without args: show list
        if not args:
            lines = [f"Rewindable turns ({len(turns)} total, showing last 10):"]
            show = turns[-10:]
            for offset, (_, preview) in enumerate(reversed(show), 1):
                lines.append(f"  {offset}) {preview!r}")
            lines.append("/rewind <n> to rewind n turns (1 = undo last turn).")
            self._system("\n".join(lines))
            return
        # /rewind <n>: truncate last n turns
        try:
            n = int(args[0])
        except ValueError:
            self._system("Usage: /rewind <n> (1 = undo last turn)")
            return
        if n < 1 or n > len(turns):
            self._system(f"Invalid: range is 1-{len(turns)}")
            return
        # cut_at = index of the n-th turn from the end
        cut_at = turns[-n][0]
        removed = len(history) - cut_at
        history[:] = history[:cut_at]
        # Sync TUI messages: keep only messages before the corresponding user message
        real_user_indices = [i for i, msg in enumerate(session.messages) if msg.role == "user"]
        if n <= len(real_user_indices):
            cut_msg = real_user_indices[-n]
            session.messages = session.messages[:cut_msg]
        # Mark rewind in agentmain's working memory history
        try: session.agent.history.append(f"[USER]: /rewind {n}")
        except Exception: pass
        self._system(f"Rewound {n} turn(s). Removed {removed} history entries.")

    def _cmd_clear(self, args: list[str]) -> None:
        self.current.messages.clear(); self._refresh_all()

    def _cmd_close(self, args: list[str]) -> None:
        if len(self.sessions) <= 1:
            self._system("Cannot close the last session."); return
        del self.sessions[self.current_id]
        self.current_id = next(iter(self.sessions))
        self._refresh_all()

    def _cmd_switch(self, args: list[str]) -> None:
        if not args:
            self._system("Usage: /switch <id|name>")
            return
        key = " ".join(args)
        target: Optional[int] = None
        if key.isdigit() and int(key) in self.sessions:
            target = int(key)
        else:
            for sid, session in self.sessions.items():
                if session.name == key:
                    target = sid
                    break
        if target is None:
            self._system(f"No session found for {key!r}.")
            return
        self.current_id = target
        self._refresh_all()
        self._system(f"Switched to session #{target}.")

    def _cmd_sessions(self, args: list[str]) -> None:
        lines = []
        for sid, session in self.sessions.items():
            mark = "*" if sid == self.current_id else " "
            lines.append(f"{mark} #{sid} {session.name} [{session.status}] messages={len(session.messages)} task={session.current_task_id}")
        self._system("Sessions:\n" + "\n".join(lines))

    def _cmd_status(self, args: list[str]) -> None:
        self._cmd_sessions(args)

    def _cmd_stop(self, args: list[str]) -> None:
        session = self.current
        try:
            session.agent.abort()
            session.status = "stopping" if session.status == "running" else session.status
            self._system(f"Stop signal sent to session #{session.agent_id}.")
        except Exception as exc:
            self._system(f"Stop failed: {exc}")
        self._refresh_all()

    def _cmd_llm(self, args: list[str]) -> None:
        session = self.current
        if args:
            try:
                session.agent.next_llm(int(args[0]))
                self._system(f"Switched model to #{int(args[0])}.")
            except Exception as exc:
                self._system(f"Model switch failed: {exc}")
                return
        try:
            rows = session.agent.list_llms()
            self._system("Models:\n" + "\n".join(f"{'*' if cur else ' '} {i}: {name}" for i, name, cur in rows))
        except Exception as exc:
            self._system(f"Listing models failed: {exc}")

    def _system(self, text: str) -> None:
        if self.current_id is not None and self.current_id in self.sessions:
            self.current.messages.append(ChatMessage("system", text))
        self._refresh_all()

    def _refresh_all(self) -> None:
        if not self.is_mounted:
            return
        self._refresh_sidebar()
        self._refresh_status()
        self._refresh_log()

    def _session_last_user_query(self, session: AgentSession) -> str:
        """Return the last user message content, truncated for sidebar display."""
        for msg in reversed(session.messages):
            if msg.role == "user":
                text = msg.content.strip().replace("\n", " ")
                return self._truncate_display(text, 20)
        return ""

    def _session_last_summary(self, session: AgentSession) -> str:
        """Extract the last <summary> from the most recent assistant message."""
        for msg in reversed(session.messages):
            if msg.role == "assistant" and msg.content:
                matches = re.findall(r"<summary>\s*(.*?)\s*</summary>", msg.content, re.DOTALL)
                if matches:
                    text = matches[-1].strip().split("\n", 1)[0].replace("\n", " ")
                    return self._truncate_display(text, 20)
        return ""

    @staticmethod
    def _truncate_display(text: str, max_width: int) -> str:
        """Truncate text by display width (CJK chars count as 2)."""
        import unicodedata
        width = 0
        result = []
        for ch in text:
            w = 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1
            if width + w > max_width:
                result.append("…")
                break
            result.append(ch)
            width += w
        return "".join(result)

    def _refresh_sidebar(self) -> None:
        sidebar = self.query_one("#sidebar", Static)
        max_w = 26  # 30 - 2(border) - 2(padding)
        lines: list[str] = ["[b]Sessions[/b]", ""]
        for sid, session in self.sessions.items():
            mark = "▶" if sid == self.current_id else " "
            last_q = self._session_last_user_query(session)
            last_s = self._session_last_summary(session)
            status_style = "green" if session.status == "running" else "dim"
            # Header line: "▶ #1 name status" — truncate name if needed
            prefix = f"{mark} #{sid} "
            suffix = f" {session.status}"
            name_max = max_w - len(prefix) - len(suffix)
            name_disp = self._truncate_display(session.name, max(name_max, 4))
            lines.append(f"{prefix}{name_disp} [{status_style}]{session.status}[/{status_style}]")
            if last_q:
                lines.append(f"   [dim]Q:{last_q}[/dim]")
            if last_s:
                lines.append(f"   [dim]S:{last_s}[/dim]")
        lines.append("")
        lines.append("[dim]/new, /switch, Ctrl+N[/dim]")
        lines.append("[dim]I have memory, just say what you want[/dim]")
        sidebar.update("\n".join(lines))

    def _refresh_status(self) -> None:
        status = self.query_one("#status", Static)
        if self.current_id is None:
            status.update("No session")
            return
        session = self.current
        try:
            model = session.agent.get_llm_name(model=True)
        except Exception:
            model = "unknown"
        status.update(
            f"[b]#{session.agent_id} {session.name}[/b]  status={session.status}  task={session.current_task_id}  model={model}\n"
            "Enter message or /help. Per-task queue streaming is enabled (inc_out=True)."
        )

    def action_toggle_fold(self) -> None:
        self.fold_mode = not self.fold_mode
        # Invalidate cached panels for assistant messages since fold state changed
        if self.current_id is not None:
            for msg in self.current.messages:
                if msg.role == "assistant":
                    msg._rendered_panel = None
        self._refresh_log()
        mode_label = "folded" if self.fold_mode else "expanded"
        self.notify(f"Display mode: {mode_label} (Ctrl+F to toggle)")

    def _refresh_log(self) -> None:
        log = self.query_one("#log", RichLog)
        log.clear()
        if self.current_id is None:
            return
        all_msgs = self.current.messages
        # Limit to last 150 messages for performance
        if len(all_msgs) > 150:
            display_msgs = all_msgs[-150:]
            log.write(Text(f"  ↑ {len(all_msgs) - 150} older messages hidden ↑", style="dim italic"))
        else:
            display_msgs = all_msgs
        # Collect recent task_ids to only expand the latest 3 tasks
        recent_task_ids: set[int] = set()
        if not self.fold_mode:
            seen: list[int] = []
            for msg in reversed(display_msgs):
                if msg.role == "assistant" and msg.task_id not in seen:
                    seen.append(msg.task_id)
                    if len(seen) == 5:
                        break
            recent_task_ids = set(seen)
        for msg in display_msgs:
            if msg.role == "user":
                if msg._rendered_panel is None:
                    msg._rendered_panel = Panel(Markdown(msg.content), title="You", border_style="blue")
                log.write(msg._rendered_panel)
            elif msg.role == "assistant":
                if msg.done and msg._rendered_panel is not None:
                    log.write(msg._rendered_panel)
                else:
                    suffix = "" if msg.done else "\n▌"
                    # Fold older tasks even in unfold mode to reduce render cost
                    should_fold = self.fold_mode or (msg.task_id not in recent_task_ids)
                    content = render_folded_text(msg.content) if should_fold else msg.content
                    panel = Panel(Markdown(content + suffix), title=f"Agent task {msg.task_id}", border_style="green")
                    if msg.done:
                        msg._rendered_panel = panel
                    log.write(panel)
            else:
                if msg._rendered_panel is None:
                    msg._rendered_panel = Panel(Text(msg.content), title="System", border_style="yellow")
                log.write(msg._rendered_panel)


def build_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Textual TUI for GenericAgent")
    return parser


def main(argv: Optional[list[str]] = None) -> int:
    args = build_arg_parser().parse_args(argv)
    app = GenericAgentTUI()
    app.run()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
