"""Interactive CLI mode and single-shot execution.""" import asyncio import logging import queue import random import sys from typing import Any import typer # type: ignore[import-untyped] from prompt_toolkit import PromptSession # type: ignore[import-untyped] from prompt_toolkit.auto_suggest import ( AutoSuggestFromHistory, # type: ignore[import-untyped] ) from prompt_toolkit.completion import ( # type: ignore[import-untyped] Completer, Completion, ) from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped] from prompt_toolkit.history import FileHistory # type: ignore[import-untyped] from prompt_toolkit.key_binding import KeyBindings # type: ignore[import-untyped] from prompt_toolkit.shortcuts import CompleteStyle # type: ignore[import-untyped] from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untyped] from rich.markdown import Markdown from rich.markup import escape from rich.panel import Panel from rich.table import Table from rich.text import Text import EvoScientist.cli.channel as _ch_mod from ..sessions import ( _format_relative_time, delete_thread, find_similar_threads, generate_thread_id, get_checkpointer, get_thread_messages, get_thread_metadata, list_threads, thread_exists, ) from ..stream.display import console from ._constants import LOGO_GRADIENT, LOGO_LINES, WELCOME_SLOGANS, build_metadata from .agent import _create_session_workspace, _load_agent, _shorten_path from .channel import ( ChannelMessage, _auto_start_channel, _channels_is_running, _cmd_channel, _cmd_channel_stop, _message_queue, _set_channel_response, ) from .file_mentions import complete_file_mention, resolve_file_mentions from .mcp_ui import _cmd_mcp from .skills_cmd import ( _cmd_install_skill, _cmd_install_skills, _cmd_list_skills, _cmd_uninstall_skill, ) from .tui_interactive import run_textual_interactive from .tui_runtime import resolve_ui_backend, run_streaming _channel_logger = logging.getLogger(__name__) # ============================================================================= # Banner # ============================================================================= def print_banner( thread_id: str, workspace_dir: str | None = None, memory_dir: str | None = None, mode: str | None = None, model: str | None = None, provider: str | None = None, ui_backend: str | None = None, ): """Print welcome banner with ASCII art logo, info line, and hint.""" for line, color in zip(LOGO_LINES, LOGO_GRADIENT, strict=False): console.print(Text(line, style=f"{color} bold")) info = Text() info.append(" ", style="dim") parts: list[tuple[str, str]] = [] if model: parts.append(("Model: ", model)) if provider: parts.append(("Provider: ", provider)) if mode: parts.append(("Mode: ", mode)) if ui_backend: parts.append(("UI: ", ui_backend)) for i, (label, value) in enumerate(parts): if i > 0: info.append(" ", style="dim") info.append(label, style="dim") info.append(value, style="magenta") # Directory line import os effective_dir = workspace_dir or os.getcwd() home = os.path.expanduser("~") dir_display = ( effective_dir.replace(home, "~", 1) if effective_dir.startswith(home) else effective_dir ) info.append("\n ", style="dim") info.append("Directory: ", style="dim") info.append(dir_display, style="magenta") _nl_key = "Option+Enter" if sys.platform == "darwin" else "Ctrl+J" info.append("\n Enter ", style="#ffe082") info.append("send", style="#ffe082 bold") info.append(f" \u2022 {_nl_key} ", style="#ffe082") info.append("newline", style="#ffe082 bold") info.append(" \u2022 Type ", style="#ffe082") info.append("/", style="#ffe082 bold") info.append(" for commands", style="#ffe082") info.append(" \u2022 Ctrl+C ", style="#ffe082") info.append("interrupt", style="#ffe082 bold") console.print(info) # ============================================================================= # Slash-command completer # ============================================================================= _SLASH_COMMANDS = [ ("/current", "Show current session info"), ("/threads", "List recent sessions"), ("/resume", "Resume a previous session (prefix match)"), ("/delete", "Delete a saved session"), ("/new", "Start a new session"), ("/skills", "List installed skills"), ("/install-skill", "Add a skill from path or GitHub"), ("/uninstall-skill", "Remove an installed skill"), ("/evoskills", "Browse and install EvoSkills (optional: /evoskills )"), ("/mcp", "Manage MCP servers"), ("/channel", "Configure messaging channels"), ("/compact", "Compact conversation to free context"), ("/exit", "Quit EvoScientist"), ] _COMPLETION_STYLE = PtStyle.from_dict( { "completion-menu": "bg:default noreverse nounderline noitalic", "completion-menu.completion": "bg:default #888888 noreverse", "completion-menu.completion.current": "bg:default default bold noreverse", "completion-menu.meta.completion": "bg:default #888888 noreverse", "completion-menu.meta.completion.current": "bg:default default bold noreverse", "scrollbar.background": "bg:default", "scrollbar.button": "bg:default", } ) # Style for questionary pickers — matches _COMPLETION_STYLE visual language: # gray (#888888) for non-selected, bold for selected, no background changes. _PICKER_STYLE = PtStyle.from_dict( { "questionmark": "#888888", "question": "", "pointer": "bold", "highlighted": "bold", "text": "#888888", "answer": "bold", } ) class SlashCommandCompleter(Completer): """Autocomplete for slash commands and ``@file`` mentions.""" def __init__(self, workspace_dir: str | None = None) -> None: self._workspace_dir = workspace_dir def get_completions(self, document, complete_event): text = document.text_before_cursor # @file mention completion if "@" in text: candidates = complete_file_mention(text, self._workspace_dir) if candidates: # Replace from the last '@' token import re as _re m = _re.search(r"@[^\s]*$", text) start = -len(m.group(0)) if m else 0 for path, type_hint in candidates: yield Completion(path, start_position=start, display_meta=type_hint) return # Slash command completion if not text.startswith("/"): return for cmd, desc in _SLASH_COMMANDS: if cmd.startswith(text): yield Completion( cmd, start_position=-len(text), display=f"{cmd:<40}", display_meta=desc, ) # ============================================================================= # Interactive & single-shot modes # ============================================================================= def cmd_interactive( show_thinking: bool = True, channel_send_thinking: bool = True, workspace_dir: str | None = None, workspace_fixed: bool = False, mode: str | None = None, model: str | None = None, provider: str | None = None, run_name: str | None = None, thread_id: str | None = None, ui_backend: str = "cli", config=None, ) -> None: """Interactive conversation mode with streaming output. The persistent ``AsyncSqliteSaver`` checkpointer is opened here and shared for the entire interactive session lifetime. Args: show_thinking: Whether to display thinking panels channel_send_thinking: Whether channels should receive thinking messages workspace_dir: Per-session workspace directory path workspace_fixed: If True, /new keeps the same workspace directory mode: Workspace mode ('daemon' or 'run'), displayed in banner model: Model name to display in banner provider: LLM provider name to display in banner run_name: Optional run name for /new session deduplication thread_id: Optional thread ID to resume a previous session ui_backend: UI backend ('cli' or 'tui') """ import nest_asyncio nest_asyncio.apply() resolved_ui_backend = resolve_ui_backend(ui_backend, warn_fallback=True) if resolved_ui_backend == "tui": from functools import partial load_agent = partial(_load_agent, config=config) run_textual_interactive( show_thinking=show_thinking, channel_send_thinking=channel_send_thinking, workspace_dir=workspace_dir, workspace_fixed=workspace_fixed, mode=mode, model=model, provider=provider, run_name=run_name, thread_id=thread_id, load_agent=load_agent, create_session_workspace=_create_session_workspace, ) return from .. import paths memory_dir = str(paths.MEMORY_DIR) from ..config.settings import get_config_dir config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) history_file = str(config_dir / "history") # Key bindings: Enter submits, Alt+Enter (Option+Enter) inserts newline _kb = KeyBindings() @_kb.add("escape", "enter") # Alt+Enter / Option+Enter on macOS def _insert_newline(event): event.current_buffer.insert_text("\n") @_kb.add("enter") def _submit(event): event.current_buffer.validate_and_handle() session = PromptSession( history=FileHistory(history_file), auto_suggest=AutoSuggestFromHistory(), completer=SlashCommandCompleter(workspace_dir=workspace_dir), complete_style=CompleteStyle.COLUMN, complete_while_typing=True, style=_COMPLETION_STYLE, multiline=True, key_bindings=_kb, ) def _print_separator(): """Print a horizontal separator line spanning the terminal width.""" width = console.size.width console.print(Text("\u2500" * width, style="dim")) # Mutable state for async loop state: dict[str, Any] = { "agent": None, "thread_id": thread_id or generate_thread_id(), "workspace_dir": workspace_dir, "running": True, "resumed": False, "ui_backend": resolved_ui_backend, } async def _resolve_thread_id(tid: str) -> str | None: """Resolve a (possibly partial) thread ID. Returns full ID or None.""" if await thread_exists(tid): return tid similar = await find_similar_threads(tid) if len(similar) == 1: return similar[0] if len(similar) > 1: console.print( f"[yellow]Ambiguous thread ID '{escape(tid)}'. Matches:[/yellow]" ) for s in similar: console.print(f" [cyan]{s}[/cyan]") return None console.print(f"[red]Thread '{escape(tid)}' not found.[/red]") return None async def _cmd_threads(): """Handle /threads command — show recent sessions.""" threads = await list_threads( limit=0, include_message_count=True, include_preview=True, ) if not threads: console.print("[yellow]No saved sessions.[/yellow]") return table = Table(title="Sessions", show_header=True, header_style="bold cyan") table.add_column("ID", style="bold") table.add_column("Preview", style="dim", max_width=50, no_wrap=True) table.add_column("Messages", justify="right") table.add_column("Model", style="dim") table.add_column("Last Used", style="dim") for t in threads: tid = t["thread_id"] marker = " *" if tid == state["thread_id"] else "" table.add_row( f"{tid}{marker}", t.get("preview", "") or "", str(t.get("message_count", 0)), t.get("model", "") or "", _format_relative_time(t.get("updated_at")), ) console.print() console.print(table) console.print( "[dim] /resume[/dim] to continue a session [dim]/delete [/dim] to remove [dim]/new[/dim] to start fresh" ) console.print() async def _render_history(thread_id: str): """Display conversation history for a resumed session.""" messages = await get_thread_messages(thread_id) if not messages: return HISTORY_WINDOW = 50 # Only human and ai messages; skip tool/system display = [m for m in messages if getattr(m, "type", None) in ("human", "ai")] if len(display) > HISTORY_WINDOW: skipped = len(display) - HISTORY_WINDOW display = display[-HISTORY_WINDOW:] console.print(f"[dim]── ... {skipped} earlier messages ──[/dim]") else: console.print("[dim]── Conversation history ──[/dim]") for msg in display: msg_type = getattr(msg, "type", None) content = getattr(msg, "content", "") or "" if msg_type == "human": # Extract text from multimodal list if isinstance(content, list): parts = [ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" ] content = " ".join(parts) if parts else "" content = content.strip() if content: console.print( Text.assemble(("\u276f ", "bold blue"), (content, "")) ) elif msg_type == "ai": thinking_text = "" text_content = "" if isinstance(content, list): for block in content: if not isinstance(block, dict): continue if block.get("type") == "thinking": thinking_text += block.get("thinking", "") elif block.get("type") == "text": text_content += block.get("text", "") else: text_content = content or "" text_content = text_content.strip() # Thinking panel (only when show_thinking is enabled) if thinking_text.strip() and show_thinking: console.print( Panel( thinking_text.strip(), title="[bold blue]\U0001f4ad Thinking[/bold blue]", border_style="blue", expand=False, ) ) # AI response — full Markdown rendering if text_content: console.print(Markdown(text_content)) # Skip tool messages — verbose and not useful in replay console.print("[dim]── End of history ──[/dim]") console.print() async def _cmd_resume(arg: str, checkpointer): """Handle /resume [id] — resume a previous session.""" if not arg: # Show interactive session picker with conversation previews threads = await list_threads( limit=0, include_message_count=True, include_preview=True, ) if not threads: console.print("[yellow]No sessions to resume.[/yellow]") return import questionary from .widgets.thread_selector import _build_items choices = [] items = _build_items(threads) for item in items: if item["type"] == "header": choices.append( questionary.Separator( f"\u2500\u2500 \U0001f4c2 {item['label']}" ) ) elif item["type"] == "subheader": choices.append(questionary.Separator(f" {item['label']}")) else: t = item["thread"] tid = t["thread_id"] preview = t.get("preview", "") or "" msgs = t.get("message_count", 0) model = t.get("model", "") or "" when = _format_relative_time(t.get("updated_at")) indent = " " if item.get("indented") else " " parts = [f"{indent}{tid}"] if preview: parts.append( preview[:40] + "\u2026" if len(preview) > 40 else preview ) parts.append(f"({msgs} msgs)") if model: parts.append(model) if when: parts.append(when) label = " ".join(parts) choices.append(questionary.Choice(title=label, value=tid)) from prompt_toolkit.layout.dimension import Dimension from questionary.prompts.common import InquirerControl prompt = questionary.select( "Select session to resume:", choices=choices, style=_PICKER_STYLE, ) # Limit visible list to 10 rows with scrolling for window in prompt.application.layout.find_all_windows(): if isinstance(window.content, InquirerControl): window.height = Dimension(max=10) break selected = prompt.ask() if selected is None: return arg = selected resolved = await _resolve_thread_id(arg) if not resolved: return meta = await get_thread_metadata(resolved) ws = (meta or {}).get("workspace_dir", "") or state["workspace_dir"] state["thread_id"] = resolved state["resumed"] = True if ws: state["workspace_dir"] = ws console.print("[dim]Loading session...[/dim]") state["agent"] = _load_agent( workspace_dir=state["workspace_dir"], checkpointer=checkpointer, config=config, ) # Sync shared refs if channel is running if _channels_is_running(): _ch_mod._cli_agent = state["agent"] _ch_mod._cli_thread_id = state["thread_id"] console.print(f"[green]Resumed session:[/green] [yellow]{resolved}[/yellow]") if state["workspace_dir"]: console.print( f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]" ) console.print() await _render_history(resolved) async def _cmd_delete(arg: str): """Handle /delete — delete a saved session.""" if not arg: console.print("[red]Usage: /delete [/red]") return resolved = await _resolve_thread_id(arg) if not resolved: return if resolved == state["thread_id"]: console.print("[red]Cannot delete the current session.[/red]") return deleted = await delete_thread(resolved) if deleted: console.print(f"[green]Deleted session {resolved}.[/green]") else: console.print(f"[red]Session {resolved} not found.[/red]") async def _async_main_loop(): """Async main loop with prompt_async and channel queue checking.""" async with get_checkpointer() as checkpointer: # Handle --thread-id resume if thread_id: resolved = await _resolve_thread_id(thread_id) if resolved: meta = await get_thread_metadata(resolved) ws = (meta or {}).get("workspace_dir", "") or state["workspace_dir"] state["thread_id"] = resolved state["resumed"] = True if ws: state["workspace_dir"] = ws console.print("[dim]Loading agent...[/dim]") state["agent"] = _load_agent( workspace_dir=state["workspace_dir"], checkpointer=checkpointer, config=config, ) # Print banner if state["resumed"]: print_banner( state["thread_id"], state["workspace_dir"], memory_dir, mode, model, provider, state["ui_backend"], ) console.print( f"[green]Resumed session [yellow]{state['thread_id']}[/yellow][/green]\n" ) else: print_banner( state["thread_id"], state["workspace_dir"], memory_dir, mode, model, provider, state["ui_backend"], ) # ---- Channel queue processing (bus → main thread) ---- async def _process_channel_message(msg: ChannelMessage) -> None: """Process a single channel message with real-time streaming. Clears the waiting prompt line and reprints the message as if the user typed it after ❯, then streams the agent response with Rich Live display. Display: ❯ message content [channel: Received from sender] ───────────────── (real-time streaming output) [channel: Replied to sender] ───────────────── """ # Clear the waiting ❯ prompt line sys.stdout.write("\r\033[2K") sys.stdout.flush() # Reprint as if user typed it after ❯ prompt_line = Text() prompt_line.append("\u276f ", style="bold blue") prompt_line.append(msg.content) console.print(prompt_line) rx = Text() rx.append(f"[{msg.channel_type}: Received from ", style="dim") rx.append(msg.sender, style="cyan") rx.append("]", style="dim") console.print(rx) _print_separator() def _send_to_channel(coro, label: str, timeout: int = 15) -> None: """Schedule an async channel send on the bus loop.""" loop = _ch_mod._bus_loop if not loop: return try: asyncio.run_coroutine_threadsafe(coro, loop).result( timeout=timeout ) except Exception as e: _channel_logger.debug(f"{label} send failed: {e}") def _send_thinking_to_channel(thinking: str) -> None: ch = msg.channel_ref if ch and ch.send_thinking: _send_to_channel( ch.send_thinking_message( sender=msg.chat_id, thinking=thinking, metadata=msg.metadata, ), "Thinking", ) def _send_todo_to_channel(items: list[dict]) -> None: from ..channels.consumer import _format_todo_list if msg.channel_ref: _send_to_channel( msg.channel_ref.send_todo_message( sender=msg.chat_id, content=_format_todo_list(items), metadata=msg.metadata, ), "Todo", ) def _send_media_to_channel(file_path: str) -> None: if msg.channel_ref: _send_to_channel( msg.channel_ref.send_media( recipient=msg.chat_id, file_path=file_path, metadata=msg.metadata, ), "Media", timeout=30, ) def _channel_hitl_prompt(action_requests: list) -> list[dict] | None: """Send HITL approval prompt to channel user and wait for reply.""" return _ch_mod.channel_hitl_prompt(action_requests, msg) def _channel_ask_user(ask_user_data: dict) -> dict: """Send ask_user questions to channel user and wait for reply.""" return _ch_mod.channel_ask_user_prompt(ask_user_data, msg) meta = build_metadata(state["workspace_dir"], model) try: response = run_streaming( ui_backend=state["ui_backend"], agent=state["agent"], message=msg.content, thread_id=state["thread_id"], show_thinking=show_thinking, interactive=True, metadata=meta, on_thinking=_send_thinking_to_channel, on_todo=_send_todo_to_channel, on_file_write=_send_media_to_channel, hitl_prompt_fn=_channel_hitl_prompt, ask_user_prompt_fn=_channel_ask_user, ) except Exception as e: response = f"Error: {e}" console.print(f"[red]Channel error: {e}[/red]") _set_channel_response(msg.msg_id, response) tx = Text() tx.append(f"[{msg.channel_type}: Replied to ", style="dim") tx.append(msg.sender, style="cyan") tx.append("]", style="dim") console.print(tx) _print_separator() # Redraw the ❯ prompt on a new line after separator sys.stdout.write("\033[34;1m\u276f\033[0m ") sys.stdout.flush() async def _check_channel_queue() -> None: """Poll the channel message queue and dispatch to the agent.""" while True: try: msg = _message_queue.get_nowait() except queue.Empty: await asyncio.sleep(0.1) continue await _process_channel_message(msg) queue_task = asyncio.create_task(_check_channel_queue()) # Startup hint console.print( Text( " EvoScientist is your research buddy.\n" " Tell it about your taste before cooking some meal!", style="yellow", ) ) # Auto-start channel if enabled in config from ..config import load_config _channel_cfg = load_config() if ( _channel_cfg and _channel_cfg.channel_enabled and not _channels_is_running() ): _auto_start_channel( state["agent"], state["thread_id"], _channel_cfg, send_thinking=channel_send_thinking, ) # Update check — non-blocking, runs in background thread import concurrent.futures _update_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) def _show_update_hint() -> None: try: from ..update_check import _installed_version, is_update_available available, latest = is_update_available() if available: current = _installed_version() console.print( Text( f" Update available: v{latest} (current: v{current}).\n" " Run: uv tool upgrade EvoScientist", style="yellow", ) ) except Exception: pass _update_executor.submit(_show_update_hint) # Slogan — after channels, right before user input console.print( Text(f" {random.choice(WELCOME_SLOGANS)}", style="dim italic") ) console.print() try: _print_separator() while state["running"]: try: user_input = await session.prompt_async( HTML("\u276f ") ) user_input = user_input.strip() if not user_input: # Erase the empty prompt line so it looks like nothing happened sys.stdout.write("\033[A\033[2K\r") sys.stdout.flush() continue _print_separator() # Special commands if user_input.lower() in ("/exit", "/quit", "/q"): console.print("[dim]Goodbye![/dim]") state["running"] = False break if user_input.lower() == "/threads": await _cmd_threads() continue if user_input.lower().startswith("/resume"): arg = user_input[len("/resume") :].strip() await _cmd_resume(arg, checkpointer) continue if user_input.lower().startswith("/delete"): arg = user_input[len("/delete") :].strip() await _cmd_delete(arg) continue if user_input.lower() == "/new": # New session: new thread; workspace only changes if not fixed if not workspace_fixed: state["workspace_dir"] = _create_session_workspace( run_name ) console.print("[dim]Loading new session...[/dim]") state["agent"] = _load_agent( workspace_dir=state["workspace_dir"], checkpointer=checkpointer, config=config, ) state["thread_id"] = generate_thread_id() state["resumed"] = False # Sync channel refs so the queue checker uses the new agent if _channels_is_running(): _ch_mod._cli_agent = state["agent"] _ch_mod._cli_thread_id = state["thread_id"] console.print( f"[green]New session:[/green] [yellow]{state['thread_id']}[/yellow]" ) if state["workspace_dir"]: console.print( f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]\n" ) continue if user_input.lower() == "/current": console.print( f"[dim]Thread:[/dim] [yellow]{state['thread_id']}[/yellow]" ) if state["workspace_dir"]: console.print( f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]" ) if memory_dir: console.print( f"[dim]Memory dir:[/dim] [cyan]{_shorten_path(memory_dir)}[/cyan]" ) console.print( f"[dim]UI:[/dim] [cyan]{state['ui_backend']}[/cyan]" ) console.print() continue if user_input.lower() == "/skills": _cmd_list_skills() continue if user_input.lower().startswith("/install-skill"): source = user_input[len("/install-skill") :].strip() _cmd_install_skill(source) continue if user_input.lower().startswith("/uninstall-skill"): name = user_input[len("/uninstall-skill") :].strip() _cmd_uninstall_skill(name) continue if user_input.lower().startswith("/evoskills"): browse_args = user_input[len("/evoskills") :].strip() _cmd_install_skills(browse_args) continue if user_input.lower().startswith("/mcp"): _cmd_mcp(user_input[len("/mcp") :]) continue if user_input.lower().startswith("/channel"): args = user_input[len("/channel") :].strip() if args.lower().startswith("stop"): stop_arg = args[len("stop") :].strip() _cmd_channel_stop(stop_arg or None) else: _cmd_channel( args, state["agent"], state["thread_id"], send_thinking=channel_send_thinking, ) continue if user_input.lower() == "/compact": from .commands import ( compact_conversation, render_compact_result, ) with console.status( "[cyan]Compacting conversation...[/cyan]" ): result = await compact_conversation( agent=state["agent"], thread_id=state["thread_id"], ) console.print(render_compact_result(result)) continue # Resolve @file mentions — inject file contents inline _, message_to_send = resolve_file_mentions( user_input, state["workspace_dir"] ) # Stream agent response with metadata for persistence console.print() meta = build_metadata(state["workspace_dir"], model) run_streaming( ui_backend=state["ui_backend"], agent=state["agent"], message=message_to_send, thread_id=state["thread_id"], show_thinking=show_thinking, interactive=True, metadata=meta, ) console.print() _print_separator() except KeyboardInterrupt: console.print("\n[dim]Goodbye![/dim]") state["running"] = False break except EOFError: # Handle Ctrl+D console.print("\n[dim]Goodbye![/dim]") state["running"] = False break except Exception as e: error_msg = str(e) if ( "authentication" in error_msg.lower() or "api_key" in error_msg.lower() ): console.print("[red]Error: API key not configured.[/red]") console.print( "[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]" ) state["running"] = False break else: console.print(f"[red]Error: {escape(str(e))}[/red]") finally: queue_task.cancel() try: await queue_task except asyncio.CancelledError: pass # Run the async main loop try: asyncio.run(_async_main_loop()) except KeyboardInterrupt: console.print("\n[dim]Goodbye![/dim]") def cmd_run( agent: Any, prompt: str, thread_id: str | None = None, show_thinking: bool = True, workspace_dir: str | None = None, model: str | None = None, ui_backend: str = "cli", ) -> None: """Single-shot execution with streaming display. Args: agent: Compiled agent graph prompt: User prompt thread_id: Optional thread ID (generates new one if None) show_thinking: Whether to display thinking panels workspace_dir: Per-session workspace directory path model: Model name for checkpoint metadata ui_backend: UI backend ('cli' or 'tui') """ thread_id = thread_id or generate_thread_id() width = console.size.width sep = Text("\u2500" * width, style="dim") console.print(sep) console.print(Text(f"> {prompt}")) console.print(sep) console.print(f"[dim]Thread: {thread_id}[/dim]") if workspace_dir: console.print(f"[dim]Workspace: {_shorten_path(workspace_dir)}[/dim]") console.print() meta = build_metadata(workspace_dir, model) try: run_streaming( ui_backend=resolve_ui_backend(ui_backend, warn_fallback=True), agent=agent, message=prompt, thread_id=thread_id, show_thinking=show_thinking, interactive=False, metadata=meta, ) except Exception as e: error_msg = str(e) if "authentication" in error_msg.lower() or "api_key" in error_msg.lower(): console.print("[red]Error: API key not configured.[/red]") console.print( "[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]" ) raise typer.Exit(1) from e else: console.print(f"[red]Error: {e}[/red]") raise