""" ui.py — AIPA Terminal Interface Authored by: Iris, Director of Interface & Experience Visual language: Miranda — cyan primary orchestrator, delivers to Principal Vera — yellow auditor, independent track Atlas — green research Cole — blue operations Clio — magenta analysis Evelyn — white personnel & systems Iris — bright_cyan interface System — dim white infrastructure messages Error — red failures and blockers Warning — orange flags and cautions Panels carry the agent name as their title. The Principal never has to read a header to know who is speaking — color and panel style make it immediate. """ from contextlib import contextmanager from datetime import datetime import json as _json import config as _config from rich.console import Console, Group from rich.columns import Columns from rich.live import Live from rich.markdown import Markdown from rich.panel import Panel from rich.progress import ( BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn, ) from rich.rule import Rule from rich.table import Table from rich.text import Text from rich.theme import Theme from rich import box # --------------------------------------------------------------------------- # Console setup # --------------------------------------------------------------------------- def _build_theme() -> Theme: """Build the Rich theme dynamically from agents.yaml color definitions.""" theme_dict: dict[str, str] = { # System styles — not agent-specific "system": "dim white", "error": "bold red", "warning": "bold yellow", "task_id": "dim cyan", "label": "bold white", "muted": "dim", "pass": "bold green", "flag": "bold yellow", "reject": "bold red", } # Agent name aliases — one entry per agent in agents.yaml for name in _config.AGENT_CONFIGS: theme_dict[name] = f"bold {_config.agent_color(name)}" # Role aliases — stable names regardless of which agent holds the role for role, agent in ( ("orchestrator", _config.ORCHESTRATOR_AGENT), ("auditor", _config.AUDITOR_AGENT), ("recruiter", _config.RECRUITER_AGENT), ): if agent: theme_dict[role] = f"bold {_config.agent_color(agent)}" return Theme(theme_dict) console = Console(theme=_build_theme(), highlight=False) _last_thinking: str = "" # Stored from most recent streamed response _last_stream_rendered: bool = False # True when stream_agent_output rendered the final panel # --------------------------------------------------------------------------- # Agent visual config # --------------------------------------------------------------------------- def _agent_color(name: str) -> str: return _config.agent_color(name.lower()) def _agent_display_title(name: str) -> str: """Return 'Name — Title' using the title from agents.yaml.""" title = _config.agent_title(name) return f"{name.capitalize()} — {title}" if title else name.capitalize() # --------------------------------------------------------------------------- # Session chrome # --------------------------------------------------------------------------- def print_session_header(): """Opening banner for a new Principal session.""" cos_name = _config.ORCHESTRATOR_AGENT.capitalize() cos_title = _config.agent_title(_config.ORCHESTRATOR_AGENT) aud_name = _config.AUDITOR_AGENT.capitalize() app_name = _config.APP_NAME console.print() console.rule(f"[bold cyan]{app_name}[/bold cyan] [dim]Autonomous Intelligence Principal Architecture[/dim]", style="cyan") console.print() table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) table.add_column(style="dim") table.add_column(style="white") table.add_row("Session opened", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) table.add_row("Orchestrator", f"{cos_name} — {cos_title}") table.add_row("Auditor", f"{aud_name} (independent track)") table.add_row("Commands", "/help for available commands") console.print(table) console.print() def print_session_footer(task_count: int): """Closing rule when the session ends.""" console.print() console.rule( f"[dim]Session closed — {task_count} directive{'s' if task_count != 1 else ''} processed[/dim]", style="dim" ) console.print() # --------------------------------------------------------------------------- # Input prompt # --------------------------------------------------------------------------- def get_directive() -> str: """Styled input prompt for the Principal.""" console.print() return console.input("[bold white]Principal ›[/bold white] ").strip() # --------------------------------------------------------------------------- # System / status messages # --------------------------------------------------------------------------- def print_system(message: str): console.print(f" [system]⬡ {message}[/system]") def print_error(message: str): console.print(f"\n [error]✗ {message}[/error]\n") def print_warning(message: str): console.print(f" [warning]⚠ {message}[/warning]") def print_task_received(task_id: str, directive_preview: str): console.print() console.print( f" [task_id]{task_id}[/task_id] [dim]Directive received:[/dim] " f"[white]{directive_preview[:100]}{'…' if len(directive_preview) > 100 else ''}[/white]" ) # --------------------------------------------------------------------------- # Lead dispatch progress # --------------------------------------------------------------------------- def _brief_preview(brief: str, max_len: int = 72) -> str: """Return a short preview of a task brief, skipping the header lines.""" lines = [l.strip() for l in brief.splitlines() if l.strip()] content = [ l for l in lines if not l.upper().startswith("TASK BRIEF") and not l.upper().startswith("TO:") ] text = " ".join(content) return (text[:max_len] + "…") if len(text) > max_len else text def print_task_dispatch_plan(tasks: list): """ Show the task assignments before leads are dispatched. Each row: task_id | lead | brief preview. """ console.print() console.rule("[dim]Lead dispatch[/dim]", style="dim") table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2), expand=False) table.add_column(style="task_id", no_wrap=True) table.add_column(no_wrap=True) table.add_column(style="dim") for t in tasks: color = _agent_color(t.assigned_to) table.add_row( t.task_id, f"[{color}]{_agent_display_title(t.assigned_to)}[/{color}]", _brief_preview(t.brief), ) console.print(table) @contextmanager def lead_dispatch_progress(tasks: list): """ Per-task live progress display while leads are working. Yields (progress, rows) where rows is keyed by task_id. """ def _row(task_id: str, lead_name: str, status: str) -> str: color = _agent_color(lead_name) return ( f"[task_id]{task_id}[/task_id] " f"[{color}]{lead_name.capitalize()}[/{color}] " f"{status}" ) with Progress( SpinnerColumn(style="dim"), TextColumn("{task.description}"), TimeElapsedColumn(), console=console, transient=True, ) as progress: rows = { t.task_id: progress.add_task( _row(t.task_id, t.assigned_to, "[dim]queued[/dim]"), total=1, ) for t in tasks } yield progress, rows def mark_task_running(progress, rows: dict, task_id: str, lead_name: str): """Update a task row to show it is actively being worked.""" if task_id not in rows: return color = _agent_color(lead_name) progress.update( rows[task_id], description=( f"[task_id]{task_id}[/task_id] " f"[{color}]{lead_name.capitalize()}[/{color}] " f"[yellow]working…[/yellow]" ), ) def mark_task_done(progress, rows: dict, task_id: str, lead_name: str, error: bool = False): """Update a task row to show it is finished (complete or error).""" if task_id not in rows: return color = _agent_color(lead_name) status = "[red]error[/red]" if error else "[green]done[/green]" progress.update( rows[task_id], completed=1, description=( f"[task_id]{task_id}[/task_id] " f"[{color}]{lead_name.capitalize()}[/{color}] " f"{status}" ), ) def print_direct_response_notice(): """Shown when Miranda handles a directive without dispatching leads.""" console.print( " [dim]Miranda responded directly — no lead dispatch required.[/dim]" ) # --------------------------------------------------------------------------- # Agent output panels # --------------------------------------------------------------------------- def print_agent_panel(agent_name: str, content: str, task_id: str = ""): """ Render an agent's output in a styled panel. Content is treated as Markdown. Skips printing if stream_agent_output already rendered this response. """ global _last_stream_rendered if _last_stream_rendered: _last_stream_rendered = False return color = _agent_color(agent_name) title_text = _agent_display_title(agent_name) subtitle = f"[dim]{task_id}[/dim]" if task_id else "" panel = Panel( Markdown(content), title=f"[{color}] {title_text} [/{color}]", subtitle=subtitle, border_style=color, padding=(1, 2), box=box.ROUNDED, ) console.print() console.print(panel) def print_deliverable(content: str, task_id: str = ""): """ Orchestrator's synthesized deliverable. Skips printing if stream_agent_output already rendered this response. """ global _last_stream_rendered if _last_stream_rendered: _last_stream_rendered = False return _print_deliverable_panel(content, task_id) def _print_deliverable_panel(content: str, task_id: str = ""): """Internal: always render the deliverable panel unconditionally.""" agent_name = _config.ORCHESTRATOR_AGENT color = _agent_color(agent_name) title_text = _agent_display_title(agent_name) subtitle = f"[dim]{task_id}[/dim]" if task_id else "" panel = Panel( Markdown(content), title=f"[{color}] {title_text} [/{color}]", subtitle=subtitle, border_style=color, padding=(1, 2), box=box.ROUNDED, ) console.print() console.print(panel) def print_audit_memo(content: str, task_id: str = ""): """ Auditor's memo. Verdict line drives the border colour. PASS = green, PASS WITH NOTES = yellow, FLAG = yellow, REJECT = red. Skips printing if stream_agent_output already rendered this response. """ global _last_stream_rendered if _last_stream_rendered: _last_stream_rendered = False return _print_audit_memo_panel(content, task_id) def _print_audit_memo_panel(content: str, task_id: str = ""): """Internal: always render the audit memo panel with verdict-based border.""" aud_name = _config.AUDITOR_AGENT aud_title = _config.agent_title(aud_name) aud_color = _agent_color(aud_name) verdict_style = "dim" for line in content.splitlines(): upper = line.upper() if "VERDICT:" in upper: if "REJECT" in upper: verdict_style = "reject" elif "FLAG" in upper: verdict_style = "flag" elif "PASS WITH NOTES" in upper: verdict_style = "warning" elif "PASS" in upper: verdict_style = "pass" break panel = Panel( Markdown(content), title=f"[{aud_color}] {aud_name.capitalize()} — {aud_title} [/{aud_color}]", subtitle=f"[dim]{task_id}[/dim]" if task_id else "", border_style=verdict_style if verdict_style != "dim" else aud_color, padding=(1, 2), box=box.ROUNDED, ) console.print() console.print(panel) def print_lead_output(agent_name: str, content: str, task_id: str = ""): """A lead's raw output (shown only in DEBUG mode).""" print_agent_panel(agent_name, content, task_id) # --------------------------------------------------------------------------- # Standing Brief renderer # --------------------------------------------------------------------------- def print_standing_brief(raw_md: str): """ Render the standing brief as a navigable terminal layout. Sections are separated by rules; tables are preserved where present. """ console.print() console.rule("[bold cyan]Standing Brief[/bold cyan]", style="cyan") console.print() console.print(Markdown(raw_md)) console.print() console.rule(style="dim") console.print() # --------------------------------------------------------------------------- # Synthesis progress (Miranda) # --------------------------------------------------------------------------- @contextmanager def synthesis_progress(): """Spinner shown while Miranda synthesizes lead outputs.""" with Progress( SpinnerColumn(style="cyan"), TextColumn("[cyan]Miranda synthesizing…[/cyan]"), TimeElapsedColumn(), console=console, transient=True, ) as progress: progress.add_task("", total=None) yield @contextmanager def audit_progress(): """Spinner shown while Vera audits.""" with Progress( SpinnerColumn(style="yellow"), TextColumn("[yellow]Vera auditing…[/yellow]"), TimeElapsedColumn(), console=console, transient=True, ) as progress: progress.add_task("", total=None) yield @contextmanager def brief_update_progress(): """Spinner shown while Miranda updates the standing brief.""" with Progress( SpinnerColumn(style="dim"), TextColumn("[dim]Updating standing brief…[/dim]"), console=console, transient=True, ) as progress: progress.add_task("", total=None) yield # --------------------------------------------------------------------------- # Streaming output with thinking support # --------------------------------------------------------------------------- def _thinking_panel_title(agent_name: str, mode: str) -> str: """Return the thinking panel title, tinted by agent identity.""" if mode == "audit": color = _agent_color(agent_name) return f"[{color}]Vera thinking…[/{color}]" color = _agent_color(agent_name) return f"[{color} dim]Thinking…[/{color} dim]" def _build_thinking_renderable( thinking_text: str, active: bool, agent_name: str, mode: str, window: int = 300, ) -> object: """ Build the thinking panel (active) or collapsed summary line (done). Active: dim panel with a rolling window of the last `window` chars, plus an overflow indicator when earlier content has scrolled off. Collapsed: single dim line — ' ▸ Thinking (N chars, L lines)'. """ if not thinking_text: return None if active: overflow = len(thinking_text) - window if overflow > 0: preview = thinking_text[-window:] header = Text(f" +{overflow} chars earlier\n", style="dim italic") body = Text(preview, style="dim") content = Group(header, body) else: content = Text(thinking_text, style="dim") return Panel( content, title=_thinking_panel_title(agent_name, mode), border_style="dim", padding=(0, 1), box=box.SIMPLE, ) else: lines = thinking_text.count("\n") + 1 return Text( f" ▸ Thinking [{len(thinking_text):,} chars · {lines} lines]", style="dim", ) async def stream_agent_output( agent_name: str, stream_gen, mode: str = "deliverable", ) -> str: """ Consume an async generator of ('thinking'|'content', text) chunks and render them live in the terminal. mode values ----------- "deliverable" Normal agent response — thinking panel + full styled panel. The Live display is NOT transient: the final Markdown panel is rendered inside the Live block, so there is no erase/ reprint flash. Sets _last_stream_rendered = True so that the caller's print_deliverable / print_agent_panel becomes a no-op. "audit" Like "deliverable" but uses Vera's yellow identity cues during the thinking phase. Sets _last_stream_rendered so print_audit_memo is a no-op (the verdict-border panel is rendered here instead). "background" Housekeeping call (e.g. standing-brief update). A compact transient progress line is shown; no full panel is printed. _last_stream_rendered is set so the caller's print_* is a no-op. In all modes the full content string is returned and _last_thinking is updated so /thinking works. Thinking tokens stream inside a dim panel. When the first content token arrives the panel collapses to a single dim summary line and content begins streaming in the agent's normal styled panel. The full thinking text is stored in _last_thinking and can be retrieved with get_last_thinking() (exposed to the /thinking command). Returns the full content string. """ global _last_thinking, _last_stream_rendered color = _agent_color(agent_name) title_text = _agent_display_title(agent_name) # ── Background mode: compact transient progress line ──────────────────── if mode == "background": thinking_parts: list[str] = [] content_parts: list[str] = [] with Progress( SpinnerColumn(style="dim"), TextColumn("[dim]Updating standing brief…[/dim]"), TimeElapsedColumn(), console=console, transient=True, ) as prog: prog.add_task("", total=None) async for chunk_type, text in stream_gen: if chunk_type == "thinking": thinking_parts.append(text) elif chunk_type == "content": content_parts.append(text) _last_thinking = "".join(thinking_parts) _last_stream_rendered = True return "".join(content_parts) # ── Deliverable / audit modes ──────────────────────────────────────────── thinking_parts: list[str] = [] content_parts: list[str] = [] phase = "start" # "start" | "thinking" | "content" def _build_final_panel(content_text: str) -> Panel: """Render the agent's finished output as a polished Markdown panel.""" if mode == "audit": # Re-derive verdict border from completed content aud_name = _config.AUDITOR_AGENT aud_title = _config.agent_title(aud_name) aud_color = _agent_color(aud_name) verdict_style = "dim" for line in content_text.splitlines(): upper = line.upper() if "VERDICT:" in upper: if "REJECT" in upper: verdict_style = "reject" elif "FLAG" in upper: verdict_style = "flag" elif "PASS WITH NOTES" in upper: verdict_style = "warning" elif "PASS" in upper: verdict_style = "pass" break return Panel( Markdown(content_text), title=f"[{aud_color}] {aud_name.capitalize()} — {aud_title} [/{aud_color}]", border_style=verdict_style if verdict_style != "dim" else aud_color, padding=(1, 2), box=box.ROUNDED, ) else: return Panel( Markdown(content_text), title=f"[{color}] {title_text} [/{color}]", border_style=color, padding=(1, 2), box=box.ROUNDED, ) def _renderable(final: bool = False): """ Build the live renderable for the current stream state. final=True: replace plain Text content with Markdown and apply the verdict-aware border so the Live display ends in its final polished form. This avoids a transient erase + reprint flash. """ thinking_text = "".join(thinking_parts) content_text = "".join(content_parts) items: list = [] # ── Agent identity line (shown while only thinking, not yet content) ── if phase in ("start", "thinking") and not content_text: items.append(Text( f" {title_text}", style=f"dim {color}", )) # ── Thinking panel / collapsed summary ────────────────────────────── thinking_active = (phase == "thinking") think_renderable = _build_thinking_renderable( thinking_text, thinking_active, agent_name, mode ) if think_renderable is not None: items.append(think_renderable) # ── Content panel ──────────────────────────────────────────────────── if content_text or phase == "content": if final and content_text: items.append(_build_final_panel(content_text)) else: items.append(Panel( Text(content_text), title=f"[{color}] {title_text} [/{color}]", border_style=color, padding=(1, 2), box=box.ROUNDED, )) if not items: # Nothing yet — show a faint connecting indicator return Text( f" [{_agent_color(agent_name)} dim]{title_text} …[/{_agent_color(agent_name)} dim]", style="dim", ) return Group(*items) if len(items) > 1 else items[0] console.print() # transient=False: the Live display persists — we render the final panel # inside it before stopping, so there is no erase/reprint cycle. with Live( _renderable(), console=console, refresh_per_second=15, transient=False, vertical_overflow="visible", ) as live: async for chunk_type, text in stream_gen: if chunk_type == "thinking": if phase == "start": phase = "thinking" thinking_parts.append(text) elif chunk_type == "content": if phase in ("start", "thinking"): phase = "content" content_parts.append(text) live.update(_renderable()) # Final update: replace plain Text with formatted Markdown panel # (and apply verdict border for audit mode) before the Live context # exits and the display freezes in place. live.update(_renderable(final=True)) _last_thinking = "".join(thinking_parts) _last_stream_rendered = True return "".join(content_parts) def get_last_thinking() -> str: """Return the thinking text captured from the most recent streamed response.""" return _last_thinking def print_thinking_expansion(thinking_text: str): """Render the stored thinking output in a dim panel.""" panel = Panel( Text(thinking_text, style="dim"), title="[dim] Thinking — last response [/dim]", border_style="dim", padding=(1, 2), box=box.ROUNDED, ) console.print() console.print(panel) # --------------------------------------------------------------------------- # Dry-run callout # --------------------------------------------------------------------------- def print_dry_run_banner(): panel = Panel( "[bold yellow]DRY RUN MODE[/bold yellow]\n" "[dim]Routing and pipeline logic will execute normally.\n" "No provider API calls will be made.[/dim]", border_style="yellow", box=box.ROUNDED, padding=(0, 2), ) console.print() console.print(panel) console.print() # --------------------------------------------------------------------------- # Slash command displays # --------------------------------------------------------------------------- def print_help(commands: list[tuple[str, str]]): """Render the slash command reference table.""" console.print() console.rule("[bold cyan]Commands[/bold cyan]", style="cyan") console.print() table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2), expand=False) table.add_column(style="bold cyan", no_wrap=True) table.add_column(style="white") for cmd, description in commands: table.add_row(cmd, description) console.print(table) console.print() def print_status( session_start: datetime, directive_count: int, session_log: list[str], auto_audit: bool, debug: bool, dry_run: bool, ): """Render current session status.""" console.print() console.rule("[bold cyan]Session Status[/bold cyan]", style="cyan") console.print() elapsed = datetime.now() - session_start hours, remainder = divmod(int(elapsed.total_seconds()), 3600) minutes, seconds = divmod(remainder, 60) elapsed_str = f"{hours}h {minutes}m {seconds}s" if hours else f"{minutes}m {seconds}s" global_provider = _config.ACTIVE_PROVIDER provider_display = ( global_provider if global_provider != "none" else "[dim]none — set default_provider in config/agents.yaml[/dim]" ) table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) table.add_column(style="dim", no_wrap=True) table.add_column(style="white") table.add_row("Session started", session_start.strftime("%Y-%m-%d %H:%M:%S")) table.add_row("Elapsed", elapsed_str) table.add_row("Directives", str(directive_count)) table.add_row("Global provider", provider_display) table.add_row("Auto-audit", "[green]on[/green]" if auto_audit else "[dim]off[/dim]") table.add_row("Debug output", "[yellow]on[/yellow]" if debug else "[dim]off[/dim]") table.add_row("Dry run", "[yellow]on[/yellow]" if dry_run else "[dim]off[/dim]") console.print(table) if session_log: console.print() console.rule("[dim]Session log[/dim]", style="dim") console.print() for entry in session_log: console.print(f" [dim]·[/dim] [muted]{entry}[/muted]") console.print() def print_agents(agent_configs: dict, active_provider: str): """Render the named agent roster with per-agent provider resolution.""" console.print() console.rule("[bold cyan]Agent Roster[/bold cyan]", style="cyan") console.print() table = Table(box=box.SIMPLE, show_header=True, padding=(0, 2), expand=False) table.add_column("Agent", style="bold white", no_wrap=True) table.add_column("Role", style="dim", no_wrap=True) table.add_column("Provider", style="white", no_wrap=True) table.add_column("Model", style="cyan") table.add_column("Temp", style="dim", justify="right") table.add_column("Tokens", style="dim", justify="right") for name, cfg in agent_configs.items(): color = _agent_color(name) label = _config.agent_title(name) provider = _config.agent_provider_name(name) model = _config.agent_model(name) or "[dim]unset[/dim]" provider_display = ( f"[dim]{provider}[/dim]" if provider == "none" else provider ) table.add_row( f"[{color}]{name.capitalize()}[/{color}]", label, provider_display, model, str(cfg["temperature"]), str(cfg["max_tokens"]), ) console.print(table) if active_provider == "none": console.print(" [dim]Global fallback: none — set default_provider in config/agents.yaml[/dim]") else: console.print(f" [dim]Global fallback provider:[/dim] [white]{active_provider}[/white]") console.print() def print_history(session_log: list[str]): """Render the session log for this session.""" console.print() console.rule("[bold cyan]Session History[/bold cyan]", style="cyan") console.print() if not session_log: console.print(" [dim]No directives processed yet.[/dim]") else: for i, entry in enumerate(session_log, 1): console.print(f" [dim]{i:>2}.[/dim] [muted]{entry}[/muted]") console.print() def print_tasks(tasks: list, session_id: str = ""): """Render the task roster for the current session.""" console.print() title = f"[bold cyan]Tasks[/bold cyan]" if session_id: title += f" [dim]{session_id}[/dim]" console.rule(title, style="cyan") console.print() if not tasks: console.print(" [dim]No tasks dispatched this session.[/dim]") console.print() return STATUS_STYLE = { "pending": "[dim]pending[/dim]", "in_progress": "[yellow]in progress[/yellow]", "complete": "[green]complete[/green]", "error": "[red]error[/red]", } table = Table(box=box.SIMPLE, show_header=True, padding=(0, 2), expand=False) table.add_column("Task", style="task_id", no_wrap=True) table.add_column("Lead", no_wrap=True) table.add_column("Status", no_wrap=True) table.add_column("Brief", style="dim") for t in tasks: color = _agent_color(t.assigned_to) status = STATUS_STYLE.get(t.status, t.status) table.add_row( t.task_id, f"[{color}]{t.assigned_to.capitalize()}[/{color}]", status, _brief_preview(t.brief, max_len=60), ) console.print(table) console.print() def print_debug_toggle(enabled: bool): state = "[yellow]ON[/yellow]" if enabled else "[dim]OFF[/dim]" console.print(f" [dim]Debug output:[/dim] {state}") def print_audit_toggle(enabled: bool): state = "[green]ON[/green]" if enabled else "[dim]OFF[/dim]" console.print(f" [dim]Auto-audit (Vera):[/dim] {state}") # --------------------------------------------------------------------------- # Tool-call display # --------------------------------------------------------------------------- def print_tool_call(agent_name: str, tool_name: str, arguments_json: str, result_json: str): """ Render a single tool invocation inline during an agent's tool-calling loop. Layout: ⚙ AgentName › tool_name arguments (pretty-printed JSON, dim) ← result (pretty-printed JSON; red on parse error) """ color = _agent_color(agent_name) # Header line console.print( f" [dim]⚙[/dim] [{color}]{agent_name.capitalize()}[/{color}]" f" [dim]›[/dim] [bold]{tool_name}[/bold]" ) # Arguments try: args_pretty = _json.dumps(_json.loads(arguments_json), indent=2, ensure_ascii=False) for line in args_pretty.splitlines(): console.print(f" [dim]{line}[/dim]") except Exception: console.print(f" [dim]{arguments_json}[/dim]") # Result try: result_obj = _json.loads(result_json) result_pretty = _json.dumps(result_obj, indent=2, ensure_ascii=False) has_error = isinstance(result_obj, dict) and "error" in result_obj style = "red" if has_error else "dim" console.print(f" [dim]←[/dim]") for line in result_pretty.splitlines(): console.print(f" [{style}]{line}[/{style}]") except Exception: console.print(f" [dim]← {result_json}[/dim]")