935 lines
32 KiB
Python
935 lines
32 KiB
Python
"""
|
||
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]")
|