c5a4d559a2
- Added blank lines for better separation of test cases in multiple test files. - Reformatted event handling in tests for clarity and consistency. - Ensured consistent use of multi-line formatting for dictionary arguments in event handling. - Improved assertions and test descriptions for better understanding. - Updated test cases across various modules including test_stream_state, test_stream_utils, test_summarization, test_thread_selector, test_tool_error_handler, test_tui_widgets, test_ui_runtime, and test_wechat_channel.
902 lines
35 KiB
Python
902 lines
35 KiB
Python
"""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.completion import Completer, Completion # type: ignore[import-untyped]
|
||
from prompt_toolkit.history import FileHistory # type: ignore[import-untyped]
|
||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory # type: ignore[import-untyped]
|
||
from prompt_toolkit.formatted_text import HTML # 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
|
||
|
||
from ..sessions import (
|
||
generate_thread_id,
|
||
get_checkpointer,
|
||
list_threads,
|
||
thread_exists,
|
||
find_similar_threads,
|
||
delete_thread,
|
||
get_thread_metadata,
|
||
get_thread_messages,
|
||
_format_relative_time,
|
||
)
|
||
from ..stream.display import console
|
||
from .tui_runtime import run_streaming, resolve_ui_backend
|
||
from .tui_interactive import run_textual_interactive
|
||
from ._constants import WELCOME_SLOGANS, LOGO_LINES, LOGO_GRADIENT, build_metadata
|
||
from .agent import _shorten_path, _create_session_workspace, _load_agent
|
||
from .channel import (
|
||
ChannelMessage,
|
||
_channels_is_running,
|
||
_cmd_channel,
|
||
_cmd_channel_stop,
|
||
_auto_start_channel,
|
||
_message_queue,
|
||
_set_channel_response,
|
||
)
|
||
import EvoScientist.cli.channel as _ch_mod
|
||
from .mcp_ui import _cmd_mcp
|
||
from .skills_cmd import _cmd_list_skills, _cmd_install_skill, _cmd_uninstall_skill
|
||
|
||
_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"),
|
||
("/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("/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
|