Files
EvoScientist-Multi/EvoScientist/cli/interactive.py
T
2026-03-19 17:04:02 +01:00

919 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.shortcuts import CompleteStyle # type: ignore[import-untyped]
from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untyped]
from rich.markup import escape
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 .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):
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")
info.append("\n Type ", style="#ffe082")
info.append("/", style="#ffe082 bold")
info.append(" for commands", style="#ffe082")
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"),
("/install-skills", "Browse and install skills (optional: /install-skills <tag>)"),
("/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 — triggers when input starts with '/'."""
def get_completions(self, document, complete_event):
text = document.text_before_cursor
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")
session = PromptSession(
history=FileHistory(history_file),
auto_suggest=AutoSuggestFromHistory(),
completer=SlashCommandCompleter(),
complete_style=CompleteStyle.COLUMN,
complete_while_typing=True,
style=_COMPLETION_STYLE,
)
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 <id>[/dim] to remove [dim]/new[/dim] to start fresh"
)
console.print()
async def _render_history(thread_id: str):
"""Display a compact conversation history for a resumed session."""
messages = await get_thread_messages(thread_id)
if not messages:
return
MAX_CONTENT_LEN = 200 # truncate long messages
def _truncate(text: str) -> str:
text = text.strip()
if len(text) <= MAX_CONTENT_LEN:
return text
return text[:MAX_CONTENT_LEN] + "..."
console.print("[dim]── Conversation history ──[/dim]")
for msg in messages:
msg_type = getattr(msg, "type", None)
content = getattr(msg, "content", "") or ""
# content can be a list of blocks (multimodal) — extract text
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 ""
if msg_type == "human":
console.print(
Text.assemble(
("\u276f ", "bold blue"),
(_truncate(content), ""),
)
)
elif msg_type == "ai":
tool_calls = getattr(msg, "tool_calls", None) or []
if content:
console.print(Text(_truncate(content), style="dim"))
if tool_calls:
names = [tc.get("name", "?") for tc in tool_calls]
console.print(
Text(
f" \u25b6 {', '.join(names)}",
style="dim italic",
)
)
# Skip tool messages — they are 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
choices = []
# Display-width-aware padding (CJK chars take 2 columns)
import unicodedata
def _display_width(s: str) -> int:
w = 0
for ch in s:
w += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
return w
def _pad_to_width(s: str, target: int) -> str:
pad = target - _display_width(s)
return s + " " * max(pad, 2)
lefts = [t.get("preview", "") or t["thread_id"] for t in threads]
col_width = max(_display_width(s) for s in lefts) + 4
for t, left_text in zip(threads, lefts):
tid = t["thread_id"]
when = _format_relative_time(t.get("updated_at"))
label = f"{_pad_to_width(left_text, col_width)}({tid} {when})"
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 <id> — delete a saved session."""
if not arg:
console.print("[red]Usage: /delete <thread-id>[/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())
# 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,
)
# 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("<ansiblue><b>\u276f</b></ansiblue> ")
)
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("/install-skills"):
browse_args = user_input[len("/install-skills") :].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
# 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=user_input,
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)
else:
console.print(f"[red]Error: {e}[/red]")
raise