Files
EvoScientist-Multi/EvoScientist/cli/interactive.py
T
Xi Zhang fab5f85eee v0.0.4 (#93)
* feat(tui): enhance conversation history rendering and implement two-level thread hierarchy in picker

* feat(tui): improve conversation history display and enhance thread selection UI

* feat(file_mentions): implement @file mention parsing and completion for CLI and TUI

* feat(uv-tool): add compatibility checks and installation helpers for uv tool environments

* feat(dependencies): update package versions in uv.lock for compatibility and improvements

* feat(badges): update PyPI version to v0.0.4 in SVG assets and README files

* feat(tests): format code in TestUvToolCompat for improved readability
2026-03-24 18:13:42 +00:00

1035 lines
40 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.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 <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 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 <id>[/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 <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())
# 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("<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("/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