2035 lines
87 KiB
Python
2035 lines
87 KiB
Python
"""Full-screen Textual interactive TUI for EvoScientist.
|
|
|
|
Widget-based rendering: each message/tool/sub-agent is an independent widget
|
|
mounted into a VerticalScroll container. No timer-based Group rebuilds.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import queue
|
|
import random
|
|
import shlex
|
|
from typing import Any, Callable
|
|
|
|
from rich.console import Group
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
|
|
import EvoScientist.cli.channel as _ch_mod
|
|
from .channel import (
|
|
ChannelMessage,
|
|
_channels_is_running,
|
|
_channels_running_list,
|
|
_channels_stop,
|
|
_auto_start_channel,
|
|
_message_queue,
|
|
_set_channel_response,
|
|
)
|
|
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 ..config.settings import get_config_dir
|
|
from ..stream.events import stream_agent_events
|
|
from ..stream.state import StreamState, _INTERNAL_TOOLS
|
|
from .history_suggester import HistorySuggester
|
|
|
|
from ._constants import LOGO_LINES, LOGO_GRADIENT, WELCOME_SLOGANS, build_metadata
|
|
|
|
_channel_logger = logging.getLogger(__name__)
|
|
|
|
_TUI_SLASH_COMMANDS = [
|
|
("/current", "Show current session info"),
|
|
("/threads", "List recent sessions"),
|
|
("/resume", "Resume a previous session"),
|
|
("/delete", "Delete a saved session"),
|
|
("/new", "Start a new session"),
|
|
("/clear", "Clear chat history"),
|
|
("/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"),
|
|
("/help", "Show available commands"),
|
|
("/exit", "Quit EvoScientist"),
|
|
]
|
|
|
|
|
|
def _shorten_path(path: str) -> str:
|
|
"""Shorten absolute path to a cwd-relative form (consistent with Rich CLI)."""
|
|
if not path:
|
|
return path
|
|
from .agent import _shorten_path as _sp
|
|
return _sp(path)
|
|
|
|
|
|
def _build_welcome_banner(
|
|
*,
|
|
thread_id: str,
|
|
workspace_dir: str | None,
|
|
mode: str | None,
|
|
model: str | None,
|
|
provider: str | None,
|
|
ui_backend: str | None = None,
|
|
channels: list[tuple[str, bool, str]] | None = None,
|
|
) -> Any:
|
|
"""Build CLI-matching welcome banner with logo, info line, and channels.
|
|
|
|
Args:
|
|
channels: List of (name, ok, detail) tuples for the channels panel.
|
|
"""
|
|
banner = Text()
|
|
for line, color in zip(LOGO_LINES, LOGO_GRADIENT):
|
|
banner.append(f"{line}\n", style=f"bold {color}")
|
|
|
|
# Info line — matches CLI print_banner format
|
|
info = Text()
|
|
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))
|
|
if parts:
|
|
info.append(" ", style="dim")
|
|
for i, (label, value) in enumerate(parts):
|
|
if i > 0:
|
|
info.append(" ", style="dim")
|
|
info.append(label, style="dim")
|
|
info.append(value, style="magenta")
|
|
info.append("\n Type ", style="#ffe082")
|
|
info.append("/", style="#ffe082 bold")
|
|
info.append(" for commands", style="#ffe082")
|
|
banner.append_text(info)
|
|
|
|
slogan = Text(f"\n {random.choice(WELCOME_SLOGANS)}", style="dim italic")
|
|
|
|
# Channels panel
|
|
if channels:
|
|
from rich.panel import Panel
|
|
|
|
lines: list[Text] = []
|
|
all_ok = True
|
|
for name, ok, detail in channels:
|
|
line = Text()
|
|
if ok:
|
|
line.append("\u25cf ", style="green")
|
|
line.append(name, style="bold")
|
|
else:
|
|
line.append("\u25cb ", style="dim")
|
|
line.append(name, style="bold dim")
|
|
all_ok = False
|
|
if detail:
|
|
line.append(f" {detail}", style="dim")
|
|
lines.append(line)
|
|
body = Text("\n").join(lines)
|
|
border = "green" if all_ok else "dim"
|
|
panel = Panel(body, title="[bold]Channels[/bold]", border_style=border, expand=False)
|
|
return Group(banner, Text(""), panel, slogan)
|
|
|
|
# No channels — append slogan directly to banner
|
|
banner.append_text(slogan)
|
|
return banner
|
|
|
|
|
|
def _is_final_response(state: StreamState) -> bool:
|
|
"""Check if all tools are done and no sub-agents are active."""
|
|
n_visible = 0
|
|
n_done = 0
|
|
for i, tc in enumerate(state.tool_calls):
|
|
if tc.get("name") in _INTERNAL_TOOLS:
|
|
continue
|
|
n_visible += 1
|
|
if i < len(state.tool_results):
|
|
n_done += 1
|
|
has_pending = n_visible > n_done
|
|
any_active_sa = any(sa.is_active for sa in state.subagents)
|
|
return not has_pending and not any_active_sa and not state.is_processing
|
|
|
|
|
|
def run_textual_interactive(
|
|
*,
|
|
show_thinking: bool,
|
|
channel_send_thinking: bool = True,
|
|
workspace_dir: str | None,
|
|
workspace_fixed: bool,
|
|
mode: str | None,
|
|
model: str | None,
|
|
provider: str | None,
|
|
run_name: str | None,
|
|
thread_id: str | None,
|
|
load_agent: Callable[..., Any],
|
|
create_session_workspace: Callable[[str | None], str],
|
|
) -> None:
|
|
"""Run full-screen Textual interactive chat loop."""
|
|
try:
|
|
from textual.app import App, ComposeResult
|
|
from textual.binding import Binding
|
|
from textual.containers import Container, Horizontal, VerticalScroll
|
|
from textual.events import MouseUp
|
|
from textual.widgets import Input, Static
|
|
|
|
from .clipboard import copy_selection_to_clipboard
|
|
from .widgets import (
|
|
LoadingWidget,
|
|
ThinkingWidget,
|
|
AssistantMessage,
|
|
ToolCallWidget,
|
|
SubAgentWidget,
|
|
TodoWidget,
|
|
UserMessage,
|
|
SystemMessage,
|
|
UsageWidget,
|
|
)
|
|
except Exception as e: # pragma: no cover - runtime fallback path
|
|
raise RuntimeError(
|
|
"Textual TUI backend requires 'textual'. Run: pip install textual"
|
|
) from e
|
|
|
|
class EvoTextualInteractiveApp(App[None]): # type: ignore[type-arg]
|
|
"""Deep-Agents-style full-screen TUI with independent widget rendering."""
|
|
|
|
CSS = """
|
|
Screen {
|
|
layout: vertical;
|
|
background: #16161a;
|
|
color: #d1d5db;
|
|
}
|
|
#chat {
|
|
height: 1fr;
|
|
padding: 1 2;
|
|
background: #16161a;
|
|
}
|
|
#welcome {
|
|
height: auto;
|
|
margin-bottom: 1;
|
|
}
|
|
#input-shell {
|
|
height: auto;
|
|
padding: 0 2 1 2;
|
|
background: #16161a;
|
|
}
|
|
#input-row {
|
|
height: 3;
|
|
border: solid #0284c7;
|
|
background: #1e1f26;
|
|
padding: 0 1;
|
|
}
|
|
#input-cursor {
|
|
width: 2;
|
|
content-align: center middle;
|
|
color: #0284c7;
|
|
text-style: bold;
|
|
}
|
|
#prompt {
|
|
width: 1fr;
|
|
border: none;
|
|
background: transparent;
|
|
color: #e5e7eb;
|
|
}
|
|
#prompt:focus {
|
|
border: none;
|
|
}
|
|
#queued-message {
|
|
display: none;
|
|
height: auto;
|
|
background: #1e1f26;
|
|
padding: 0 2;
|
|
color: #9ca3af;
|
|
}
|
|
#completions {
|
|
display: none;
|
|
height: auto;
|
|
max-height: 15;
|
|
background: #1e1f26;
|
|
padding: 0 1;
|
|
border-bottom: solid #0284c7;
|
|
}
|
|
#status {
|
|
height: 1;
|
|
background: #171a20;
|
|
color: #f59e0b;
|
|
padding: 0 1;
|
|
}
|
|
"""
|
|
BINDINGS = [
|
|
Binding("ctrl+c", "request_quit", "Quit", show=False),
|
|
Binding("up", "edit_queued", show=False, priority=True),
|
|
Binding("escape", "cancel_queued", show=False, priority=True),
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
agent: Any,
|
|
thread_id_value: str,
|
|
workspace: str | None,
|
|
checkpointer: Any,
|
|
channel_send_thinking_value: bool = True,
|
|
resumed: bool = False,
|
|
resume_warning: str = "",
|
|
) -> None:
|
|
super().__init__()
|
|
self._agent = agent
|
|
self._conversation_tid = thread_id_value
|
|
self._workspace_dir = workspace
|
|
self._checkpointer = checkpointer
|
|
self._channel_send_thinking = channel_send_thinking_value
|
|
self._resumed = resumed
|
|
self._resume_warning = resume_warning
|
|
self._channel_timer: Any = None
|
|
self._started_channel_types: list[str] = []
|
|
self._busy = False
|
|
self._run_task: Any = None # asyncio.Task for current _run_turn
|
|
self._queued_messages: list[str] = [] # queued messages to send after current turn
|
|
self._comp_items: list[tuple[str, str]] = []
|
|
self._comp_index: int = -1
|
|
self._hitl_auto_approve: bool = False
|
|
self._approval_future: asyncio.Future | None = None
|
|
self._history_suggester = HistorySuggester(get_config_dir() / "history")
|
|
|
|
# ── Layout ─────────────────────────────────────────────
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with VerticalScroll(id="chat"):
|
|
yield Static("", id="welcome")
|
|
# Widgets are mounted directly here by _stream_with_widgets,
|
|
# _append_system, _mount_renderable, etc.
|
|
|
|
with Container(id="input-shell"):
|
|
yield Static("", id="queued-message")
|
|
yield Static("", id="completions")
|
|
with Horizontal(id="input-row"):
|
|
yield Static(">", id="input-cursor")
|
|
yield Input(
|
|
placeholder="Type message (/ for commands)",
|
|
id="prompt",
|
|
suggester=self._history_suggester,
|
|
)
|
|
|
|
yield Static("", id="status")
|
|
|
|
def on_mount(self) -> None:
|
|
self._render_welcome()
|
|
self._render_status()
|
|
self.query_one("#prompt", Input).focus()
|
|
# Show resume status
|
|
if self._resume_warning:
|
|
self._append_system(self._resume_warning, style="yellow")
|
|
elif self._resumed:
|
|
self._append_system(
|
|
f"Resumed session: {self._conversation_tid}", style="green",
|
|
)
|
|
self.call_later(
|
|
lambda: asyncio.ensure_future(self._render_history(self._conversation_tid))
|
|
)
|
|
# Auto-start channels
|
|
self._start_channels()
|
|
|
|
# ── Channel integration ────────────────────────────────
|
|
|
|
def _start_channels(self) -> None:
|
|
"""Auto-start channels if enabled in config."""
|
|
try:
|
|
from ..config import load_config
|
|
|
|
cfg = load_config()
|
|
if cfg and cfg.channel_enabled and not _channels_is_running():
|
|
_auto_start_channel(
|
|
self._agent,
|
|
self._conversation_tid,
|
|
cfg,
|
|
send_thinking=self._channel_send_thinking,
|
|
)
|
|
types = [
|
|
t.strip()
|
|
for t in cfg.channel_enabled.split(",")
|
|
if t.strip()
|
|
]
|
|
self._started_channel_types = types
|
|
self._render_welcome()
|
|
except Exception as e:
|
|
_channel_logger.debug(f"Channel auto-start failed: {e}")
|
|
self._channel_timer = self.set_interval(0.1, self._poll_channel_queue)
|
|
|
|
def _poll_channel_queue(self) -> None:
|
|
"""Poll the channel message queue (called every 100ms)."""
|
|
try:
|
|
msg = _message_queue.get_nowait()
|
|
except queue.Empty:
|
|
return
|
|
if self._busy:
|
|
_message_queue.put(msg)
|
|
return
|
|
self.call_later(lambda m=msg: asyncio.ensure_future(self._process_channel_message(m)))
|
|
|
|
# ── Widget helpers ─────────────────────────────────────
|
|
|
|
def _append_system(self, text: str, *, style: str = "dim") -> None:
|
|
"""Mount a SystemMessage widget into #chat."""
|
|
container = self.query_one("#chat", VerticalScroll)
|
|
container.mount(SystemMessage(text, msg_style=style))
|
|
container.scroll_end(animate=False)
|
|
|
|
def _mount_renderable(self, renderable: Any) -> None:
|
|
"""Mount a Rich renderable (e.g. Table) as a Static widget."""
|
|
container = self.query_one("#chat", VerticalScroll)
|
|
container.mount(Static(renderable))
|
|
container.scroll_end(animate=False)
|
|
|
|
async def _wait_for_approval(self, approval_widget) -> Any:
|
|
"""Wait for user to interact with an ApprovalWidget.
|
|
|
|
Returns the ``ApprovalWidget.Decided`` message, or ``None`` on
|
|
timeout / cancellation.
|
|
"""
|
|
self._approval_future = asyncio.get_event_loop().create_future()
|
|
try:
|
|
return await asyncio.wait_for(self._approval_future, timeout=300)
|
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
return None
|
|
finally:
|
|
self._approval_future = None
|
|
|
|
def on_approval_widget_decided(self, event) -> None: # type: ignore[override]
|
|
"""Handle ApprovalWidget.Decided message."""
|
|
if self._approval_future and not self._approval_future.done():
|
|
self._approval_future.set_result(event)
|
|
|
|
# ── Streaming core ─────────────────────────────────────
|
|
|
|
async def _stream_with_widgets(
|
|
self,
|
|
user_text: str,
|
|
*,
|
|
on_thinking_cb: Callable[[str], None] | None = None,
|
|
on_todo_cb: Callable[[list[dict]], None] | None = None,
|
|
on_media_cb: Callable[[str], None] | None = None,
|
|
skip_user_message: bool = False,
|
|
channel_hitl_fn: Callable[[list], list[dict] | None] | None = None,
|
|
) -> str:
|
|
"""Stream agent events and mount widgets. Returns response text.
|
|
|
|
Shared by ``_run_turn`` (interactive) and
|
|
``_process_channel_message`` (channel).
|
|
|
|
Args:
|
|
skip_user_message: If True, don't mount UserMessage (caller
|
|
already mounted it — e.g. channel messages with labels).
|
|
channel_hitl_fn: Optional channel-based HITL approval function.
|
|
When provided (channel messages), this is called instead
|
|
of mounting the ApprovalWidget.
|
|
"""
|
|
container = self.query_one("#chat", VerticalScroll)
|
|
|
|
# 1. Mount user message + loading spinner
|
|
if not skip_user_message:
|
|
await container.mount(UserMessage(user_text))
|
|
loading = LoadingWidget()
|
|
await container.mount(loading)
|
|
container.scroll_end(animate=False)
|
|
|
|
# 2. Event-driven widget rendering
|
|
state = StreamState()
|
|
loading_removed = False
|
|
thinking_w: ThinkingWidget | None = None
|
|
assistant_w: AssistantMessage | None = None
|
|
todo_w: TodoWidget | None = None
|
|
tool_widgets: dict[str, ToolCallWidget] = {}
|
|
subagent_widgets: dict[str, SubAgentWidget] = {}
|
|
|
|
# Transient indicator widgets (auto-removed on state transitions)
|
|
narration_w: Static | None = None # dim italic intermediate text
|
|
processing_w: Static | None = None # "Analyzing results..."
|
|
|
|
# Tool collapsing (matches CLI MAX_VISIBLE_TOOLS)
|
|
_MAX_VISIBLE_TOOLS = 4
|
|
completed_tool_order: list[str] = [] # tool_ids in completion order
|
|
collapse_summary_w: Static | None = None
|
|
has_used_tools = False
|
|
|
|
_thinking_sent = False
|
|
_todo_sent = False
|
|
_media_sent: set[str] = set()
|
|
_MIN_THINKING_LEN = 200
|
|
_scroll_pending = False
|
|
|
|
def _schedule_scroll() -> None:
|
|
"""Throttle scroll_end to at most once per 200ms.
|
|
|
|
Uses call_after_refresh so the scroll happens after Textual
|
|
finishes its layout pass — otherwise scroll_end may see
|
|
stale widget heights and not scroll far enough.
|
|
"""
|
|
nonlocal _scroll_pending
|
|
if not _scroll_pending:
|
|
_scroll_pending = True
|
|
self.set_timer(0.2, _do_scroll)
|
|
|
|
def _do_scroll() -> None:
|
|
nonlocal _scroll_pending
|
|
_scroll_pending = False
|
|
self.call_after_refresh(
|
|
lambda: container.scroll_end(animate=False),
|
|
)
|
|
|
|
metadata = build_metadata(self._workspace_dir, model)
|
|
response = ""
|
|
|
|
async def _remove_w(w: Static | None) -> None:
|
|
"""Safely remove a transient indicator widget."""
|
|
if w is not None:
|
|
try:
|
|
await w.remove()
|
|
except Exception:
|
|
pass
|
|
|
|
async def _collapse_completed_tools() -> None:
|
|
"""Hide older completed tool widgets; show summary line."""
|
|
nonlocal collapse_summary_w
|
|
completed = [
|
|
(tid, tool_widgets[tid])
|
|
for tid in completed_tool_order
|
|
if tid in tool_widgets
|
|
]
|
|
n = len(completed)
|
|
if n <= _MAX_VISIBLE_TOOLS:
|
|
if collapse_summary_w is not None:
|
|
collapse_summary_w.display = False
|
|
return
|
|
|
|
to_hide = n - _MAX_VISIBLE_TOOLS
|
|
ok_count = 0
|
|
fail_count = 0
|
|
for i, (_, tw) in enumerate(completed):
|
|
if i < to_hide:
|
|
tw.display = False
|
|
if tw._status == "success":
|
|
ok_count += 1
|
|
else:
|
|
fail_count += 1
|
|
else:
|
|
tw.display = True
|
|
|
|
summary = Text()
|
|
summary.append(f"\u2713 {ok_count} completed", style="dim green")
|
|
if fail_count > 0:
|
|
summary.append(f" | {fail_count} failed", style="dim red")
|
|
|
|
if collapse_summary_w is None:
|
|
collapse_summary_w = Static(summary)
|
|
# Position before first visible tool widget
|
|
first_visible = None
|
|
for _, tw in completed[to_hide:]:
|
|
if tw.display:
|
|
first_visible = tw
|
|
break
|
|
if first_visible:
|
|
await container.mount(collapse_summary_w, before=first_visible)
|
|
else:
|
|
await container.mount(collapse_summary_w)
|
|
else:
|
|
collapse_summary_w.update(summary)
|
|
collapse_summary_w.display = True
|
|
|
|
def _find_or_rename_sa_widget(
|
|
resolved_name: str, description: str = "",
|
|
) -> SubAgentWidget | None:
|
|
"""Look up a sub-agent widget, renaming 'sub-agent' entry if needed."""
|
|
if resolved_name in subagent_widgets:
|
|
w = subagent_widgets[resolved_name]
|
|
if description and not w._description:
|
|
w.update_name(w._sa_name, description)
|
|
return w
|
|
# Rename "sub-agent" → real name (mirrors state._get_or_create_subagent)
|
|
if resolved_name != "sub-agent" and "sub-agent" in subagent_widgets:
|
|
w = subagent_widgets.pop("sub-agent")
|
|
w.update_name(resolved_name, description)
|
|
subagent_widgets[resolved_name] = w
|
|
return w
|
|
return None
|
|
|
|
_MAX_HITL_ROUNDS = 50
|
|
_stream_input: Any = user_text # str or Command for HITL resume
|
|
|
|
for _hitl_round in range(_MAX_HITL_ROUNDS):
|
|
state.pending_interrupt = None
|
|
_hitl_resuming = False
|
|
try:
|
|
async for event in stream_agent_events(
|
|
self._agent,
|
|
_stream_input,
|
|
self._conversation_tid,
|
|
metadata=metadata,
|
|
):
|
|
event_type = state.handle_event(event)
|
|
|
|
# -- Channel callbacks (thinking, todo, media) --
|
|
if (
|
|
on_thinking_cb
|
|
and not _thinking_sent
|
|
and state.thinking_text
|
|
and event_type != "thinking"
|
|
and len(state.thinking_text) >= _MIN_THINKING_LEN
|
|
):
|
|
on_thinking_cb(state.thinking_text.rstrip())
|
|
_thinking_sent = True
|
|
|
|
if (
|
|
on_todo_cb
|
|
and not _todo_sent
|
|
and event_type == "tool_call"
|
|
and event.get("name") == "write_todos"
|
|
and state.todo_items
|
|
):
|
|
if (
|
|
on_thinking_cb
|
|
and not _thinking_sent
|
|
and state.thinking_text
|
|
and len(state.thinking_text) >= _MIN_THINKING_LEN
|
|
):
|
|
on_thinking_cb(state.thinking_text.rstrip())
|
|
_thinking_sent = True
|
|
on_todo_cb(state.todo_items)
|
|
_todo_sent = True
|
|
|
|
if (
|
|
on_media_cb
|
|
and event_type == "tool_result"
|
|
and event.get("success")
|
|
):
|
|
tool_name = event.get("name", "")
|
|
if tool_name in ("write_file", "read_file"):
|
|
_forward_media_to_channel(
|
|
state, tool_name, _media_sent, on_media_cb,
|
|
)
|
|
|
|
# -- Remove loading spinner on first content event --
|
|
if not loading_removed and event_type in (
|
|
"thinking", "text", "tool_call",
|
|
):
|
|
await loading.cleanup()
|
|
loading_removed = True
|
|
|
|
# -- Widget dispatch --
|
|
if event_type == "thinking":
|
|
if thinking_w is None:
|
|
thinking_w = ThinkingWidget(show_thinking=show_thinking)
|
|
await container.mount(thinking_w)
|
|
thinking_w.append_text(event.get("content", ""))
|
|
|
|
elif event_type == "text":
|
|
if thinking_w is not None and thinking_w._is_active:
|
|
thinking_w.finalize()
|
|
# Clear processing indicator
|
|
await _remove_w(processing_w)
|
|
processing_w = None
|
|
|
|
if has_used_tools and not _is_final_response(state):
|
|
# Tools still running — show intermediate narration
|
|
await _remove_w(narration_w)
|
|
narration_w = None
|
|
last_line = state.latest_text.strip().split("\n")[-1].strip()
|
|
if last_line:
|
|
if len(last_line) > 60:
|
|
last_line = last_line[:57] + "\u2026"
|
|
narration_w = Static(
|
|
Text(f" {last_line}", style="dim italic"),
|
|
)
|
|
await container.mount(narration_w)
|
|
else:
|
|
# Stream final response incrementally (both
|
|
# text-only replies and post-tool responses).
|
|
await _remove_w(narration_w)
|
|
narration_w = None
|
|
if assistant_w is None:
|
|
assistant_w = AssistantMessage(state.response_text)
|
|
await container.mount(assistant_w)
|
|
else:
|
|
await assistant_w.append_content(
|
|
event.get("content", ""),
|
|
)
|
|
|
|
elif event_type == "tool_call":
|
|
tool_name = event.get("name", "unknown")
|
|
tool_id = event.get("id", "")
|
|
tool_args = event.get("args", {})
|
|
# Finalize thinking if still active
|
|
if thinking_w is not None and thinking_w._is_active:
|
|
thinking_w.finalize()
|
|
# Clear transient indicators
|
|
await _remove_w(narration_w)
|
|
narration_w = None
|
|
await _remove_w(processing_w)
|
|
processing_w = None
|
|
# Remove early AssistantMessage (text arrived before tools)
|
|
if assistant_w is not None:
|
|
try:
|
|
await assistant_w.remove()
|
|
except Exception:
|
|
pass
|
|
assistant_w = None
|
|
# Skip internal tools and task (handled by SubAgentWidget)
|
|
if tool_name not in _INTERNAL_TOOLS and tool_name != "task":
|
|
has_used_tools = True
|
|
if tool_id and tool_id in tool_widgets:
|
|
# Re-emitted with updated args — update in place
|
|
existing = tool_widgets[tool_id]
|
|
existing._tool_name = tool_name
|
|
existing._tool_args = tool_args
|
|
try:
|
|
existing._render_header()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
w = ToolCallWidget(tool_name, tool_args, tool_id)
|
|
await container.mount(w)
|
|
if tool_id:
|
|
tool_widgets[tool_id] = w
|
|
# Update todo widget on write_todos
|
|
if tool_name == "write_todos" and state.todo_items:
|
|
if todo_w is None:
|
|
todo_w = TodoWidget(state.todo_items)
|
|
await container.mount(todo_w)
|
|
else:
|
|
todo_w.update_items(state.todo_items)
|
|
|
|
elif event_type == "tool_result":
|
|
result_name = event.get("name", "unknown")
|
|
result_content = event.get("content", "")
|
|
result_success = event.get("success", True)
|
|
# Match via state's deduplicated tool_calls (uses tool_id)
|
|
matched = False
|
|
matched_tid = ""
|
|
result_idx = len(state.tool_results) - 1
|
|
if 0 <= result_idx < len(state.tool_calls):
|
|
tc = state.tool_calls[result_idx]
|
|
tid = tc.get("id", "")
|
|
if tid and tid in tool_widgets:
|
|
tw = tool_widgets[tid]
|
|
if tw._status == "running":
|
|
if result_success:
|
|
tw.set_success(result_content)
|
|
else:
|
|
tw.set_error(result_content)
|
|
matched = True
|
|
matched_tid = tid
|
|
# Fallback: match first running widget with same name
|
|
if not matched:
|
|
for fid, tw in tool_widgets.items():
|
|
if tw.tool_name == result_name and tw._status == "running":
|
|
if result_success:
|
|
tw.set_success(result_content)
|
|
else:
|
|
tw.set_error(result_content)
|
|
matched = True
|
|
matched_tid = fid
|
|
break
|
|
# Track completion order for collapsing
|
|
if matched_tid and matched_tid not in completed_tool_order:
|
|
completed_tool_order.append(matched_tid)
|
|
await _collapse_completed_tools()
|
|
# Update todo from results
|
|
if result_name in ("write_todos", "read_todos") and state.todo_items:
|
|
if todo_w is None:
|
|
todo_w = TodoWidget(state.todo_items)
|
|
await container.mount(todo_w)
|
|
else:
|
|
todo_w.update_items(state.todo_items)
|
|
# Show "Analyzing results..." if all tools done, no text yet
|
|
if (
|
|
_is_final_response(state)
|
|
and not state.response_text
|
|
and processing_w is None
|
|
):
|
|
processing_w = Static(
|
|
Text("\u25cf Analyzing results...", style="cyan"),
|
|
)
|
|
await container.mount(processing_w)
|
|
|
|
elif event_type == "subagent_start":
|
|
sa_name = event.get("name", "sub-agent")
|
|
sa_desc = event.get("description", "")
|
|
existing = _find_or_rename_sa_widget(sa_name, sa_desc)
|
|
if existing is None:
|
|
sa_w = SubAgentWidget(sa_name, sa_desc)
|
|
await container.mount(sa_w)
|
|
subagent_widgets[sa_name] = sa_w
|
|
|
|
elif event_type == "subagent_tool_call":
|
|
sa_name = event.get("subagent", "sub-agent")
|
|
sa_name = state._resolve_subagent_name(sa_name)
|
|
sa_w = _find_or_rename_sa_widget(sa_name)
|
|
if sa_w is None:
|
|
sa_w = SubAgentWidget(sa_name)
|
|
await container.mount(sa_w)
|
|
subagent_widgets[sa_name] = sa_w
|
|
await sa_w.add_tool_call(
|
|
event.get("name", "unknown"),
|
|
event.get("args", {}),
|
|
event.get("id", ""),
|
|
)
|
|
|
|
elif event_type == "subagent_tool_result":
|
|
sa_name = event.get("subagent", "sub-agent")
|
|
sa_name = state._resolve_subagent_name(sa_name)
|
|
sa_w = _find_or_rename_sa_widget(sa_name)
|
|
if sa_w is not None:
|
|
sa_w.complete_tool(
|
|
event.get("name", "unknown"),
|
|
event.get("content", ""),
|
|
event.get("success", True),
|
|
event.get("id", ""),
|
|
)
|
|
|
|
elif event_type == "subagent_end":
|
|
sa_name = event.get("name", "sub-agent")
|
|
sa_name = state._resolve_subagent_name(sa_name)
|
|
sa_w = _find_or_rename_sa_widget(sa_name)
|
|
if sa_w is not None:
|
|
sa_w.finalize()
|
|
|
|
elif event_type == "interrupt":
|
|
action_reqs = event.get("action_requests", [])
|
|
n = len(action_reqs) or 1
|
|
|
|
# HITL: check session auto-approve first
|
|
if self._hitl_auto_approve:
|
|
from langgraph.types import Command # type: ignore[import-untyped]
|
|
_stream_input = Command(resume={"decisions": [{"type": "approve"} for _ in range(n)]})
|
|
_hitl_resuming = True
|
|
break # re-enter outer HITL loop
|
|
|
|
# Channel messages: use channel-based text approval
|
|
if channel_hitl_fn is not None:
|
|
self._append_system(
|
|
"Waiting for channel user approval...",
|
|
style="dim italic",
|
|
)
|
|
decisions = await asyncio.to_thread(
|
|
channel_hitl_fn, action_reqs,
|
|
)
|
|
if decisions is not None:
|
|
from langgraph.types import Command # type: ignore[import-untyped]
|
|
_stream_input = Command(resume={"decisions": decisions})
|
|
_hitl_resuming = True
|
|
break # re-enter outer HITL loop
|
|
else:
|
|
state.pending_interrupt = None
|
|
for tw in tool_widgets.values():
|
|
if tw._status == "running":
|
|
tw.set_rejected()
|
|
self._append_system(
|
|
"Tool execution rejected by channel user.",
|
|
style="yellow",
|
|
)
|
|
continue
|
|
|
|
# Interactive TUI: mount approval widget
|
|
from .widgets.approval_widget import ApprovalWidget
|
|
approval_w = ApprovalWidget(action_reqs)
|
|
await container.mount(approval_w)
|
|
_schedule_scroll()
|
|
decided_event = await self._wait_for_approval(approval_w)
|
|
await approval_w.remove()
|
|
if decided_event and decided_event.decisions is not None:
|
|
if decided_event.auto_approve_session:
|
|
self._hitl_auto_approve = True
|
|
from langgraph.types import Command # type: ignore[import-untyped]
|
|
_stream_input = Command(resume={"decisions": decided_event.decisions})
|
|
_hitl_resuming = True
|
|
break # re-enter outer HITL loop with resume
|
|
else:
|
|
state.pending_interrupt = None
|
|
for tw in tool_widgets.values():
|
|
if tw._status == "running":
|
|
tw.set_rejected()
|
|
self._append_system(
|
|
"Tool execution rejected.", style="yellow",
|
|
)
|
|
|
|
elif event_type == "done":
|
|
# Clean up transient indicators
|
|
await _remove_w(narration_w)
|
|
narration_w = None
|
|
await _remove_w(processing_w)
|
|
processing_w = None
|
|
# Mount final response
|
|
if assistant_w is None and state.response_text:
|
|
# Strip trailing standalone "..."
|
|
clean = state.response_text.strip()
|
|
while clean.endswith("\n...") or clean.rstrip() == "...":
|
|
clean = clean.rstrip().removesuffix("...").rstrip()
|
|
assistant_w = AssistantMessage(clean or state.response_text)
|
|
await container.mount(assistant_w)
|
|
# Markdown rendering is async and needs multiple
|
|
# layout cycles to compute final height. Schedule
|
|
# repeated deferred scrolls so long content stays
|
|
# visible even when Markdown takes time to lay out.
|
|
for delay in (0.15, 0.4, 0.8, 1.5):
|
|
self.set_timer(
|
|
delay,
|
|
lambda: self.call_after_refresh(
|
|
lambda: container.scroll_end(animate=False),
|
|
),
|
|
)
|
|
# Mount token usage stats
|
|
if state.total_input_tokens or state.total_output_tokens:
|
|
await container.mount(
|
|
UsageWidget(state.total_input_tokens, state.total_output_tokens)
|
|
)
|
|
|
|
elif event_type == "error":
|
|
error_msg = event.get("message", "Unknown error")
|
|
self._append_system(f"Error: {error_msg}", style="red")
|
|
|
|
# Scroll after Textual processes the layout update
|
|
_schedule_scroll()
|
|
|
|
response = (state.response_text or "").strip()
|
|
|
|
except asyncio.CancelledError:
|
|
# Ctrl+C cancellation
|
|
pass
|
|
except Exception as exc:
|
|
error_msg = str(exc)
|
|
if "authentication" in error_msg.lower() or "api_key" in error_msg.lower():
|
|
self._append_system(
|
|
"Error: API key not configured.", style="red",
|
|
)
|
|
self._append_system(
|
|
"Run EvoSci onboard to set up your API key.", style="dim",
|
|
)
|
|
else:
|
|
self._append_system(f"Error: {exc}", style="red")
|
|
response = f"Error: {exc}"
|
|
finally:
|
|
# Clean up loading widget if it wasn't removed yet
|
|
if not loading_removed:
|
|
try:
|
|
await loading.cleanup()
|
|
except Exception:
|
|
pass
|
|
# Clean up transient indicators
|
|
for w in (narration_w, processing_w):
|
|
await _remove_w(w)
|
|
# Mark any still-running tool widgets as interrupted
|
|
# (skip if HITL approved — tools will continue next round)
|
|
if not _hitl_resuming:
|
|
for tw in tool_widgets.values():
|
|
if tw._status == "running":
|
|
try:
|
|
tw.set_interrupted()
|
|
except Exception:
|
|
pass
|
|
# Finalize any still-active sub-agents
|
|
for sa_w in subagent_widgets.values():
|
|
if sa_w._is_active:
|
|
try:
|
|
sa_w.finalize()
|
|
except Exception:
|
|
pass
|
|
# Finalize thinking widget
|
|
if thinking_w is not None and thinking_w._is_active:
|
|
try:
|
|
thinking_w.finalize()
|
|
except Exception:
|
|
pass
|
|
# Finalize assistant message stream
|
|
if assistant_w is not None:
|
|
await assistant_w.stop_stream()
|
|
# Flush remaining thinking callback
|
|
if (
|
|
on_thinking_cb
|
|
and not _thinking_sent
|
|
and state.thinking_text
|
|
and len(state.thinking_text) >= _MIN_THINKING_LEN
|
|
):
|
|
on_thinking_cb(state.thinking_text.rstrip())
|
|
# Final scrolls to ensure last content is visible.
|
|
# Markdown layout is async — schedule multiple deferred
|
|
# scrolls so long content eventually scrolls into view.
|
|
self.call_after_refresh(
|
|
lambda: container.scroll_end(animate=False),
|
|
)
|
|
for delay in (0.3, 0.8):
|
|
self.set_timer(
|
|
delay,
|
|
lambda: self.call_after_refresh(
|
|
lambda: container.scroll_end(animate=False),
|
|
),
|
|
)
|
|
|
|
# HITL: if interrupt was handled, loop back to resume stream
|
|
if state.pending_interrupt is None:
|
|
break # normal completion or rejection — exit HITL loop
|
|
# Otherwise _stream_input was set to Command(resume=...)
|
|
# by the interrupt handler above; loop continues.
|
|
|
|
return response
|
|
|
|
async def _run_turn(self, user_text: str) -> None:
|
|
"""Handle a user turn: stream agent response with widgets."""
|
|
self._busy = True
|
|
self._render_status()
|
|
cancelled = False
|
|
|
|
try:
|
|
await self._stream_with_widgets(user_text)
|
|
except asyncio.CancelledError:
|
|
cancelled = True
|
|
self._append_system("Interrupted.", style="yellow")
|
|
finally:
|
|
self._busy = False
|
|
self._run_task = None
|
|
self._render_status()
|
|
self.query_one("#prompt", Input).focus()
|
|
|
|
# Process next queued message (FIFO) — skip if interrupted
|
|
if not cancelled and self._queued_messages:
|
|
next_msg = self._queued_messages.pop(0)
|
|
self._render_queue_indicator()
|
|
self._run_task = asyncio.ensure_future(self._run_turn(next_msg))
|
|
|
|
async def _process_channel_message(self, msg: ChannelMessage) -> None:
|
|
"""Process a channel message: stream agent response and reply.
|
|
|
|
Display order (matches Rich CLI):
|
|
> message content
|
|
[channel: Received from sender]
|
|
(streaming response)
|
|
[channel: Replied to sender]
|
|
"""
|
|
self._busy = True
|
|
self._render_status()
|
|
|
|
prompt_widget = self.query_one("#prompt", Input)
|
|
prompt_widget.disabled = True
|
|
|
|
# Mount user message first, then "Received" label
|
|
container = self.query_one("#chat", VerticalScroll)
|
|
await container.mount(UserMessage(msg.content))
|
|
self._append_system(
|
|
f"[{msg.channel_type}: Received from {msg.sender}]",
|
|
style="dim",
|
|
)
|
|
container.scroll_end(animate=False)
|
|
|
|
# Build channel callbacks (fire-and-forget to avoid blocking UI)
|
|
def _send_to_channel(coro, label: str) -> None:
|
|
loop = _ch_mod._bus_loop
|
|
if not loop:
|
|
return
|
|
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
|
future.add_done_callback(
|
|
lambda f: (
|
|
_channel_logger.debug(f"{label} send failed: {f.exception()}")
|
|
if f.exception() else None
|
|
)
|
|
)
|
|
|
|
def _send_thinking(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(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(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",
|
|
)
|
|
|
|
def _channel_hitl_prompt(action_requests: list) -> list[dict] | None:
|
|
"""Send HITL approval prompt to channel user and wait for reply.
|
|
|
|
This runs in a thread (called via asyncio.to_thread) so it can
|
|
block without freezing the Textual event loop.
|
|
"""
|
|
return _ch_mod.channel_hitl_prompt(action_requests, msg)
|
|
|
|
response = ""
|
|
try:
|
|
response = await self._stream_with_widgets(
|
|
msg.content,
|
|
on_thinking_cb=_send_thinking if self._channel_send_thinking else None,
|
|
on_todo_cb=_send_todo,
|
|
on_media_cb=_send_media,
|
|
skip_user_message=True,
|
|
channel_hitl_fn=_channel_hitl_prompt,
|
|
)
|
|
except Exception as exc:
|
|
response = f"Error: {exc}"
|
|
self._append_system(f"Error: {exc}", style="red")
|
|
finally:
|
|
self._busy = False
|
|
self._render_status()
|
|
prompt_widget.disabled = False
|
|
prompt_widget.focus()
|
|
|
|
_set_channel_response(msg.msg_id, response)
|
|
self._append_system(
|
|
f"[{msg.channel_type}: Replied to {msg.sender}]",
|
|
style="dim",
|
|
)
|
|
|
|
# ── Clipboard (copy on mouse select) ─────────────────
|
|
|
|
def on_mouse_up(self, event: MouseUp) -> None:
|
|
"""Copy mouse-selected text to clipboard on release."""
|
|
copy_selection_to_clipboard(self)
|
|
|
|
# ── Input handling ─────────────────────────────────────
|
|
|
|
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
text = event.value.strip()
|
|
prompt = self.query_one("#prompt", Input)
|
|
prompt.value = ""
|
|
if not text:
|
|
return
|
|
|
|
if self._busy:
|
|
# Queue the message to send after current turn finishes
|
|
self._queued_messages.append(text)
|
|
self._render_queue_indicator()
|
|
return
|
|
|
|
if text.startswith("/"):
|
|
self._hide_completions()
|
|
await self._handle_command(text)
|
|
return
|
|
|
|
self._history_suggester.append_entry(text)
|
|
self._run_task = asyncio.ensure_future(self._run_turn(text))
|
|
|
|
def on_input_changed(self, event: Input.Changed) -> None:
|
|
text = event.value
|
|
comp_widget = self.query_one("#completions", Static)
|
|
if text.startswith("/"):
|
|
prefix = text.lower()
|
|
matches = [
|
|
(cmd, desc)
|
|
for cmd, desc in _TUI_SLASH_COMMANDS
|
|
if cmd.startswith(prefix)
|
|
]
|
|
if len(matches) == 1 and matches[0][0] == prefix:
|
|
self._hide_completions()
|
|
return
|
|
if matches:
|
|
self._comp_items = matches
|
|
self._comp_index = -1
|
|
self._render_completions()
|
|
comp_widget.display = True
|
|
return
|
|
self._hide_completions()
|
|
|
|
def _render_queue_indicator(self) -> None:
|
|
"""Render the queued messages indicator above the input."""
|
|
queued_w = self.query_one("#queued-message", Static)
|
|
if not self._queued_messages:
|
|
queued_w.display = False
|
|
return
|
|
parts: list[tuple[str, str]] = []
|
|
for msg in self._queued_messages:
|
|
preview = msg if len(msg) <= 60 else msg[:57] + "\u2026"
|
|
parts.append(("\u276f ", "bold"))
|
|
parts.append((preview, ""))
|
|
parts.append(("\n", ""))
|
|
parts.append((" [press up to edit last \u00b7 esc to cancel last]", "dim italic"))
|
|
queued_w.update(Text.assemble(*parts))
|
|
queued_w.display = True
|
|
|
|
def action_cancel_queued(self) -> None:
|
|
"""Cancel the last queued message on Esc."""
|
|
# Delegate to ApprovalWidget if it has focus
|
|
focused = self.focused
|
|
if focused is not None:
|
|
from .widgets.approval_widget import ApprovalWidget
|
|
if isinstance(focused, ApprovalWidget):
|
|
focused.action_select_reject()
|
|
return
|
|
if self._queued_messages:
|
|
self._queued_messages.pop()
|
|
self._render_queue_indicator()
|
|
|
|
def action_edit_queued(self) -> None:
|
|
"""Pop the last queued message back into input for editing."""
|
|
# Skip if an ApprovalWidget has focus — let it handle up/down
|
|
focused = self.focused
|
|
if focused is not None:
|
|
from .widgets.approval_widget import ApprovalWidget
|
|
if isinstance(focused, ApprovalWidget):
|
|
focused.action_move_up()
|
|
return
|
|
if self._queued_messages:
|
|
last = self._queued_messages.pop()
|
|
prompt = self.query_one("#prompt", Input)
|
|
prompt.value = last
|
|
prompt.cursor_position = len(prompt.value)
|
|
prompt.focus()
|
|
self._render_queue_indicator()
|
|
|
|
def on_key(self, event: Any) -> None:
|
|
comp_widget = self.query_one("#completions", Static)
|
|
if not (comp_widget.display and self._comp_items):
|
|
return
|
|
|
|
if event.key in ("tab", "down"):
|
|
event.prevent_default()
|
|
event.stop()
|
|
self._comp_index = (self._comp_index + 1) % len(self._comp_items)
|
|
self._apply_selected_completion()
|
|
elif event.key == "up":
|
|
event.prevent_default()
|
|
event.stop()
|
|
self._comp_index = (self._comp_index - 1) % len(self._comp_items)
|
|
self._apply_selected_completion()
|
|
elif event.key == "enter" and self._comp_index >= 0:
|
|
event.prevent_default()
|
|
event.stop()
|
|
self._hide_completions()
|
|
|
|
def _apply_selected_completion(self) -> None:
|
|
selected_cmd = self._comp_items[self._comp_index][0]
|
|
prompt = self.query_one("#prompt", Input)
|
|
prompt.value = selected_cmd + " "
|
|
prompt.cursor_position = len(prompt.value)
|
|
self._render_completions()
|
|
|
|
def _hide_completions(self) -> None:
|
|
self._comp_items = []
|
|
self._comp_index = -1
|
|
comp_widget = self.query_one("#completions", Static)
|
|
comp_widget.display = False
|
|
|
|
def _render_completions(self) -> None:
|
|
comp_text = Text()
|
|
for i, (cmd, desc) in enumerate(self._comp_items):
|
|
if i == self._comp_index:
|
|
comp_text.append("\u25b8 ", style="bold")
|
|
comp_text.append(f"{cmd:<22}", style="bold")
|
|
comp_text.append(desc, style="bold")
|
|
else:
|
|
comp_text.append(" ", style="#888888")
|
|
comp_text.append(f"{cmd:<22}", style="#888888")
|
|
comp_text.append(desc, style="#888888")
|
|
if i < len(self._comp_items) - 1:
|
|
comp_text.append("\n")
|
|
self.query_one("#completions", Static).update(comp_text)
|
|
|
|
# ── Slash commands ─────────────────────────────────────
|
|
|
|
async def _handle_command(self, command: str) -> None:
|
|
cmd, _, arg = command.strip().partition(" ")
|
|
cmd = cmd.lower()
|
|
arg = arg.strip()
|
|
|
|
if cmd in ("/exit", "/quit", "/q"):
|
|
self.action_request_quit()
|
|
return
|
|
|
|
if cmd == "/help":
|
|
help_text = Text("Available commands:\n", style="bold")
|
|
for hcmd, hdesc in _TUI_SLASH_COMMANDS:
|
|
help_text.append(f" {hcmd:<22}", style="cyan")
|
|
help_text.append(f"{hdesc}\n", style="dim")
|
|
self._mount_renderable(help_text)
|
|
return
|
|
|
|
if cmd == "/current":
|
|
from .. import paths
|
|
|
|
self._append_system(f"Thread: {self._conversation_tid}", style="dim")
|
|
if self._workspace_dir:
|
|
self._append_system(
|
|
f"Workspace: {_shorten_path(self._workspace_dir)}",
|
|
style="dim",
|
|
)
|
|
memory_path = _shorten_path(str(paths.MEMORY_DIR))
|
|
if memory_path:
|
|
self._append_system(f"Memory dir: {memory_path}", style="dim")
|
|
self._append_system("UI: textual", style="dim")
|
|
return
|
|
|
|
if cmd == "/new":
|
|
# Clear all widgets except #welcome
|
|
container = self.query_one("#chat", VerticalScroll)
|
|
welcome = self.query_one("#welcome", Static)
|
|
for child in list(container.children):
|
|
if child is not welcome:
|
|
await child.remove()
|
|
|
|
if not workspace_fixed:
|
|
self._workspace_dir = create_session_workspace(run_name)
|
|
self._conversation_tid = generate_thread_id()
|
|
self._agent = load_agent(
|
|
workspace_dir=self._workspace_dir,
|
|
checkpointer=self._checkpointer,
|
|
)
|
|
if _channels_is_running():
|
|
_ch_mod._cli_agent = self._agent
|
|
_ch_mod._cli_thread_id = self._conversation_tid
|
|
self._render_welcome()
|
|
self._render_status()
|
|
self._append_system(f"New session: {self._conversation_tid}", style="green")
|
|
return
|
|
|
|
if cmd == "/clear":
|
|
container = self.query_one("#chat", VerticalScroll)
|
|
welcome = self.query_one("#welcome", Static)
|
|
for child in list(container.children):
|
|
if child is not welcome:
|
|
await child.remove()
|
|
return
|
|
|
|
if cmd == "/threads":
|
|
await self._cmd_threads()
|
|
return
|
|
|
|
if cmd == "/resume":
|
|
await self._cmd_resume(arg)
|
|
return
|
|
|
|
if cmd == "/delete":
|
|
await self._cmd_delete(arg)
|
|
return
|
|
|
|
if cmd == "/skills":
|
|
self._cmd_skills()
|
|
return
|
|
|
|
if cmd == "/install-skill":
|
|
self._cmd_install_skill(arg)
|
|
return
|
|
|
|
if cmd == "/uninstall-skill":
|
|
self._cmd_uninstall_skill(arg)
|
|
return
|
|
|
|
if cmd == "/mcp":
|
|
self._cmd_mcp(arg)
|
|
return
|
|
|
|
if cmd == "/channel":
|
|
self._cmd_channel(arg)
|
|
return
|
|
|
|
self._append_system(f"Unknown command: {command}", style="yellow")
|
|
|
|
async def _resolve_thread_id(self, prefix: str) -> str | None:
|
|
if await thread_exists(prefix):
|
|
return prefix
|
|
|
|
similar = await find_similar_threads(prefix)
|
|
if len(similar) == 1:
|
|
return similar[0]
|
|
|
|
if len(similar) > 1:
|
|
self._append_system(
|
|
f"Ambiguous thread ID '{prefix}'. Use a longer prefix.",
|
|
style="yellow",
|
|
)
|
|
for thread in similar:
|
|
self._append_system(f" - {thread}", style="dim")
|
|
return None
|
|
|
|
self._append_system(f"Thread '{prefix}' not found.", style="red")
|
|
return None
|
|
|
|
async def _cmd_threads(self) -> None:
|
|
threads = await list_threads(
|
|
limit=0,
|
|
include_message_count=True,
|
|
include_preview=True,
|
|
)
|
|
if not threads:
|
|
self._append_system("No saved sessions.", style="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 thread in threads:
|
|
thread_id_value = thread["thread_id"]
|
|
marker = " *" if thread_id_value == self._conversation_tid else ""
|
|
table.add_row(
|
|
f"{thread_id_value}{marker}",
|
|
thread.get("preview", "") or "",
|
|
str(thread.get("message_count", 0)),
|
|
thread.get("model", "") or "",
|
|
_format_relative_time(thread.get("updated_at")),
|
|
)
|
|
self._mount_renderable(table)
|
|
|
|
async def _render_history(self, thread_id_value: str) -> None:
|
|
"""Render conversation history from a saved thread."""
|
|
messages = await get_thread_messages(thread_id_value)
|
|
if not messages:
|
|
return
|
|
|
|
container = self.query_one("#chat", VerticalScroll)
|
|
await container.mount(SystemMessage("── Conversation history ──", msg_style="dim"))
|
|
for message in messages:
|
|
msg_type = getattr(message, "type", None)
|
|
content = getattr(message, "content", "") or ""
|
|
if isinstance(content, list):
|
|
parts = [
|
|
block.get("text", "")
|
|
for block in content
|
|
if isinstance(block, dict) and block.get("type") == "text"
|
|
]
|
|
content = " ".join(parts) if parts else ""
|
|
content = content.strip()
|
|
if len(content) > 220:
|
|
content = content[:220] + "..."
|
|
|
|
if msg_type == "human":
|
|
await container.mount(UserMessage(content))
|
|
elif msg_type == "ai":
|
|
tool_calls = getattr(message, "tool_calls", None) or []
|
|
if content:
|
|
await container.mount(
|
|
Static(Text(content, style="dim"))
|
|
)
|
|
if tool_calls:
|
|
names = [tc.get("name", "?") for tc in tool_calls]
|
|
await container.mount(
|
|
Static(Text(f" \u25b6 {', '.join(names)}", style="dim italic"))
|
|
)
|
|
await container.mount(SystemMessage("── End of history ──", msg_style="dim"))
|
|
container.scroll_end(animate=False)
|
|
|
|
async def _cmd_resume(self, arg: str) -> None:
|
|
if not arg:
|
|
self._append_system("Usage: /resume <thread-id-prefix>", style="yellow")
|
|
await self._cmd_threads()
|
|
return
|
|
|
|
resolved = await self._resolve_thread_id(arg)
|
|
if not resolved:
|
|
return
|
|
|
|
metadata = await get_thread_metadata(resolved)
|
|
restored_workspace = (metadata or {}).get("workspace_dir", "")
|
|
if restored_workspace:
|
|
self._workspace_dir = restored_workspace
|
|
|
|
self._conversation_tid = resolved
|
|
self._agent = load_agent(
|
|
workspace_dir=self._workspace_dir,
|
|
checkpointer=self._checkpointer,
|
|
)
|
|
if _channels_is_running():
|
|
_ch_mod._cli_agent = self._agent
|
|
_ch_mod._cli_thread_id = self._conversation_tid
|
|
self._render_welcome()
|
|
self._render_status()
|
|
self._append_system(f"Resumed session: {resolved}", style="green")
|
|
await self._render_history(resolved)
|
|
|
|
async def _cmd_delete(self, arg: str) -> None:
|
|
if not arg:
|
|
self._append_system("Usage: /delete <thread-id-prefix>", style="yellow")
|
|
return
|
|
|
|
resolved = await self._resolve_thread_id(arg)
|
|
if not resolved:
|
|
return
|
|
|
|
if resolved == self._conversation_tid:
|
|
self._append_system(
|
|
"Cannot delete the current session.",
|
|
style="yellow",
|
|
)
|
|
return
|
|
|
|
deleted = await delete_thread(resolved)
|
|
if deleted:
|
|
self._append_system(f"Deleted session {resolved}.", style="green")
|
|
else:
|
|
self._append_system(f"Session {resolved} not found.", style="red")
|
|
|
|
def _cmd_skills(self) -> None:
|
|
from ..tools.skills_manager import list_skills
|
|
from ..paths import USER_SKILLS_DIR
|
|
|
|
skills = list_skills(include_system=True)
|
|
if not skills:
|
|
self._append_system("No skills available.", style="dim")
|
|
self._append_system("Install with: /install-skill <path-or-url>", style="dim")
|
|
self._append_system(f"Skills directory: {_shorten_path(str(USER_SKILLS_DIR))}", style="dim")
|
|
return
|
|
|
|
user_skills = [s for s in skills if s.source == "user"]
|
|
system_skills = [s for s in skills if s.source == "system"]
|
|
|
|
if user_skills:
|
|
table = Table(title=f"User Skills ({len(user_skills)})", show_header=True)
|
|
table.add_column("Name", style="green")
|
|
table.add_column("Description", style="dim")
|
|
for s in user_skills:
|
|
table.add_row(s.name, s.description)
|
|
self._mount_renderable(table)
|
|
|
|
if system_skills:
|
|
table = Table(title=f"Built-in Skills ({len(system_skills)})", show_header=True)
|
|
table.add_column("Name", style="cyan")
|
|
table.add_column("Description", style="dim")
|
|
for s in system_skills:
|
|
table.add_row(s.name, s.description)
|
|
self._mount_renderable(table)
|
|
|
|
self._append_system(
|
|
f"User skills folder: {_shorten_path(str(USER_SKILLS_DIR))}",
|
|
style="dim",
|
|
)
|
|
|
|
def _cmd_install_skill(self, source: str) -> None:
|
|
from ..tools.skills_manager import install_skill
|
|
|
|
if not source:
|
|
self._append_system("Usage: /install-skill <path-or-url>", style="yellow")
|
|
self._append_system("Examples:", style="dim")
|
|
self._append_system(" /install-skill ./my-skill", style="dim")
|
|
self._append_system(
|
|
" /install-skill https://github.com/user/repo/tree/main/skill-name",
|
|
style="dim",
|
|
)
|
|
self._append_system(" /install-skill user/repo@skill-name", style="dim")
|
|
return
|
|
|
|
self._append_system(f"Installing skill from: {source}", style="dim")
|
|
result = install_skill(source)
|
|
if result["success"]:
|
|
self._append_system(f"Installed: {result['name']}", style="green")
|
|
self._append_system(
|
|
f"Description: {result.get('description', '(none)')}",
|
|
style="dim",
|
|
)
|
|
self._append_system(f"Path: {_shorten_path(result['path'])}", style="dim")
|
|
self._append_system("Reload with /new to apply.", style="dim")
|
|
else:
|
|
self._append_system(f"Failed: {result['error']}", style="red")
|
|
|
|
def _cmd_uninstall_skill(self, name: str) -> None:
|
|
from ..tools.skills_manager import uninstall_skill
|
|
|
|
if not name:
|
|
self._append_system("Usage: /uninstall-skill <skill-name>", style="yellow")
|
|
self._append_system("Use /skills to see installed skills.", style="dim")
|
|
return
|
|
|
|
result = uninstall_skill(name)
|
|
if result["success"]:
|
|
self._append_system(f"Uninstalled: {name}", style="green")
|
|
self._append_system("Reload with /new to apply.", style="dim")
|
|
else:
|
|
self._append_system(f"Failed: {result['error']}", style="red")
|
|
|
|
def _cmd_mcp(self, args: str) -> None:
|
|
args = args.strip()
|
|
if not args or args == "list":
|
|
self._mcp_list()
|
|
return
|
|
|
|
parts = args.split(maxsplit=1)
|
|
subcmd = parts[0].lower()
|
|
subargs = parts[1] if len(parts) > 1 else ""
|
|
|
|
if subcmd == "config":
|
|
self._mcp_config(subargs.strip())
|
|
elif subcmd == "add":
|
|
self._mcp_add(subargs)
|
|
elif subcmd == "edit":
|
|
self._mcp_edit(subargs)
|
|
elif subcmd == "remove":
|
|
self._mcp_remove(subargs.strip())
|
|
else:
|
|
self._append_system("MCP commands:", style="bold")
|
|
self._append_system(" /mcp List configured servers", style="dim")
|
|
self._append_system(" /mcp list List configured servers", style="dim")
|
|
self._append_system(" /mcp config Show detailed server config", style="dim")
|
|
self._append_system(" /mcp add ... Add a server", style="dim")
|
|
self._append_system(" /mcp edit ... Edit an existing server", style="dim")
|
|
self._append_system(" /mcp remove ... Remove a server", style="dim")
|
|
|
|
def _mcp_list(self) -> None:
|
|
from ..mcp import load_mcp_config
|
|
from ..mcp.client import USER_MCP_CONFIG
|
|
|
|
config = load_mcp_config()
|
|
if not config:
|
|
self._append_system("No MCP servers configured.", style="dim")
|
|
self._append_system("Add one with: /mcp add <name> <command-or-url> [args...]", style="dim")
|
|
return
|
|
|
|
table = Table(title="MCP Servers", show_header=True)
|
|
table.add_column("Server", style="cyan")
|
|
table.add_column("Transport", style="green")
|
|
table.add_column("Tools", style="yellow")
|
|
table.add_column("Expose To", style="magenta")
|
|
|
|
for name, server in config.items():
|
|
transport = server.get("transport", "?")
|
|
tools = server.get("tools")
|
|
tools_str = ", ".join(tools) if tools else "(all)"
|
|
expose_to = server.get("expose_to", ["main"])
|
|
if isinstance(expose_to, str):
|
|
expose_to = [expose_to]
|
|
expose_str = ", ".join(expose_to)
|
|
table.add_row(name, transport, tools_str, expose_str)
|
|
|
|
self._mount_renderable(table)
|
|
self._append_system(f"Config file: {USER_MCP_CONFIG}", style="dim")
|
|
|
|
def _mcp_config(self, name: str) -> None:
|
|
from ..mcp import load_mcp_config
|
|
from ..mcp.client import USER_MCP_CONFIG
|
|
|
|
config = load_mcp_config()
|
|
if not config:
|
|
self._append_system("No MCP servers configured.", style="dim")
|
|
return
|
|
|
|
if name and name not in config:
|
|
self._append_system(f"Server not found: {name}", style="red")
|
|
return
|
|
|
|
servers = {name: config[name]} if name else config
|
|
for srv_name, srv in servers.items():
|
|
table = Table(title=f"MCP Server: {srv_name}", show_header=True, title_style="bold cyan")
|
|
table.add_column("Setting", style="cyan")
|
|
table.add_column("Value")
|
|
table.add_row("transport", str(srv.get("transport", "(not set)")))
|
|
if srv.get("command"):
|
|
table.add_row("command", str(srv["command"]))
|
|
if srv.get("args"):
|
|
table.add_row("args", " ".join(str(a) for a in srv["args"]))
|
|
if srv.get("url"):
|
|
table.add_row("url", str(srv["url"]))
|
|
if srv.get("headers"):
|
|
for k, v in srv["headers"].items():
|
|
table.add_row(f"header: {k}", str(v))
|
|
if srv.get("env"):
|
|
for k, v in srv["env"].items():
|
|
table.add_row(f"env: {k}", str(v))
|
|
tools = srv.get("tools")
|
|
table.add_row("tools", ", ".join(tools) if tools else "(all)")
|
|
expose_to = srv.get("expose_to", ["main"])
|
|
if isinstance(expose_to, str):
|
|
expose_to = [expose_to]
|
|
table.add_row("expose_to", ", ".join(expose_to))
|
|
self._mount_renderable(table)
|
|
|
|
self._append_system(f"Config file: {USER_MCP_CONFIG}", style="dim")
|
|
|
|
def _mcp_add(self, args_str: str) -> None:
|
|
from ..mcp import parse_mcp_add_args, add_mcp_server
|
|
|
|
if not args_str.strip():
|
|
self._append_system("Usage: /mcp add <name> <command-or-url> [args...]", style="yellow")
|
|
return
|
|
|
|
try:
|
|
tokens = shlex.split(args_str)
|
|
kwargs = parse_mcp_add_args(tokens)
|
|
entry = add_mcp_server(**kwargs)
|
|
self._append_system(
|
|
f"Added MCP server: {kwargs['name']} ({entry['transport']})",
|
|
style="green",
|
|
)
|
|
self._append_system("Reload with /new to apply.", style="dim")
|
|
except ValueError as exc:
|
|
self._append_system(f"{exc}", style="red")
|
|
|
|
def _mcp_edit(self, args_str: str) -> None:
|
|
from ..mcp import parse_mcp_edit_args, edit_mcp_server
|
|
|
|
if not args_str.strip():
|
|
self._append_system(
|
|
"Usage: /mcp edit <name> --<field> <value> ...",
|
|
style="yellow",
|
|
)
|
|
return
|
|
|
|
try:
|
|
tokens = shlex.split(args_str)
|
|
name, fields = parse_mcp_edit_args(tokens)
|
|
if not fields:
|
|
self._append_system(
|
|
"No fields to edit. Use --transport, --command, --url, --tools, --expose-to, etc.",
|
|
style="red",
|
|
)
|
|
return
|
|
edit_mcp_server(name, **fields)
|
|
self._append_system(f"Updated MCP server: {name}", style="green")
|
|
for k, v in fields.items():
|
|
self._append_system(f" {k}: {v}", style="dim")
|
|
self._append_system("Reload with /new to apply.", style="dim")
|
|
except (KeyError, ValueError) as exc:
|
|
self._append_system(f"{exc}", style="red")
|
|
|
|
def _mcp_remove(self, name: str) -> None:
|
|
from ..mcp import remove_mcp_server
|
|
|
|
if not name:
|
|
self._append_system("Usage: /mcp remove <name>", style="yellow")
|
|
return
|
|
|
|
if remove_mcp_server(name):
|
|
self._append_system(f"Removed MCP server: {name}", style="green")
|
|
self._append_system("Reload with /new to apply.", style="dim")
|
|
else:
|
|
self._append_system(f"Server not found: {name}", style="red")
|
|
|
|
def _cmd_channel(self, args: str) -> None:
|
|
"""Handle /channel command — start, stop, or show status."""
|
|
from ..config import load_config
|
|
from .channel import (
|
|
_add_channel_to_running_bus,
|
|
_start_channels_bus_mode,
|
|
_channels_running_list,
|
|
)
|
|
|
|
args = args.strip().lower() if args else ""
|
|
|
|
if args == "status" or (not args and _channels_is_running()):
|
|
running = _channels_running_list()
|
|
if running and _ch_mod._manager:
|
|
detailed = _ch_mod._manager.get_detailed_status()
|
|
table = Table(
|
|
title="Channel Status", show_header=True, expand=False,
|
|
)
|
|
table.add_column("Channel", style="cyan")
|
|
table.add_column("Status")
|
|
table.add_column("Uptime", style="dim")
|
|
table.add_column("Rx", justify="right")
|
|
table.add_column("Tx", justify="right")
|
|
for ch_name in running:
|
|
info = detailed.get(ch_name, {})
|
|
secs = info.get("uptime_seconds", 0)
|
|
mins, s = divmod(int(secs), 60)
|
|
hours, mins = divmod(mins, 60)
|
|
uptime = (
|
|
f"{hours}h{mins:02d}m" if hours else f"{mins}m{s:02d}s"
|
|
)
|
|
rx = str(info.get("received", 0))
|
|
tx = str(info.get("sent", 0))
|
|
table.add_row(
|
|
ch_name, "[green]running[/green]", uptime, rx, tx,
|
|
)
|
|
self._mount_renderable(table)
|
|
else:
|
|
self._append_system("No channel running", style="dim")
|
|
return
|
|
|
|
if args.startswith("stop"):
|
|
stop_type = args[len("stop"):].strip() or None
|
|
if not _channels_is_running():
|
|
self._append_system("No channel running", style="dim")
|
|
return
|
|
if stop_type:
|
|
if not _channels_is_running(stop_type):
|
|
self._append_system(
|
|
f"{stop_type} is not running", style="dim",
|
|
)
|
|
return
|
|
_channels_stop(stop_type)
|
|
if stop_type in self._started_channel_types:
|
|
self._started_channel_types.remove(stop_type)
|
|
self._append_system(f"{stop_type} stopped", style="dim")
|
|
else:
|
|
running = _channels_running_list()
|
|
_channels_stop()
|
|
self._started_channel_types.clear()
|
|
self._append_system(
|
|
f"{', '.join(running)} stopped", style="dim",
|
|
)
|
|
self._render_welcome()
|
|
return
|
|
|
|
# Start channel(s)
|
|
app_config = load_config()
|
|
channel_type = args if args else (
|
|
app_config.channel_enabled if app_config else ""
|
|
)
|
|
if not channel_type:
|
|
self._append_system("No channel configured.", style="yellow")
|
|
self._append_system(
|
|
"Run EvoSci onboard or specify: /channel telegram",
|
|
style="dim",
|
|
)
|
|
return
|
|
|
|
requested = [
|
|
t.strip() for t in channel_type.split(",") if t.strip()
|
|
]
|
|
|
|
if _channels_is_running():
|
|
running = _channels_running_list()
|
|
results: list[tuple[str, bool, str]] = []
|
|
for ct in requested:
|
|
if ct in running:
|
|
results.append((ct, True, "already running"))
|
|
else:
|
|
try:
|
|
_add_channel_to_running_bus(
|
|
ct,
|
|
app_config,
|
|
send_thinking=self._channel_send_thinking,
|
|
)
|
|
results.append((ct, True, "connected (bus)"))
|
|
except Exception as e:
|
|
results.append((ct, False, str(e)))
|
|
else:
|
|
_ch_mod._cli_agent = self._agent
|
|
_ch_mod._cli_thread_id = self._conversation_tid
|
|
original = app_config.channel_enabled
|
|
app_config.channel_enabled = channel_type
|
|
try:
|
|
_start_channels_bus_mode(
|
|
app_config,
|
|
self._agent,
|
|
self._conversation_tid,
|
|
send_thinking=self._channel_send_thinking,
|
|
)
|
|
results = [
|
|
(ct, True, "connected (bus)") for ct in requested
|
|
]
|
|
except Exception as e:
|
|
results = [(ct, False, str(e)) for ct in requested]
|
|
finally:
|
|
app_config.channel_enabled = original
|
|
|
|
for ct, ok, _ in results:
|
|
if ok and ct not in self._started_channel_types:
|
|
self._started_channel_types.append(ct)
|
|
|
|
self._render_channel_results(results)
|
|
self._render_welcome()
|
|
|
|
def _render_channel_results(
|
|
self, results: list[tuple[str, bool, str]],
|
|
) -> None:
|
|
for name, ok, detail in results:
|
|
if ok:
|
|
self._append_system(
|
|
f"\u25cf {name} {detail}", style="green",
|
|
)
|
|
else:
|
|
self._append_system(
|
|
f"\u2717 {name} {detail}", style="yellow",
|
|
)
|
|
|
|
# ── Quit handling ──────────────────────────────────────
|
|
|
|
def action_request_quit(self) -> None:
|
|
if self._busy:
|
|
# Clear all queued messages on interrupt
|
|
if self._queued_messages:
|
|
self._queued_messages.clear()
|
|
self._render_queue_indicator()
|
|
if self._run_task is not None and not self._run_task.done():
|
|
self._run_task.cancel()
|
|
else:
|
|
# Edge case: busy but no task — force reset
|
|
self._busy = False
|
|
self.query_one("#prompt", Input).focus()
|
|
self._render_status()
|
|
self._append_system("Interrupted.", style="yellow")
|
|
return
|
|
# Clean up channels
|
|
if self._channel_timer is not None:
|
|
self._channel_timer.stop()
|
|
self._channel_timer = None
|
|
self._started_channel_types.clear()
|
|
if _channels_is_running():
|
|
try:
|
|
_channels_stop()
|
|
except Exception:
|
|
pass
|
|
self.exit()
|
|
|
|
# ── Banner & status ────────────────────────────────────
|
|
|
|
def _render_welcome(self) -> None:
|
|
channels_info: list[tuple[str, bool, str]] | None = None
|
|
try:
|
|
running = _channels_running_list()
|
|
started = self._started_channel_types
|
|
if running or started:
|
|
all_types = list(dict.fromkeys(running + started))
|
|
channels_info = [
|
|
(ct, True, "connected (bus)") for ct in all_types
|
|
]
|
|
else:
|
|
from ..config import load_config
|
|
|
|
cfg = load_config()
|
|
if cfg and cfg.channel_enabled:
|
|
types = [
|
|
t.strip()
|
|
for t in cfg.channel_enabled.split(",")
|
|
if t.strip()
|
|
]
|
|
if types:
|
|
channels_info = [
|
|
(ct, False, "configured") for ct in types
|
|
]
|
|
except Exception:
|
|
pass
|
|
|
|
welcome = self.query_one("#welcome", Static)
|
|
welcome.update(
|
|
_build_welcome_banner(
|
|
thread_id=self._conversation_tid,
|
|
workspace_dir=self._workspace_dir,
|
|
mode=mode,
|
|
model=model,
|
|
provider=provider,
|
|
ui_backend="tui",
|
|
channels=channels_info,
|
|
)
|
|
)
|
|
|
|
def _render_status(self) -> None:
|
|
status = self.query_one("#status", Static)
|
|
if self._busy:
|
|
left = "vibe researching..."
|
|
left_style = "bold #f59e0b"
|
|
else:
|
|
left = "/help for commands"
|
|
left_style = "#f59e0b"
|
|
|
|
status.update(
|
|
Text.assemble(
|
|
(left, left_style),
|
|
(" ", ""),
|
|
("EvoScientist", "dim"),
|
|
)
|
|
)
|
|
|
|
# ── Media forwarding helper (module-level) ──────────────
|
|
|
|
_MEDIA_EXTENSIONS = {
|
|
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg",
|
|
".pdf", ".mp3", ".wav", ".mp4",
|
|
}
|
|
|
|
def _forward_media_to_channel(
|
|
state: StreamState,
|
|
tool_name: str,
|
|
media_sent: set[str],
|
|
send_fn: Any,
|
|
) -> None:
|
|
"""Check tool calls for media files and forward to channel."""
|
|
import os
|
|
from ..paths import resolve_virtual_path
|
|
|
|
arg_key = "path" if tool_name == "write_file" else "file_path"
|
|
for tc in reversed(state.tool_calls):
|
|
if tc.get("name") == tool_name:
|
|
p = tc.get("args", {}).get(arg_key, "")
|
|
if not p:
|
|
p = tc.get("args", {}).get("path", "")
|
|
if p and p not in media_sent:
|
|
ext = os.path.splitext(p)[1].lower()
|
|
if ext in _MEDIA_EXTENSIONS:
|
|
real_path = str(resolve_virtual_path(p))
|
|
if not os.path.isfile(real_path) and os.path.isfile(p):
|
|
real_path = p
|
|
if os.path.isfile(real_path):
|
|
media_sent.add(p)
|
|
send_fn(real_path)
|
|
break
|
|
|
|
# ── Entry point ─────────────────────────────────────────
|
|
|
|
async def _amain() -> None:
|
|
async with get_checkpointer() as checkpointer:
|
|
effective_workspace = workspace_dir
|
|
effective_thread_id = thread_id
|
|
resumed = False
|
|
resume_warning = ""
|
|
if thread_id:
|
|
if await thread_exists(thread_id):
|
|
resolved = thread_id
|
|
else:
|
|
similar = await find_similar_threads(thread_id)
|
|
resolved = similar[0] if len(similar) == 1 else None
|
|
if resolved:
|
|
meta = await get_thread_metadata(resolved)
|
|
ws = (meta or {}).get("workspace_dir", "")
|
|
if ws:
|
|
effective_workspace = ws
|
|
effective_thread_id = resolved
|
|
resumed = True
|
|
else:
|
|
resume_warning = (
|
|
f"Thread '{thread_id}' not found. Starting new session."
|
|
)
|
|
if not effective_thread_id:
|
|
effective_thread_id = generate_thread_id()
|
|
|
|
initial_agent = load_agent(
|
|
workspace_dir=effective_workspace,
|
|
checkpointer=checkpointer,
|
|
)
|
|
app = EvoTextualInteractiveApp(
|
|
agent=initial_agent,
|
|
thread_id_value=effective_thread_id,
|
|
workspace=effective_workspace,
|
|
checkpointer=checkpointer,
|
|
channel_send_thinking_value=channel_send_thinking,
|
|
resumed=resumed,
|
|
resume_warning=resume_warning,
|
|
)
|
|
await app.run_async()
|
|
|
|
import nest_asyncio # type: ignore[import-untyped]
|
|
|
|
nest_asyncio.apply()
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
except RuntimeError:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
loop.run_until_complete(_amain())
|