Files
EvoScientist-Multi/EvoScientist/cli/tui_interactive.py
T
dinos 8b1451cdda refactor(runtime): centralize async bridges under an owned runtime (#376)
* feat(runtime): add application-scoped async runtime

* refactor(cli): use owned runtime for session stats

* refactor(onboard): use the owned async runtime

* docs(runtime): record async bridge ownership

* refactor(middleware): keep sync fallback synchronous

* refactor(mcp): load tools on an owned runtime

* refactor(cli): share owned runtime across entry points

* refactor(channels): make inbound sync bridge explicit

* refactor(stream): run Rich streaming on owned runtime

* chore(runtime): remove nest-asyncio dependency

* refactor(asyncio): require active loops in async code

* docs(runtime): document final event loop ownership

* fix(stream): cancel stalled owned streams

* fix(cli): recover cleanly from stream cancellation

* fix(runtime): drain executor work before shutdown

* fix(runtime): terminate cancelled shell process trees

* fix(models): let fallback bypass selector failures

* fix(cli): reset interrupt handling between turns

* docs: rm implementation spec

* fix(serve): cancel active turns during shutdown

* fix(runtime): protect settlement from waiter cancellation

* fix(backends): reject empty shell commands

* fix(runtime): terminate descendants after shell exit

* fix(mcp): keep standalone discovery off channel loop

* fix(cli): own and settle interactive prompt cancellation

* fix(serve): keep channel sends off runtime loop

* fix(stream): scope cancel context to iterator steps

* refactor(serve): require the owned async runtime

* fix(channels): keep interactive sends off runtime loop

* fix(selector): surface fallback without log spam

* test(runtime): normalize Windows shell marker

* fix(cli): serialize interactive session turns

* fix(shell): bound output drain after termination

* fix(ui): do not retry owned runtime failures

* fix(shell): allow signal-safe registry reentry

* fix(shell): avoid terminating reused process ids

* fix(channels): preserve streaming send order

* fix(cli): report runtime shutdown timeouts cleanly

* fix(mcp): guide async callers to async loader

* docs(runtime): clarify reserved async bridge APIs

* fix(runtime): bound code interpreter cleanup

* test(shell): use active Python for drain regression

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-07-27 14:17:57 +01:00

3670 lines
160 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 sys
import threading
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any, ClassVar
from rich.console import Group
from rich.text import Text
import EvoScientist.cli.channel as _ch_mod
from EvoScientist.cli.widgets.thread_selector import ThreadPickerWidget
from ..commands import Command, CommandContext
from ..commands import manager as cmd_manager
from ..gateway import (
GraphGateway,
GraphTarget,
RunRequest,
RuntimeGateways,
create_runtime_gateways,
)
from ..paths import DATA_DIR
from ..sessions import get_checkpointer
from ..stream.state import ResearchPhase, StreamState
from ._agent_loader import BackgroundAgentLoader, MCPProgressTracker
from ._constants import (
DANGEROUS_BANNER_LABEL,
DANGEROUS_BANNER_MESSAGE,
LOGO_GRADIENT,
LOGO_LINES,
WELCOME_SLOGANS,
build_metadata,
)
from .async_notifier import (
AsyncTasksState,
consume_notifications,
has_pending_notifications,
read_async_tasks_from_gateway,
)
from .channel import (
ChannelMessage,
_auto_start_channel,
_channels_is_running,
_channels_stop,
_message_queue,
_set_channel_response,
dispatch_channel_slash_command,
get_channel_startup_results,
)
from .file_mentions import complete_file_mention, resolve_file_mentions
from .history_suggester import HistorySuggester
from .status_bar import (
STATUS_BAR_BG,
STATUS_DIM,
STATUS_GOOD,
STATUS_HINT_BUSY,
STATUS_HINT_IDLE,
STATUS_HINT_WRITING,
apply_assistant_text_to_snapshot,
apply_user_text_to_snapshot,
build_session_status_snapshot,
build_status_text,
format_duration_compact,
make_empty_status_snapshot,
make_usage_status_snapshot,
)
if TYPE_CHECKING:
from ..runtime import AsyncRuntime
_channel_logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from langgraph.graph.state import CompiledStateGraph
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)
async def _auto_start_channel_in_worker(
agent: Any,
thread_id: str,
config: Any,
*,
send_thinking: bool,
runtime: Any,
stop_requested: threading.Event,
) -> list[tuple[str, bool, str]]:
"""Run blocking channel startup without occupying the TUI event loop."""
def _start() -> list[tuple[str, bool, str]]:
try:
return _auto_start_channel(
agent,
thread_id,
config,
send_thinking=send_thinking,
runtime=runtime,
)
finally:
if stop_requested.is_set():
_channels_stop(runtime=runtime)
worker = asyncio.create_task(asyncio.to_thread(_start))
try:
return await asyncio.shield(worker)
except asyncio.CancelledError:
stop_requested.set()
try:
await worker
except Exception:
_channel_logger.debug(
"Channel startup worker failed during cancellation",
exc_info=True,
)
raise
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, strict=False):
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")
# Directory line
import os
effective_dir = workspace_dir or os.getcwd()
home = os.path.expanduser("~")
dir_display = (
effective_dir.replace(home, "~", 1)
if effective_dir.startswith(home)
else effective_dir
)
info.append("\n ", style="dim")
info.append("Directory: ", style="dim")
info.append(dir_display, style="magenta")
_nl_key = "Option+Enter" if sys.platform == "darwin" else "Ctrl+J"
info.append("\n Enter ", style="#ffe082")
info.append("send", style="#ffe082 bold")
info.append(f" \u2022 {_nl_key} ", style="#ffe082")
info.append("newline", style="#ffe082 bold")
info.append(" \u2022 Type ", style="#ffe082")
info.append("/", style="#ffe082 bold")
info.append(" for commands", style="#ffe082")
info.append(" \u2022 ", style="#ffe082")
info.append("@ files", style="#ffe082 bold")
info.append(" \u2022 Ctrl+C ", style="#ffe082")
info.append("interrupt", style="#ffe082 bold")
try:
from ..config import get_effective_config
if get_effective_config().dangerous_mode:
info.append(
f"\n \u26a0 {DANGEROUS_BANNER_LABEL}", style="bold white on red"
)
info.append(f" {DANGEROUS_BANNER_MESSAGE}", style="bold red")
except Exception:
pass
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_done, n_visible = state.visible_tool_counts()
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
_SUMMARY_CONTINUATION_EVENTS = {
"summarization_start",
"summarization",
"usage_stats",
}
async def _sync_tui_command_completion(
app: Any,
ctx: CommandContext,
original_agent: Any,
cmd: Command,
) -> None:
"""Adopt successful command-side state changes back into the TUI app."""
if app._exiting:
return
agent_swapped = ctx.agent is not None and ctx.agent is not original_agent
if agent_swapped:
from ..EvoScientist import _ensure_config
app._agent_loader.adopt(ctx.agent)
cfg = _ensure_config()
update_model = getattr(app, "update_status_after_model_change", None)
if callable(update_model):
update_model(cfg.model, cfg.provider)
# Rebind the runtime whenever the agent OR thread_id may have moved
# — ``/new`` and ``/resume`` rotate ``app._conversation_tid``
# without swapping the agent, and the bus expects both to stay in
# sync (matches the serve-mode hook contract).
if _channels_is_running():
runtime_agent = ctx.agent if ctx.agent is not None else app._agent_loader.agent
if runtime_agent is not None:
app._channel_runtime.bind(runtime_agent, app._conversation_tid)
await app._refresh_status_snapshot(reset_streaming_text=True)
def _should_finalize_active_summarization(event_type: str) -> bool:
"""Return whether an active summary panel should stop for this event."""
return bool(event_type) and event_type not in _SUMMARY_CONTINUATION_EVENTS
def _response_after_narration(response_text: str, narrated_response_end: int) -> str:
"""Return the response suffix that has not already been shown inline."""
return response_text[max(0, narrated_response_end) :]
def _strip_trailing_placeholder_ellipsis(text: str) -> str:
"""Remove the standalone streaming placeholder suffix from final TUI text."""
clean = text.strip()
while clean.endswith("\n...") or clean.rstrip() == "...":
clean = clean.rstrip().removesuffix("...").rstrip()
return clean
def _stopped_response_after_narration(
previous_text: str,
narrated_response_end: int,
) -> tuple[str, str, str]:
"""Return display-current, display-stopped, and full stopped response text."""
from ..stream.display import build_stopped_response_text
display_previous = _response_after_narration(previous_text, narrated_response_end)
display_current, display_stopped = build_stopped_response_text(display_previous)
_, full_stopped = build_stopped_response_text(previous_text)
return display_current, display_stopped, full_stopped
# (kind, payload, item_index): kind is "header"/"sep"/"item"; payload is the
# category name for headers or the candidate for items; item_index is the
# candidate's position in the source list (-1 for non-item rows).
_CompletionRow = tuple[str, Any, int]
def _build_completion_rows(items: list[Any]) -> list[_CompletionRow]:
"""Flatten completion candidates into render rows with category headers."""
rows: list[_CompletionRow] = []
last_cat = ""
for i, candidate in enumerate(items):
cat = getattr(candidate, "category", "")
if cat and cat != last_cat:
if last_cat:
rows.append(("sep", "", -1))
rows.append(("header", cat, -1))
last_cat = cat
rows.append(("item", candidate, i))
return rows
def _window_completion_rows(
rows: list[_CompletionRow],
selected: int,
max_rows: int,
) -> tuple[list[_CompletionRow], int, int]:
"""Slice *rows* to a window of at most *max_rows* total display lines.
The window always contains the selected item (top of the list when
nothing is selected) and reserves one line per overflow indicator.
Returns ``(visible_rows, hidden_items_above, hidden_items_below)``.
"""
max_rows = max(max_rows, 5)
if len(rows) <= max_rows:
return list(rows), 0, 0
sel_row = 0
if selected >= 0:
for r, (kind, _payload, idx) in enumerate(rows):
if kind == "item" and idx == selected:
sel_row = r
break
# Center the selection; centering keeps it clear of the indicator
# lines that replace the window's edge rows when content is clipped.
start = min(max(sel_row - max_rows // 2, 0), len(rows) - max_rows)
end = start + max_rows
content_start = start + (1 if start > 0 else 0)
content_end = end - (1 if end < len(rows) else 0)
above = sum(1 for kind, _p, _i in rows[:content_start] if kind == "item")
below = sum(1 for kind, _p, _i in rows[content_end:] if kind == "item")
return rows[content_start:content_end], above, below
def _render_completion_text(items: list[Any], selected: int, max_rows: int) -> Text:
"""Render the completion popup content bounded to *max_rows* lines."""
rows = _build_completion_rows(items)
visible, above, below = _window_completion_rows(rows, selected, max_rows)
# Blank separator lines are cosmetic — drop them at the window edges.
while visible and visible[0][0] == "sep":
visible = visible[1:]
while visible and visible[-1][0] == "sep":
visible = visible[:-1]
lines: list[Text] = []
if above:
lines.append(Text(f" ↑ {above} more", style="dim italic"))
for kind, payload, idx in visible:
if kind == "sep":
lines.append(Text())
elif kind == "header":
lines.append(Text(f" {payload}", style="bold #6b7280"))
elif idx == selected:
lines.append(
Text.assemble(
(" ▸ ", "bold"),
(f"{payload.text:<28}", "bold"),
(payload.description, "bold"),
)
)
else:
lines.append(
Text.assemble(
(" ", "#888888"),
(f"{payload.text:<28}", "#888888"),
(payload.description, "#888888"),
)
)
if below:
lines.append(Text(f" ↓ {below} more", style="dim italic"))
return Text("\n").join(lines)
# Hard cap on popup lines so the popup never dwarfs the chat area
# (mainstream CLI behavior); matches the pre-#354 max-height.
_COMPLETION_MAX_VISIBLE_ROWS = 15
# Rows kept free for the input row, status bar and a slice of chat. On
# terminals shorter than ~17 rows the 5-row floor wins over this
# reservation — a smaller popup would be unusable.
_COMPLETION_RESERVED_ROWS = 12
def _completion_row_budget(height: int) -> int:
"""Popup line budget for a terminal of *height* rows."""
if height <= 0:
return _COMPLETION_MAX_VISIBLE_ROWS
return max(5, min(height - _COMPLETION_RESERVED_ROWS, _COMPLETION_MAX_VISIBLE_ROWS))
# Textual converts rich Text to Content and drops rich no_wrap/overflow
# attributes, so line cropping must be enforced here in CSS.
_COMPLETIONS_CSS = """
#completions {
display: none;
height: auto;
background: #1e1f26;
padding: 0 1;
border-bottom: solid #0284c7;
text-wrap: nowrap;
text-overflow: ellipsis;
}
"""
def _normalize_chat_scroll(container: Any) -> None:
"""Repair the chat scroll state after the popup resized the viewport.
Textual's compositor recomputes ``scroll_y`` for anchored containers
bypassing the validator, so when the popup hides and the content fits
again, ``scroll_y`` can go negative — the scrollbar then renders as if
scrolled to the bottom while the content sits at the top (issue #301
family). Runs after refresh so sizes are current.
"""
# force=True: with the content fitting, the scrollbar is hidden and
# allow_vertical_scroll is False — an unforced scroll_home would
# silently no-op and leave the negative scroll_y in place.
if container.is_anchored:
if container.max_scroll_y <= 0:
container.anchor(False)
container.scroll_home(animate=False, immediate=True, force=True)
elif container.scroll_y < 0:
container.scroll_home(animate=False, immediate=True, force=True)
# Resync the scrollbar thumb: watch_scroll_y skips the update while
# the scrollbar is hidden (or when the compositor wrote scroll_y via
# set_reactive), so a stale position survives until the scrollbar
# reappears — rendering as "scrolled to bottom" at the top.
scrollbar = getattr(container, "vertical_scrollbar", None)
if scrollbar is not None and scrollbar.position != container.scroll_y:
scrollbar.position = container.scroll_y
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],
config: Any | None = None,
async_runtime: AsyncRuntime | None = None,
) -> None:
"""Run full-screen Textual interactive chat loop."""
if config is None:
from ..config import get_effective_config
config = get_effective_config()
# One frontend event sink for the whole TUI session — injected into the
# agent's middleware (write side) and the local gateway's streaming path
# (read side). The fallback-notice display is bound to the App's
# _append_system once the App exists (on_mount); tool-selection needs no
# display hook (its widget is mounted from the stream event).
from ..stream.sink import SessionEventSink
event_sink = SessionEventSink()
runtime_gateways = create_runtime_gateways(events=event_sink)
graph_gateway = runtime_gateways.graph_gateway
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.widget import Widget
from textual.widgets import Static
from .clipboard import copy_selection_to_clipboard, get_clipboard_text
from .widgets import (
AssistantMessage,
CompactingWidget,
LoadingWidget,
MCPLoaderWidget,
SubAgentWidget,
SummarizationWidget,
SystemMessage,
ThinkingWidget,
TodoWidget,
ToolCallWidget,
UsageWidget,
UserMessage,
)
from .widgets.chat_input import ChatTextArea
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."""
@property
def supports_interactive(self) -> bool:
return True
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 0 2;
background: #16161a;
}
#input-row {
height: auto;
min-height: 3;
max-height: 10;
border: solid #0284c7;
background: #1e1f26;
padding: 0 1;
}
#input-cursor {
width: 2;
content-align: center middle;
color: #0284c7;
text-style: bold;
}
#prompt {
width: 1fr;
min-height: 1;
max-height: 8;
border: none;
background: transparent;
color: #e5e7eb;
}
#prompt:focus {
border: none;
}
#queued-message {
display: none;
height: auto;
background: #1e1f26;
padding: 0 2;
color: #9ca3af;
}
"""
+ _COMPLETIONS_CSS
+ """
#status {
height: 1;
min-height: 1;
background: #171a20;
color: #cbd5e1;
padding: 0 1;
}
"""
)
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "request_quit", "Quit", show=False, priority=True),
Binding("ctrl+v", "paste_clipboard", "Paste", show=False),
Binding("tab", "tab_complete", show=False, priority=True),
Binding("up", "edit_queued", show=False, priority=True),
Binding("down", "down_delegate", show=False, priority=True),
Binding("escape", "cancel_queued", show=False, priority=True),
]
def __init__(
self,
*,
thread_id_value: str,
workspace: str | None,
checkpointer: Any,
runtime_gateways: RuntimeGateways,
channel_send_thinking_value: bool = True,
resumed: bool = False,
resume_warning: str = "",
) -> None:
super().__init__()
self._progress_tracker = MCPProgressTracker()
self._agent_loader = BackgroundAgentLoader(
load_agent,
on_progress=self._on_mcp_progress,
on_success=self._on_agent_load_success,
on_failure=self._on_agent_load_failure,
)
self._mcp_loader_widget: Any = None
self._conversation_tid = thread_id_value
self._workspace_dir = workspace
self._checkpointer = checkpointer
self._runtime_gateways = runtime_gateways
self._channel_send_thinking = channel_send_thinking_value
self._resumed = resumed
self._resume_warning = resume_warning
self._channel_timer: Any = None
self._channel_start_results: list[tuple[str, bool, str]] = []
self._channel_start_stop = threading.Event()
self._busy = False
self._notification_consuming: bool = (
False # prevent overlapping consume coroutines
)
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 = []
self._comp_index: int = -1
self._comp_last_height: int = 0
self._comp_base: str = ""
self._hitl_auto_approve: bool = False
self._approval_future: asyncio.Future | None = None
self._ask_user_future: asyncio.Future | None = None
self._picker_future: asyncio.Future | None = None
self._browser_future: asyncio.Future | None = None
self._mcp_browser_future: asyncio.Future | None = None
self._model_picker_future: asyncio.Future | None = None
self._history_suggester = HistorySuggester(DATA_DIR / "history")
self._history_index: int = -1 # -1 = not browsing history
self._history_saved_input: str = "" # saved current input before browsing
self._background_tasks: set[asyncio.Task] = set()
from ..commands.base import ChannelRuntime
self._channel_runtime = ChannelRuntime()
self._quit_pending: bool = False
self._exiting: bool = False
self._current_model: str | None = model
self._current_provider: str | None = provider
self._status_started_at = datetime.now()
self._status_base_snapshot = make_empty_status_snapshot(self._current_model)
self._status_snapshot = self._status_base_snapshot
self._status_streaming_text = ""
self._status_last_input_tokens: int | None = None
self._status_phase: ResearchPhase = ResearchPhase.IDLE
self._turn_started_at: datetime | None = None
self._compacting_widget: CompactingWidget | None = None
self._chat_following: bool = True
self._new_content_below: bool = False
# ── Background agent / MCP loading ───────────────────
def _on_mcp_progress(self, event: str, server: str, detail: str) -> None:
"""Bridge worker-thread progress events to the Textual loop."""
if event not in {"start", "success", "error"}:
return
try:
self.call_from_thread(self._apply_mcp_progress, event, server, detail)
except Exception:
pass
def _apply_mcp_progress(self, event: str, server: str, detail: str) -> None:
"""Update tracker + widget on the Textual thread."""
state = self._progress_tracker.record(event, server, detail)
if state is None:
return
widget = self._mcp_loader_widget
if widget is None or widget.dismissed:
self._mcp_loader_widget = None
return
widget.update_server(server, state, detail)
def _on_agent_load_success(self, agent: Any) -> None:
if _channels_is_running():
self._channel_runtime.bind(agent, self._conversation_tid)
self._finish_loader_widget()
self._render_status()
def _on_agent_load_failure(self, exc: BaseException) -> None:
self._append_system(f"Agent failed to load: {exc}", style="red")
self._finish_loader_widget()
def _start_background_agent_load(self, workspace: str | None) -> None:
self._progress_tracker.prime()
self._mount_mcp_loader_widget()
self._agent_loader.start(
workspace_dir=workspace,
checkpointer=self._checkpointer,
events=self._runtime_gateways.graph_gateway.events,
)
def _mount_mcp_loader_widget(self) -> None:
if not self._progress_tracker.progress:
return
if self._mcp_loader_widget is not None:
try:
self._mcp_loader_widget.remove()
except Exception:
pass
self._mcp_loader_widget = None
widget = MCPLoaderWidget(list(self._progress_tracker.progress.keys()))
try:
shell = self.query_one("#input-shell", Container)
children = list(shell.children)
if children:
shell.mount(widget, before=children[0])
else:
shell.mount(widget)
except Exception:
# Compose hasn't happened yet — the mount will be retried
# from ``on_mount`` once the DOM is ready.
return
self._mcp_loader_widget = widget
def _finish_loader_widget(self) -> None:
"""Call ``mark_finished`` and clear the ref if self-dismissed."""
widget = self._mcp_loader_widget
if widget is None:
return
try:
widget.mark_finished()
except Exception:
pass
if widget.dismissed:
self._mcp_loader_widget = None
async def _await_agent_ready(self) -> CompiledStateGraph:
"""Await the agent load, auto-retrying on cold-start or failure."""
if self._agent_loader.needs_restart:
self._start_background_agent_load(self._workspace_dir)
return await self._agent_loader.await_ready()
def _graph_gateway(self) -> GraphGateway:
return self._runtime_gateways.graph_gateway
# ── CommandUI implementation ─────────────────────────
def append_system(self, text: str, style: str = "dim") -> None:
self._append_system(text, style)
def mount_renderable(self, renderable: Any) -> None:
self._mount_renderable(renderable)
async def start_compacting_indicator(self) -> None:
await self._start_compacting_indicator()
async def stop_compacting_indicator(self) -> None:
await self._stop_compacting_indicator()
async def wait_for_thread_pick(
self, threads: list[dict], current_thread: str, title: str
) -> str | None:
from .widgets.thread_selector import ThreadPickerWidget
container = self.query_one("#chat", VerticalScroll)
picker = ThreadPickerWidget(
threads,
current_thread=current_thread,
title=title,
)
await container.mount(picker)
self._anchor_chat(container)
picker.focus()
return await self._wait_for_thread_pick(picker)
async def wait_for_skill_browse(
self, index: list[dict], installed_names: set[str], pre_filter_tag: str
) -> list[str] | None:
from .widgets.skill_browser import SkillBrowserWidget
container = self.query_one("#chat", VerticalScroll)
browser = SkillBrowserWidget(
index,
installed_names,
pre_filter_tag=pre_filter_tag,
)
await container.mount(browser)
self._anchor_chat(container)
browser.focus()
return await self._wait_for_skill_browse(browser)
async def wait_for_mcp_browse(
self, servers: list, installed_names: set[str], pre_filter_tag: str
) -> list | None:
from .widgets.mcp_browser import MCPBrowserWidget
container = self.query_one("#chat", VerticalScroll)
browser = MCPBrowserWidget(
servers,
installed_names,
pre_filter_tag=pre_filter_tag,
)
await container.mount(browser)
self._anchor_chat(container)
browser.focus()
return await self._wait_for_mcp_browse(browser)
async def wait_for_model_pick(
self,
entries: list[tuple[str, str, str]],
current_model: str | None,
current_provider: str | None,
) -> tuple[str, str] | None:
from .widgets.model_picker import ModelPickerWidget
container = self.query_one("#chat", VerticalScroll)
picker = ModelPickerWidget(
entries,
current_model=current_model,
current_provider=current_provider,
)
await container.mount(picker)
self._anchor_chat(container)
picker.focus()
return await self._wait_for_model_pick(picker)
def clear_chat(self) -> None:
container = self.query_one("#chat", VerticalScroll)
welcome = self.query_one("#welcome", Static)
for child in list(container.children):
if child is not welcome:
child.remove()
# Issue #301: unconditionally release the anchor and pin to the
# top after wiping. The children removed above are laid out
# asynchronously, so a ``max_scroll_y`` check at this moment is
# stale — relying on it (via ``_anchor_chat``) can re-engage the
# anchor and let Textual's compositor push ``scroll_y`` negative
# once the new (smaller) content lays out. Always reset.
self._release_anchor_and_pin_top(container)
self._chat_following = True
self._new_content_below = False
def request_quit(self) -> None:
self.action_request_quit()
async def start_new_session(self) -> None:
# Clear all widgets except #welcome
self.clear_chat()
_ch_mod.forget_channel_origin(self._conversation_tid)
if not workspace_fixed:
self._workspace_dir = create_session_workspace(run_name)
self._conversation_tid = (
await self._runtime_gateways.graph_gateway.create_thread(
GraphTarget(workspace_dir=self._workspace_dir)
)
)
# Background reload: next user message awaits it.
self._start_background_agent_load(self._workspace_dir)
self._status_started_at = datetime.now()
self._status_base_snapshot = make_empty_status_snapshot(self._current_model)
self._status_snapshot = self._status_base_snapshot
self._status_streaming_text = ""
self._status_last_input_tokens = None
self._render_welcome()
self._render_status()
refresh_task = asyncio.create_task(self._refresh_status_snapshot())
self._background_tasks.add(refresh_task)
refresh_task.add_done_callback(self._background_tasks.discard)
self.append_system(f"New session: {self._conversation_tid}", style="green")
self._append_pending_skill_proposals_notice()
async def handle_session_resume(
self, thread_id: str, workspace_dir: str | None = None
) -> None:
if workspace_dir:
# Mirror the Rich CLI fix: when a /resume restores a thread
# whose workspace differs from the one the langgraph dev
# subprocess was launched with, background workers and
# deployed sub-agents would otherwise keep operating on the
# previous workspace. Sync the subprocess to the new workspace;
# the manager auto-detects the change and restarts when needed.
# Run in a worker thread so the Textual event loop keeps
# refreshing the UI during the up-to-60s wait, and show a live
# timer widget (like /compact) so the user sees progress
# instead of a frozen static line.
#
# ``self._workspace_dir`` is mutated AFTER mismatch checks so
# WorkspaceMismatchError leaves the session pointing at the
# existing workspace. Other sync failures resume locally in
# the TUI while background workers may be unavailable.
from ..langgraph_dev.manager import WorkspaceMismatchError
from .commands import _sync_background_agent_server_workspace
from .widgets.workspace_sync_widget import WorkspaceSyncWidget
sync_widget = WorkspaceSyncWidget()
container = self.query_one("#chat", VerticalScroll)
await container.mount(sync_widget)
container.scroll_end(animate=False)
try:
await _sync_background_agent_server_workspace(
config,
workspace_dir=workspace_dir,
)
except WorkspaceMismatchError as exc:
# Another EvoSci process owns the langgraph dev for a
# different workspace. Abort the resume without mutating
# session state. Raise so command UIs, including channel
# UI, report failure instead of continuing with
# success/history output.
raise RuntimeError(str(exc)) from exc
except Exception:
_channel_logger.warning(
"Failed to sync background agent server for resumed "
"workspace %s; continuing resume in degraded mode",
workspace_dir,
exc_info=True,
)
self.append_system(
"Background agent server sync failed; resumed local "
"session, but async subagents and EvoMemory workers "
"may be unavailable.",
style="yellow",
)
finally:
await sync_widget.cleanup()
self._workspace_dir = workspace_dir
if thread_id != self._conversation_tid:
# Only drop the origin on a real thread change — resuming the
# already-active thread must keep its live origin so a later
# async-notifier turn still forwards to the channel.
_ch_mod.forget_channel_origin(self._conversation_tid)
self._conversation_tid = thread_id
# Background reload: history renders immediately; next turn awaits.
self._start_background_agent_load(self._workspace_dir)
self._status_started_at = datetime.now()
self._status_base_snapshot = make_empty_status_snapshot(self._current_model)
self._status_snapshot = self._status_base_snapshot
self._status_streaming_text = ""
self._status_last_input_tokens = None
self._render_welcome()
await self._refresh_status_snapshot()
self._render_status()
self.append_system(f"Resumed session: {thread_id}", style="green")
await self._render_history(thread_id)
self._append_pending_skill_proposals_notice()
async def flush(self) -> None:
"""No-op for TUI, messages are already delivered incrementally."""
pass
# ── 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 ChatTextArea(
placeholder="Type message (/ for commands)",
id="prompt",
)
yield Static("", id="status")
def on_mount(self) -> None:
# Bind the session sink's fallback-notice display so model-fallback
# messages appear as SystemMessage widgets in the chat container.
# ``event_sink`` is the concrete SessionEventSink created by the
# enclosing factory — the same instance the gateway carries.
event_sink.set_fallback_display(
lambda text, style: self._append_system(text, style)
)
self._render_welcome()
self._render_status()
self.set_interval(1.0, self._render_status)
# Kick off agent construction in the background so the TUI
# appears instantly; MCP progress shows up in the status bar.
if self._agent_loader.agent is None and self._agent_loader.task is None:
self._start_background_agent_load(self._workspace_dir)
refresh_task = asyncio.create_task(self._refresh_status_snapshot())
self._background_tasks.add(refresh_task)
refresh_task.add_done_callback(self._background_tasks.discard)
prompt = self.query_one("#prompt", ChatTextArea)
prompt.before_submit = self._handle_completion_enter
prompt.focus()
# Show resume status
if self._resume_warning:
self._append_system(self._resume_warning, style="yellow")
self._append_pending_skill_proposals_notice()
elif self._resumed:
self._append_system(
f"Resumed session: {self._conversation_tid}",
style="green",
)
self.call_later(
lambda: asyncio.ensure_future(
self._render_history_then_pending_notice(self._conversation_tid)
)
)
else:
self._append_pending_skill_proposals_notice()
# Startup notifications
self.notify(
"EvoScientist is your research buddy.\n"
"Tell it about your taste before cooking some meal!",
severity="warning",
timeout=10,
)
self.run_worker(
self._check_for_updates, exclusive=True, group="update-check"
)
# Auto-start channels — needs the agent, so defer to after load
async def _deferred_start_channels():
try:
await self._await_agent_ready()
except Exception:
_channel_logger.debug(
"Skipping channel auto-start because agent load failed",
exc_info=True,
)
return
await self._start_channels()
ch_task = asyncio.create_task(_deferred_start_channels())
self._background_tasks.add(ch_task)
ch_task.add_done_callback(self._background_tasks.discard)
def on_resize(self, event: Any) -> None:
"""Re-window the completion popup for the new terminal height."""
try:
comp_widget = self.query_one("#completions", Static)
except Exception:
return
if comp_widget.display and self._comp_items:
# Deferred: this handler can run before the base App
# handler updates self.size with the new dimensions.
self.call_after_refresh(self._render_completions)
# ── Update check ──────────────────────────────────────
async def _check_for_updates(self) -> None:
"""Check PyPI for a newer EvoScientist version and notify."""
try:
from ..update_check import _installed_version, is_update_available
available, latest = await asyncio.to_thread(is_update_available)
if available:
current = _installed_version()
self.notify(
f"Update available: v{latest} (current: v{current}).\n"
"Run: uv tool upgrade EvoScientist",
severity="information",
timeout=15,
)
except Exception:
_channel_logger.debug("Background update check failed", exc_info=True)
# ── Channel integration ────────────────────────────────
async def _start_channels(self) -> None:
"""Auto-start channels if enabled in config."""
try:
from ..config import load_config
cfg = await asyncio.to_thread(load_config)
if cfg and cfg.channel_enabled and not _channels_is_running():
results = await _auto_start_channel_in_worker(
self._agent_loader.agent,
self._conversation_tid,
cfg,
send_thinking=self._channel_send_thinking,
runtime=self._channel_runtime,
stop_requested=self._channel_start_stop,
)
if self._exiting:
return
current_agent = self._agent_loader.agent
if current_agent is not None and _channels_is_running():
self._channel_runtime.bind(
current_agent,
self._conversation_tid,
)
self._channel_start_results = results
self._render_welcome()
except asyncio.CancelledError:
self._channel_start_stop.set()
raise
except Exception as e:
_channel_logger.debug(f"Channel auto-start failed: {e}")
finally:
if (
not self._exiting
and not self._channel_start_stop.is_set()
and self._channel_timer is None
):
self._channel_timer = self.set_interval(
0.1,
self._poll_channel_queue,
)
def _poll_channel_queue(self) -> None:
"""Poll the channel + notification queues (every 100ms)."""
try:
msg = _message_queue.get_nowait()
except queue.Empty:
msg = None
if msg is not None:
if self._busy:
_message_queue.put(msg)
return
self.call_later(
lambda m=msg: asyncio.ensure_future(
self._process_channel_message(m)
)
)
return
# Notification path (only when idle and NOT already consuming).
# _notification_consuming is set synchronously at the schedule point
# so that the next poll tick cannot schedule a second consumer before
# the first one has a chance to run (fixes overlapping-turn bug).
if (
has_pending_notifications(self._conversation_tid)
and not self._busy
and not self._notification_consuming
):
self._notification_consuming = True
self.call_later(
lambda: asyncio.ensure_future(self._consume_notifications_tui())
)
async def _consume_notifications_tui(self) -> None:
"""Drain the notification queue and inject a synthetic agent turn.
Wraps the consume call in a swallowing try/except (Fix #4) so an
exception inside dedup/inject doesn't bubble out of the
``asyncio.ensure_future(...)`` scheduled by ``_poll_channel_queue``
and silently kill notification + channel dispatch.
"""
target_tid = self._conversation_tid
try:
try:
await consume_notifications(
run_message=lambda text, notifs: self._inject_notification_tui(
text, notifs, target_thread_id=target_tid
),
read_async_tasks_state=lambda: self._read_async_tasks_tui(
target_tid
),
current_thread_id=target_tid,
)
except Exception:
import logging
logging.getLogger(__name__).warning(
"async-notifier consume failed (TUI)", exc_info=True
)
finally:
# Clear the guard flag regardless of success or exception so
# future notifications can schedule a new consume coroutine.
self._notification_consuming = False
async def _inject_notification_tui(
self,
text: str,
notifs: list,
*,
target_thread_id: str | None = None,
) -> None:
"""Run a synthetic user turn for the batched async-task notification.
Renders one compact tool-result-style line per task (matching the
Rich CLI aesthetic) instead of a single breadcrumb. The LLM still
receives the full ``format_batch_message`` text; only the visual
representation changes.
Args:
text: Full structured LLM message from ``format_batch_message``.
notifs: Survivor notification list for per-task visual rendering.
target_thread_id: Pinned thread id for the synthetic turn —
forwarded to ``_run_turn`` so a mid-consume ``/new`` cannot
misroute the notification into a different thread.
"""
from EvoScientist.cli.async_notifier import format_notification_lines
for line_text, line_style in format_notification_lines(notifs):
self._append_system(line_text, style=line_style)
# Fire-and-forget the turn as an INDEPENDENT task — matches the
# keyboard input path (line ~2113). Queue-triggered turns that
# `await _run_turn` from inside a nested call_later chain don't
# get viewport-follow during streaming (only after completion).
# Mark busy synchronously so the next poll tick doesn't re-enter.
self._busy = True
effective_tid = target_thread_id or self._conversation_tid
async def _run_and_publish() -> None:
response = await self._run_turn(
text,
skip_user_message=True,
resolve_mentions=False,
thread_id_override=target_thread_id,
)
if _ch_mod.publish_to_channel_origin(effective_tid, response or ""):
# Mirror a normal channel turn's closing "Replied to" line
# so the forwarded notification reads as terminated.
_origin = _ch_mod.get_channel_origin(effective_tid)
if _origin is not None:
self._append_system(
f"[{_origin.channel_type}: Replied to "
f"{_origin.sender or _origin.chat_id}]",
style="dim",
)
self._run_task = asyncio.ensure_future(_run_and_publish())
async def _read_async_tasks_tui(self, target_thread_id: str) -> AsyncTasksState:
"""Read async_tasks from agent state for dedup, against a frozen tid.
``target_thread_id`` is captured by ``_consume_notifications_tui`` at
the start of the consume call so a mid-consume thread switch cannot
make us read the wrong thread's state.
"""
agent = self._agent_loader.agent
if agent is None:
return {}
try:
return await read_async_tasks_from_gateway(
self._graph_gateway(),
GraphTarget(
local_graph=agent,
workspace_dir=self._workspace_dir,
),
target_thread_id,
)
except Exception:
return {}
async def _on_channel_cmd_completed(
self,
ctx: CommandContext,
original_agent: Any,
cmd: Command,
) -> None:
await _sync_tui_command_completion(self, ctx, original_agent, cmd)
# ── Widget helpers ─────────────────────────────────────
def _anchor_chat(self, container: VerticalScroll | None = None) -> None:
"""Re-engage the scroll anchor so the viewport pins to the bottom.
Only engages the anchor when the chat content actually overflows
the viewport. Textual's compositor (see ``textual._compositor``)
recomputes ``scroll_y`` for anchored widgets via ``set_reactive``
— which bypasses the validator — so when anchored content is
shorter than the viewport, ``scroll_y`` goes negative and the
welcome banner drops below the visible region (issue #301).
When content fits, we instead release any prior anchor and pin
the viewport to the top.
"""
if container is None:
container = self.query_one("#chat", VerticalScroll)
if container.max_scroll_y > 0:
container.anchor()
else:
self._release_anchor_and_pin_top(container)
def _release_anchor_and_pin_top(self, container: VerticalScroll) -> None:
"""Release any active anchor and force the viewport to the top.
See ``_anchor_chat`` for why this is needed: Textual's compositor
bypasses ``validate_scroll_y`` for anchored widgets, so a leftover
anchor with content shorter than the viewport lets ``scroll_y``
go negative and pushes the welcome banner out of view (issue #301).
"""
container.anchor(False)
# force=True: with content fitting, the scrollbar is hidden and
# allow_vertical_scroll is False — unforced scroll_home no-ops.
container.scroll_home(animate=False, immediate=True, force=True)
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))
def _mount_renderable(self, renderable: Any) -> None:
"""Mount a Rich renderable (e.g. Table) as a Static widget."""
container = self.query_one("#chat", VerticalScroll)
try:
from .commands import CompactSummaryRenderable
from .widgets.compact_summary_widget import CompactSummaryWidget
except Exception:
CompactSummaryRenderable = None # type: ignore[assignment]
if CompactSummaryRenderable is not None and isinstance(
renderable, CompactSummaryRenderable
):
container.mount(CompactSummaryWidget(renderable.summary_text))
else:
container.mount(Static(renderable))
async def _start_compacting_indicator(self) -> None:
"""Show a transient timer widget while /compact is running."""
await self._stop_compacting_indicator()
container = self.query_one("#chat", VerticalScroll)
widget = CompactingWidget()
self._compacting_widget = widget
await container.mount(widget)
async def _stop_compacting_indicator(self) -> None:
"""Remove the transient /compact progress widget, if present."""
widget = self._compacting_widget
self._compacting_widget = None
if widget is not None:
try:
await widget.cleanup()
except Exception:
try:
await widget.remove()
except Exception:
pass
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_running_loop().create_future()
try:
return await asyncio.wait_for(self._approval_future, timeout=300)
except (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)
async def _wait_for_ask_user(self, ask_w) -> dict:
"""Wait for the interactive ask_user widget to resolve via Future.
Returns ``{"answers": [...], "status": "answered"}``
or ``{"status": "cancelled"}``.
"""
loop = asyncio.get_running_loop()
self._ask_user_future = loop.create_future()
ask_w.set_future(self._ask_user_future)
try:
result = await asyncio.wait_for(self._ask_user_future, timeout=300)
except (TimeoutError, asyncio.CancelledError):
ask_w.action_cancel()
return {"status": "cancelled"}
finally:
self._ask_user_future = None
if not isinstance(result, dict):
return {"status": "cancelled"}
result_type = result.get("type", "")
if result_type == "answered":
return {"answers": result.get("answers", []), "status": "answered"}
return {"status": "cancelled"}
async def _wait_for_thread_pick(
self, picker_widget: ThreadPickerWidget
) -> str | None:
"""Wait for user to pick a thread from ThreadPickerWidget.
Returns the selected thread_id, or ``None`` on cancel/timeout.
"""
self._picker_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._picker_future, timeout=120)
except (TimeoutError, asyncio.CancelledError):
return None
finally:
self._picker_future = None
try:
picker_widget.remove()
except Exception:
pass
self.query_one("#prompt", ChatTextArea).focus()
def on_thread_picker_widget_picked(self, event) -> None: # type: ignore[override]
"""Handle ThreadPickerWidget.Picked message."""
if self._picker_future and not self._picker_future.done():
self._picker_future.set_result(event.thread_id)
def on_thread_picker_widget_cancelled(self, event) -> None: # type: ignore[override]
"""Handle ThreadPickerWidget.Cancelled message."""
if self._picker_future and not self._picker_future.done():
self._picker_future.set_result(None)
async def _wait_for_skill_browse(self, browser_widget) -> list[str] | None:
"""Wait for user to complete skill browsing.
Returns list of install sources, or None on cancel/timeout.
"""
self._browser_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._browser_future, timeout=300)
except (TimeoutError, asyncio.CancelledError):
return None
finally:
self._browser_future = None
try:
browser_widget.remove()
except Exception:
pass
self.query_one("#prompt", ChatTextArea).focus()
def on_skill_browser_widget_confirmed(self, event) -> None: # type: ignore[override]
"""Handle SkillBrowserWidget.Confirmed message."""
if self._browser_future and not self._browser_future.done():
self._browser_future.set_result(event.install_sources)
def on_skill_browser_widget_cancelled(self, event) -> None: # type: ignore[override]
"""Handle SkillBrowserWidget.Cancelled message."""
if self._browser_future and not self._browser_future.done():
self._browser_future.set_result(None)
# ── MCP browser ───────────────────────────────────────
async def _wait_for_mcp_browse(self, browser_widget) -> list | None:
"""Wait for user to complete MCP server browsing."""
self._mcp_browser_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._mcp_browser_future, timeout=300)
except (TimeoutError, asyncio.CancelledError):
return None
finally:
self._mcp_browser_future = None
try:
browser_widget.remove()
except Exception:
pass
self.query_one("#prompt", ChatTextArea).focus()
def on_mcpbrowser_widget_confirmed(self, event) -> None: # type: ignore[override]
"""Handle MCPBrowserWidget.Confirmed message."""
if self._mcp_browser_future and not self._mcp_browser_future.done():
self._mcp_browser_future.set_result(event.entries)
def on_mcpbrowser_widget_cancelled(self, event) -> None: # type: ignore[override]
"""Handle MCPBrowserWidget.Cancelled message."""
if self._mcp_browser_future and not self._mcp_browser_future.done():
self._mcp_browser_future.set_result(None)
async def _wait_for_model_pick(self, picker_widget) -> tuple[str, str] | None:
"""Wait for user to pick a model from ModelPickerWidget.
Returns ``(name, provider)`` or ``None`` on cancel/timeout.
"""
self._model_picker_future = asyncio.get_running_loop().create_future()
try:
return await asyncio.wait_for(self._model_picker_future, timeout=120)
except (TimeoutError, asyncio.CancelledError):
return None
finally:
self._model_picker_future = None
try:
picker_widget.remove()
except Exception:
_channel_logger.debug("model picker cleanup failed", exc_info=True)
self.query_one("#prompt", ChatTextArea).focus()
def on_model_picker_widget_picked(self, event) -> None: # type: ignore[override]
"""Handle ModelPickerWidget.Picked message."""
if self._model_picker_future and not self._model_picker_future.done():
self._model_picker_future.set_result((event.name, event.provider))
def on_model_picker_widget_cancelled(self, event) -> None: # type: ignore[override]
"""Handle ModelPickerWidget.Cancelled message."""
if self._model_picker_future and not self._model_picker_future.done():
self._model_picker_future.set_result(None)
# ── Streaming core ─────────────────────────────────────
async def _stream_with_widgets(
self,
user_text: str,
*,
display_text: str | None = None,
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,
file_warnings: list[str] | None = None,
channel_hitl_fn: Callable[[list], list[dict] | None] | None = None,
channel_ask_user_fn: Callable[[dict], dict] | None = None,
cancel_scope: str | None = None,
thread_id_override: str | None = None,
) -> str:
"""Stream agent events and mount widgets. Returns response text.
Shared by ``_run_turn`` (interactive) and
``_process_channel_message`` (channel).
Args:
display_text: Text to show in UserMessage widget. When
``None`` (default), falls back to *user_text*. This
allows callers to show the original user input while
sending the resolved (e.g. @file-expanded) text to
the agent.
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.
channel_ask_user_fn: Optional channel-based ask_user function.
When provided (channel messages), this is called instead
of mounting the AskUserWidget.
"""
from ..stream.display import (
is_stream_cancel_requested,
iter_with_stream_cancel,
)
container = self.query_one("#chat", VerticalScroll)
self._chat_following = True
self._new_content_below = False
# 1. Mount user message + loading spinner
if not skip_user_message:
await container.mount(UserMessage(display_text or user_text))
# Mount file warnings after user message so they appear in the
# correct position (between user input and model response).
for w in file_warnings or []:
self._append_system(f"⚠ {w}", style="yellow")
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
summarization_w: SummarizationWidget | None = None
todo_w: TodoWidget | None = None
tool_widgets: dict[str, ToolCallWidget] = {}
subagent_widgets: dict[str, SubAgentWidget] = {}
@dataclass
class _ResponseDisplayState:
assistant: AssistantMessage | None = None
narration: AssistantMessage | None = None
assistant_start: int = 0
narrated_end: int = 0
response_display = _ResponseDisplayState()
# Transient indicator widgets (auto-removed on state transitions)
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
_thinking_sent = False
_todo_sent = False
_media_sent: set[str] = set()
_MIN_THINKING_LEN = 200
metadata = build_metadata(self._workspace_dir, self._current_model)
response = ""
agent = await self._await_agent_ready()
async def _remove_w(w: Widget | None) -> None:
"""Safely remove a transient indicator widget."""
if w is not None:
try:
await w.remove()
except Exception:
pass
async def _mount_or_update_narration(text: str) -> None:
"""Show the current between-tool text segment inline."""
content = text.strip()
if not content:
return
if response_display.narration is None:
response_display.narration = AssistantMessage(content)
await container.mount(response_display.narration)
else:
response_display.narration._content = content
await response_display.narration.stop_stream()
response_display.narrated_end = len(state.response_text)
async def _convert_assistant_to_narration() -> None:
"""Turn a provisional answer into inline narration before a tool."""
if response_display.assistant is None:
return
content = response_display.assistant._content
if content.strip():
narration = AssistantMessage(content.strip())
try:
await container.mount(
narration, before=response_display.assistant
)
except Exception:
await container.mount(narration)
response_display.narrated_end = max(
response_display.narrated_end,
response_display.assistant_start + len(content),
)
try:
await response_display.assistant.remove()
except Exception:
pass
response_display.assistant = None
response_display.assistant_start = len(state.response_text)
response_display.narration = None
async def _preserve_active_narration() -> None:
"""Finalize inline narration without removing it from the transcript."""
if response_display.narration is not None:
await response_display.narration.stop_stream()
response_display.narration = None
async def _mark_cancelled_response() -> str:
previous_text = state.response_text or ""
current, final_segment, final_text = _stopped_response_after_narration(
previous_text,
response_display.narrated_end,
)
state.response_text = final_text
self._set_status_streaming_text(final_segment)
await _preserve_active_narration()
_expand_completed_tools()
if response_display.assistant is None:
if final_segment:
response_display.assistant_start = len(final_text) - len(
final_segment
)
response_display.assistant = AssistantMessage(final_segment)
await container.mount(response_display.assistant)
else:
if response_display.assistant._content != current:
response_display.assistant._content = final_segment
await response_display.assistant.stop_stream()
else:
suffix = final_segment[len(current) :]
if suffix:
await response_display.assistant.append_content(suffix)
return final_text
def _finalize_active_summarization() -> None:
"""Stop the active summary timer once the stream moves on."""
if summarization_w is not None and summarization_w._is_active:
summarization_w.finalize()
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 _expand_completed_tools() -> None:
"""Show every completed tool once the turn reaches a final state."""
if collapse_summary_w is not None:
collapse_summary_w.display = False
for tw in tool_widgets.values():
tw.display = True
def _get_sa_widget(
instance_id: str,
name: str = "",
description: str = "",
) -> SubAgentWidget | None:
if instance_id in subagent_widgets:
w = subagent_widgets[instance_id]
if name and w._sa_name != name:
w.update_name(name, description or w._description)
if description and not w._description:
w.update_name(w._sa_name, description)
return w
return None
_MAX_HITL_ROUNDS = 50
_stream_input: Any = user_text # str or Command for HITL resume
graph_gateway = self._graph_gateway()
for _hitl_round in range(_MAX_HITL_ROUNDS):
if is_stream_cancel_requested(cancel_scope):
response = await _mark_cancelled_response()
break
state.pending_interrupt = None
state.pending_ask_user = None
_hitl_resuming = False
# Reset per-round widgets so resumed streams get fresh ones
if _hitl_round > 0:
thinking_w = None
summarization_w = None
try:
_anchor_engaged = False
async for event in iter_with_stream_cancel(
graph_gateway.stream_events(
RunRequest(
message=_stream_input,
thread_id=(
thread_id_override or self._conversation_tid
),
metadata=metadata,
target=GraphTarget(
local_graph=agent,
workspace_dir=self._workspace_dir,
),
)
),
cancel_scope,
):
if is_stream_cancel_requested(cancel_scope):
response = await _mark_cancelled_response()
break
if container.max_scroll_y > 0:
at_end = container.is_vertical_scroll_end
if not _anchor_engaged:
_anchor_engaged = True
if at_end:
container.anchor()
else:
self._chat_following = False
self._new_content_below = True
elif self._chat_following and not at_end:
self._chat_following = False
self._new_content_below = True
elif not self._chat_following and at_end:
container.anchor()
self._chat_following = True
self._new_content_below = False
elif container.is_anchored:
# Content no longer overflows (e.g. loading widget
# was just removed). Release the anchor so the
# compositor doesn't pin scroll_y to a negative
# value on the next layout pass (issue #301).
self._release_anchor_and_pin_top(container)
event_type = state.handle_event(event)
new_phase = state.compute_phase()
if new_phase != self._status_phase:
self._status_phase = new_phase
self._render_status()
if event_type == "usage_stats":
self._set_status_usage_baseline(state.last_input_tokens)
if _should_finalize_active_summarization(event_type):
_finalize_active_summarization()
# -- 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",
"summarization_start",
"summarization",
):
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 == "summarization_start":
if (
summarization_w is not None
and not summarization_w._is_active
):
summarization_w = None
if summarization_w is None:
summarization_w = SummarizationWidget()
await container.mount(summarization_w)
elif event_type == "summarization":
content = event.get("content", "")
if (
summarization_w is not None
and not summarization_w._is_active
):
summarization_w = None
if summarization_w is None:
summarization_w = SummarizationWidget()
await container.mount(summarization_w)
if content:
summarization_w.append_text(content)
elif event_type == "tool_selection":
tools = event.get("tools", [])
if tools:
from .widgets.tool_selection_widget import (
ToolSelectionWidget,
)
await container.mount(ToolSelectionWidget(tools))
elif event_type == "text":
chunk = event.get("content", "")
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 not _is_final_response(state):
await _mount_or_update_narration(state.latest_text)
self._set_status_streaming_text(state.latest_text)
else:
# Stream final response incrementally (both
# text-only replies and post-tool responses).
await _preserve_active_narration()
if response_display.assistant is None:
response_display.assistant_start = max(
response_display.narrated_end,
len(state.response_text) - len(chunk),
)
response_display.assistant = AssistantMessage(
state.response_text[
response_display.assistant_start :
]
)
await container.mount(response_display.assistant)
else:
await response_display.assistant.append_content(
chunk
)
self._set_status_streaming_text(
response_display.assistant._content
)
elif event_type == "tool_call":
tool_name = event.get("name", "unknown")
tool_id = event["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 _preserve_active_narration()
await _remove_w(processing_w)
processing_w = None
existing_tool = bool(tool_id and tool_id in tool_widgets)
if response_display.assistant is not None and (
tool_name == "task" or not existing_tool
):
await _convert_assistant_to_narration()
# Task tools are handled by SubAgentWidget.
if tool_name != "task":
if existing_tool:
# 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)
tool_widgets[tool_id] = w
# Update todo widget on write_todos.
# Insert before tool call widget so Task List
# panel appears above the tool call.
if tool_name == "write_todos" and state.todo_items:
if todo_w is None:
todo_w = TodoWidget(state.todo_items)
if tool_id and tool_id in tool_widgets:
await container.mount(
todo_w,
before=tool_widgets[tool_id],
)
else:
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)
matched_tid = event["id"]
tw = tool_widgets.get(matched_tid)
if tw is not None and tw._status == "running":
if result_success:
tw.set_success(result_content)
else:
tw.set_error(result_content)
# Track completion order for collapsing
if (
tw is not None
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["name"]
sa_desc = event.get("description", "")
instance_id = event["instance_id"]
existing = _get_sa_widget(instance_id, sa_name, sa_desc)
if existing is None:
sa_w = SubAgentWidget(sa_name, sa_desc)
await container.mount(sa_w)
subagent_widgets[instance_id] = sa_w
elif event_type == "subagent_tool_call":
instance_id = event["instance_id"]
sa_w = _get_sa_widget(
instance_id, event.get("subagent", "")
)
if sa_w is None:
continue
await sa_w.add_tool_call(
event.get("name", "unknown"),
event.get("args", {}),
event["id"],
)
elif event_type == "subagent_tool_result":
instance_id = event["instance_id"]
sa_w = _get_sa_widget(
instance_id, event.get("subagent", "")
)
if sa_w is not None:
sa_w.complete_tool(
event.get("name", "unknown"),
event.get("content", ""),
event.get("success", True),
event["id"],
)
elif event_type == "subagent_end":
instance_id = event["instance_id"]
sa_w = _get_sa_widget(instance_id, event.get("name", ""))
if sa_w is not None:
sa_w.finalize()
elif event_type == "ask_user":
questions = event.get("questions", [])
if questions:
# Channel messages: use channel-based text prompt
if channel_ask_user_fn is not None:
self._append_system(
"Waiting for channel user input...",
style="dim italic",
)
_ask_fn = channel_ask_user_fn
result = await asyncio.to_thread(
lambda f=_ask_fn, e=event: f(e),
)
if is_stream_cancel_requested(cancel_scope):
state.pending_ask_user = None
response = await _mark_cancelled_response()
break
else:
# Interactive TUI: display widget, collect via arrow keys
from .widgets.ask_user_widget import AskUserWidget
_prompt = self.query_one("#prompt", ChatTextArea)
_prompt.disabled = True
ask_w = AskUserWidget(questions)
await container.mount(ask_w)
self.call_after_refresh(ask_w.focus_active)
result = await self._wait_for_ask_user(ask_w)
try:
await ask_w.remove()
except Exception:
pass
_prompt.disabled = False
from langgraph.types import (
Command, # type: ignore[import-untyped]
)
_stream_input = Command(resume=result)
_hitl_resuming = True
break # re-enter outer HITL loop
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 is_stream_cancel_requested(cancel_scope):
state.pending_interrupt = None
response = await _mark_cancelled_response()
break
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
# Disable main prompt so it can't steal focus
_prompt = self.query_one("#prompt", ChatTextArea)
_prompt.disabled = True
from .widgets.approval_widget import ApprovalWidget
approval_w = ApprovalWidget(action_reqs)
await container.mount(approval_w)
decided_event = await self._wait_for_approval(approval_w)
await approval_w.remove()
_prompt.disabled = False
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 _preserve_active_narration()
await _remove_w(processing_w)
processing_w = None
_expand_completed_tools()
# Mount final response
final_response_text = _response_after_narration(
state.response_text,
response_display.narrated_end,
)
clean = _strip_trailing_placeholder_ellipsis(
final_response_text
)
if clean:
response_display.assistant_start = len(
state.response_text
) - len(final_response_text)
if response_display.assistant is None:
response_display.assistant = AssistantMessage(clean)
await container.mount(response_display.assistant)
elif response_display.assistant._content != clean:
response_display.assistant._content = clean
await response_display.assistant.stop_stream()
elif response_display.assistant is not None:
try:
await response_display.assistant.remove()
except Exception:
pass
response_display.assistant = None
# Mount token usage stats with elapsed time
if state.total_input_tokens or state.total_output_tokens:
elapsed = None
if self._turn_started_at:
elapsed = format_duration_compact(
self._turn_started_at
)
await container.mount(
UsageWidget(
state.total_input_tokens,
state.total_output_tokens,
elapsed=elapsed,
)
)
elif event_type == "error":
error_msg = event.get("message", "Unknown error")
self._append_system(f"Error: {error_msg}", style="red")
response = (state.response_text or "").strip()
except asyncio.CancelledError:
# Ctrl+C cancellation: preserve streamed text before the outer
# turn handler appends its interruption notice.
await _mark_cancelled_response()
raise
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. Inline narration is transcript
# content, so finalize it without removing the widget.
await _preserve_active_narration()
await _remove_w(processing_w)
# Mark any still-running tool widgets as interrupted
# (skip if HITL approved — tools will continue next round)
if not _hitl_resuming:
_expand_completed_tools()
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 response_display.assistant is not None:
await response_display.assistant.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())
# Re-anchor so final content is visible, but only
# if the user hasn't scrolled away.
if self._chat_following:
self._anchor_chat(container)
self._new_content_below = False
# HITL / ask_user: if interrupt was handled, loop back to resume stream
if is_stream_cancel_requested(cancel_scope):
response = await _mark_cancelled_response()
break
if state.pending_interrupt is None and state.pending_ask_user 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,
*,
skip_user_message: bool = False,
resolve_mentions: bool = True,
thread_id_override: str | None = None,
) -> str:
"""Handle a user turn: stream agent response with widgets.
Returns the final response text from ``_stream_with_widgets`` so
callers (notably the async-notifier path) can forward it onward —
e.g. push it back to the originating channel. Returns ``""`` if
the turn was cancelled or the agent failed to load.
Args:
user_text: The user's message text.
skip_user_message: If True, suppress the UserMessage widget echo
(caller has already displayed a visual representation of the
input — e.g. async-notifier per-task lines).
resolve_mentions: If False, skip ``@file`` mention expansion.
Used by synthetic notifier turns whose payload is a fixed
JSON template — keeps the TUI path consistent with the
Rich CLI notifier path which never expands mentions.
thread_id_override: Pin the agent stream to this thread instead
of the live ``self._conversation_tid``. Used by the async
notifier path so a mid-consume ``/new`` cannot redirect a
notification meant for thread A into thread B. Falls back
to the live tid when ``None``.
"""
cancelled = False
response = ""
try:
# Foreground turns share the legacy default scope. Reset it at
# the turn boundary; scoped channel stop requests remain armed.
from ..stream.display import clear_stream_cancel
clear_stream_cancel()
self._busy = True
self._turn_started_at = datetime.now()
self._status_phase = ResearchPhase.THINKING
self._render_status()
# Resolve @file mentions — inject file contents before sending to agent.
# Use self._workspace_dir (current session) not the startup-captured
# workspace_dir closure, which becomes stale after /new or /resume.
if resolve_mentions:
_, message_to_send, file_warnings = await asyncio.to_thread(
resolve_file_mentions, user_text, self._workspace_dir
)
else:
message_to_send = user_text
file_warnings = []
await self._refresh_status_snapshot(message_to_send)
# Block the turn on MCP tools finishing, if still in flight.
# ``_on_agent_load_failure`` is the sole reporter for load
# errors; callers just return so the send is dropped.
try:
await self._await_agent_ready()
except Exception:
return ""
response = await self._stream_with_widgets(
message_to_send,
display_text=user_text,
file_warnings=file_warnings,
skip_user_message=skip_user_message,
thread_id_override=thread_id_override,
)
except asyncio.CancelledError:
cancelled = True
try:
from ..middleware.code_interpreter import (
aclose_code_interpreters,
)
await aclose_code_interpreters()
except Exception:
_channel_logger.debug(
"code interpreter cleanup after cancellation failed",
exc_info=True,
)
self._append_system("\nInterrupted by user", style="dim italic #ffe082")
finally:
self._busy = False
self._status_phase = ResearchPhase.IDLE
self._run_task = None
await self._refresh_status_snapshot(reset_streaming_text=True)
self._render_status()
self.query_one("#prompt", ChatTextArea).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))
return response
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]
"""
prompt_widget = None
if not _ch_mod._claim_or_complete_channel_request(msg):
return
_ch_mod.remember_channel_origin(self._conversation_tid, msg)
try:
self._busy = True
self._turn_started_at = datetime.now()
self._status_phase = ResearchPhase.THINKING
await self._refresh_status_snapshot(msg.content)
self._render_status()
prompt_widget = self.query_one("#prompt", ChatTextArea)
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",
)
# 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)
def _channel_ask_user(ask_user_data: dict) -> dict:
"""Send ask_user questions 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_ask_user_prompt(ask_user_data, msg)
# Handle slash commands from channel via the shared
# dispatcher (same path Rich CLI and headless serve use).
# Returns True when the command was handled (or errored)
# and we must NOT fall through to agent streaming.
_slash_handled = await dispatch_channel_slash_command(
msg,
agent=None, # resolved via await_agent_ready on demand
thread_id=self._conversation_tid,
workspace_dir=self._workspace_dir,
checkpointer=self._checkpointer,
append_system=self._append_system,
start_new_session_cb=self.start_new_session,
handle_session_resume_cb=self.handle_session_resume,
await_agent_ready=self._await_agent_ready,
on_cmd_completed=self._on_channel_cmd_completed,
channel_runtime=self._channel_runtime,
graph_gateway=self._runtime_gateways.graph_gateway,
async_runtime=async_runtime,
)
if _slash_handled:
# A channel-issued /new or /resume rotates the thread in
# the dispatch above; re-bind the now-current thread to
# this channel so async-notifier turns on it still forward.
_ch_mod.remember_channel_origin(self._conversation_tid, msg)
return # outer finally handles _busy / widget cleanup
# Non-slash message — streams through the agent, so wait
# for readiness now.
try:
await self._await_agent_ready()
except Exception as exc:
_set_channel_response(msg.msg_id, f"Error: {exc}")
return
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,
channel_ask_user_fn=_channel_ask_user,
cancel_scope=_ch_mod._channel_message_cancel_scope(msg),
)
except Exception as exc:
response = f"Error: {exc}"
self._append_system(f"Error: {exc}", style="red")
_set_channel_response(msg.msg_id, response)
self._append_system(
f"[{msg.channel_type}: Replied to {msg.sender}]",
style="dim",
)
finally:
self._busy = False
self._status_phase = ResearchPhase.IDLE
await self._refresh_status_snapshot(reset_streaming_text=True)
self._render_status()
if prompt_widget is not None:
prompt_widget.disabled = False
prompt_widget.focus()
_ch_mod._complete_channel_request(msg.msg_id)
# ── 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_chat_text_area_submitted(
self, event: ChatTextArea.Submitted
) -> None:
text = event.value.strip()
prompt = self.query_one("#prompt", ChatTextArea)
prompt.value = ""
self._quit_pending = False
self._history_index = -1
self._history_saved_input = ""
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()
# Launch as independent task to free the message pump.
# Commands like /resume mount interactive widgets that need
# the pump to process key events and message bubbling.
_task = asyncio.create_task(self._handle_command(text))
self._background_tasks.add(_task)
_task.add_done_callback(self._background_tasks.discard)
return
self._history_suggester.append_entry(text)
self._run_task = asyncio.ensure_future(self._run_turn(text))
def _build_file_candidates(self, text: str) -> list:
"""Build CompletionCandidate list for @file mentions."""
import re as _re
from ..commands._completion_engine import CompletionCandidate
candidates = complete_file_mention(text, self._workspace_dir)
if not candidates:
return []
m = _re.search(r'@"[^"\n]*$|@[^\s"\']*$', text)
start = m.start() if m else len(text)
return [
CompletionCandidate(
text=path if path.startswith("@") else f"@{path}",
description=type_hint,
replace_start=start,
replace_end=len(text),
)
for path, type_hint in candidates
]
def on_text_area_changed(self, event: ChatTextArea.Changed) -> None:
text = event.text_area.text
comp_widget = self.query_one("#completions", Static)
# Slash-command completions take priority over @file
if text.startswith("/"):
from ..commands._completion_engine import compute_completions
result = compute_completions(text, len(text))
if result.kind == "empty" or not result.candidates:
self._hide_completions()
return
self._comp_items = result.candidates
self._comp_index = -1
c0 = result.candidates[0]
self._comp_base = text[: c0.replace_start]
self._render_completions()
comp_widget.display = True
return
# @file mention completion (only for non-command input)
if "@" in text:
file_items = self._build_file_candidates(text)
if file_items:
self._comp_items = file_items
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."""
comp_widget = self.query_one("#completions", Static)
if comp_widget.display:
self._hide_completions()
return
# Cancel ask_user if active (widget handles Escape internally,
# but this is a safety fallback)
if self._ask_user_future and not self._ask_user_future.done():
try:
from .widgets.ask_user_widget import AskUserWidget
ask_w = self.query_one(AskUserWidget)
ask_w.action_cancel()
except Exception:
# Force-resolve the future
self._ask_user_future.set_result({"type": "cancelled"})
return
# Delegate to focused interactive widget
focused = self.focused
if focused is not None:
from .widgets.approval_widget import ApprovalWidget
from .widgets.mcp_browser import MCPBrowserWidget
from .widgets.model_picker import ModelPickerWidget
from .widgets.skill_browser import SkillBrowserWidget
from .widgets.thread_selector import ThreadPickerWidget
if isinstance(focused, ApprovalWidget):
focused.action_select_reject()
return
if isinstance(focused, ThreadPickerWidget):
focused.action_cancel()
return
if isinstance(focused, SkillBrowserWidget):
focused.action_cancel()
return
if isinstance(focused, MCPBrowserWidget):
focused.action_cancel()
return
# ModelPickerWidget: when in "Custom Ollama" input mode, its
# child Input widget owns focus, so ``focused`` isn't the
# picker itself. Walk the parent chain to find it, then let
# the widget's own action_cancel decide whether to close the
# picker (list mode) or just exit input mode.
picker: ModelPickerWidget | None = None
if isinstance(focused, ModelPickerWidget):
picker = focused
else:
node = focused.parent
while node is not None and not isinstance(node, ModelPickerWidget):
node = node.parent
picker = node
if picker is not None:
prev_mode = getattr(picker, "_mode", "list")
picker.action_cancel()
# In list mode action_cancel posted Cancelled; resolve the
# future immediately to avoid a frame of lag. In input
# mode action_cancel flipped back to list — keep picker
# open, do NOT close the future.
if prev_mode == "list":
if (
self._model_picker_future
and not self._model_picker_future.done()
):
self._model_picker_future.set_result(None)
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."""
# Handle completion list selection (up key)
comp_widget = self.query_one("#completions", Static)
if comp_widget.display and self._comp_items:
n = len(self._comp_items)
if self._comp_index <= 0:
self._comp_index = n - 1
else:
self._comp_index -= 1
self._render_completions()
return
# Skip if an interactive picker widget has focus
focused = self.focused
if focused is not None:
from .widgets.approval_widget import ApprovalWidget
from .widgets.ask_user_widget import AskUserWidget
from .widgets.mcp_browser import MCPBrowserWidget
from .widgets.model_picker import ModelPickerWidget
from .widgets.skill_browser import SkillBrowserWidget
from .widgets.thread_selector import ThreadPickerWidget
if isinstance(focused, ApprovalWidget):
focused.action_move_up()
return
if isinstance(focused, AskUserWidget):
focused.action_move_up()
return
if isinstance(focused, ThreadPickerWidget):
focused.action_move_up()
return
if isinstance(focused, SkillBrowserWidget):
focused.action_move_up()
return
if isinstance(focused, MCPBrowserWidget):
focused.action_move_up()
return
# ModelPickerWidget: Up from the Custom Ollama Input child
# must reach the picker (to exit input mode). See the Esc
# handler above for the parent-walk rationale.
picker_up: ModelPickerWidget | None = None
if isinstance(focused, ModelPickerWidget):
picker_up = focused
else:
node = focused.parent
while node is not None and not isinstance(node, ModelPickerWidget):
node = node.parent
picker_up = node
if picker_up is not None:
picker_up.action_move_up()
return
if self._queued_messages:
last = self._queued_messages.pop()
prompt = self.query_one("#prompt", ChatTextArea)
prompt.value = last
prompt.focus()
self._render_queue_indicator()
return
# History browsing (up key)
entries = self._history_suggester._entries
if not entries:
return
prompt = self.query_one("#prompt", ChatTextArea)
if self._history_index == -1:
# Save current input before entering history
self._history_saved_input = prompt.value
if self._history_index + 1 < len(entries):
self._history_index += 1
prompt.value = entries[self._history_index]
prompt.focus()
def action_down_delegate(self) -> None:
"""Delegate down key to focused interactive widget."""
# Handle completion list selection (down key)
comp_widget = self.query_one("#completions", Static)
if comp_widget.display and self._comp_items:
self._comp_index = (self._comp_index + 1) % len(self._comp_items)
self._render_completions()
return
focused = self.focused
if focused is not None:
from .widgets.approval_widget import ApprovalWidget
from .widgets.ask_user_widget import AskUserWidget
from .widgets.mcp_browser import MCPBrowserWidget
from .widgets.model_picker import ModelPickerWidget
from .widgets.skill_browser import SkillBrowserWidget
from .widgets.thread_selector import ThreadPickerWidget
if isinstance(focused, ApprovalWidget):
focused.action_move_down()
return
if isinstance(focused, AskUserWidget):
focused.action_move_down()
return
if isinstance(focused, ThreadPickerWidget):
focused.action_move_down()
return
if isinstance(focused, SkillBrowserWidget):
focused.action_move_down()
return
if isinstance(focused, MCPBrowserWidget):
focused.action_move_down()
return
# Same parent-walk rationale as action_edit_queued / cancel.
picker_down: ModelPickerWidget | None = None
if isinstance(focused, ModelPickerWidget):
picker_down = focused
else:
node = focused.parent
while node is not None and not isinstance(node, ModelPickerWidget):
node = node.parent
picker_down = node
if picker_down is not None:
picker_down.action_move_down()
return
# History browsing (down key)
if self._history_index >= 0:
prompt = self.query_one("#prompt", ChatTextArea)
self._history_index -= 1
if self._history_index == -1:
# Back to saved input
prompt.value = self._history_saved_input
else:
prompt.value = self._history_suggester._entries[self._history_index]
prompt.focus()
def action_paste_clipboard(self) -> None:
"""Paste text from system clipboard into the input field."""
text = get_clipboard_text()
if not text:
self.notify(
"Clipboard is empty or unavailable",
severity="warning",
timeout=2,
)
return
prompt = self.query_one("#prompt", ChatTextArea)
prompt.insert(text)
prompt.focus()
def action_tab_complete(self) -> None:
"""Handle TAB: apply highlighted completion, or trigger if not visible."""
comp_widget = self.query_one("#completions", Static)
if not (comp_widget.display and self._comp_items):
# Try to trigger completions from current input
prompt = self.query_one("#prompt", ChatTextArea)
prompt.focus()
text = prompt.value
if text.startswith("/"):
from ..commands._completion_engine import compute_completions
result = compute_completions(text, len(text))
if result.kind != "empty" and result.candidates:
self._comp_items = result.candidates
self._comp_index = -1
self._comp_base = text[: result.candidates[0].replace_start]
self._render_completions()
comp_widget.display = True
elif "@" in text:
file_items = self._build_file_candidates(text)
if file_items:
self._comp_items = file_items
self._comp_index = -1
self._render_completions()
comp_widget.display = True
return
if self._comp_index < 0:
self._comp_index = 0
self._render_completions()
self._apply_selected_completion()
selected = self._comp_items[self._comp_index]
is_file_dir = selected.text.startswith("@") and selected.text.rstrip(
'"'
).endswith("/")
if is_file_dir:
return
if selected.text.startswith("@"):
self._hide_completions()
def _handle_completion_enter(self) -> bool:
"""Called by ChatTextArea before submitting on Enter.
If a completion is active and an item is selected, apply it
and suppress the submit. If the list is visible but nothing
is selected (index == -1), select the first item instead of
submitting the raw prefix.
Returns:
True to suppress submit, False to allow it.
"""
comp_widget = self.query_one("#completions", Static)
if not (comp_widget.display and self._comp_items):
return False
if self._comp_index < 0:
self._comp_index = 0
selected = self._comp_items[self._comp_index]
is_file_dir = selected.text.startswith("@") and selected.text.rstrip(
'"'
).endswith("/")
with self.prevent(ChatTextArea.Changed):
self._apply_selected_completion()
if not is_file_dir:
self._hide_completions()
return True
def _apply_selected_completion(self) -> None:
"""Apply the currently selected completion to the input field.
Callers are responsible for suppressing or allowing Changed
events (e.g. ``self.prevent(ChatTextArea.Changed)`` around
the call when the popup should stay hidden).
"""
if self._comp_index < 0 or self._comp_index >= len(self._comp_items):
return
candidate = self._comp_items[self._comp_index]
prompt = self.query_one("#prompt", ChatTextArea)
if candidate.text.startswith("@"):
import re as _re
is_dir = candidate.text.rstrip('"').endswith("/")
suffix = "" if is_dir else " "
current = prompt.value
m = _re.search(r'@"[^"\n]*$|@[^\s"\']*$', current)
if m:
new_val = current[: m.start()] + candidate.text + suffix
else:
new_val = current + candidate.text + suffix
prompt.value = new_val
else:
prompt.value = self._comp_base + candidate.text + " "
def _hide_completions(self) -> None:
self._comp_items = []
self._comp_index = -1
self._comp_last_height = 0
comp_widget = self.query_one("#completions", Static)
was_visible = comp_widget.display
comp_widget.display = False
# Called on every ordinary input change — only a popup that was
# actually visible changed the chat viewport.
if was_visible:
self.call_after_refresh(self._normalize_chat_after_popup)
def _completion_max_rows(self) -> int:
return _completion_row_budget(int(getattr(self.size, "height", 0) or 0))
def _render_completions(self) -> None:
comp_text = _render_completion_text(
self._comp_items, self._comp_index, self._completion_max_rows()
)
self.query_one("#completions", Static).update(comp_text)
# Selection-only navigation keeps the height — skip the (cheap
# but per-keystroke) normalize unless the viewport can change.
n_lines = len(comp_text.plain.splitlines()) if comp_text.plain else 0
if n_lines != self._comp_last_height:
self._comp_last_height = n_lines
self.call_after_refresh(self._normalize_chat_after_popup)
def _normalize_chat_after_popup(self) -> None:
try:
container = self.query_one("#chat", VerticalScroll)
except Exception:
return
_normalize_chat_scroll(container)
# ── Slash commands ─────────────────────────────────────
async def _handle_command(self, command: str) -> None:
# Echo the command so the user sees what they ran
self._append_system(command.strip(), style="cyan")
# Block new user input while the command runs (important for slow
# commands like /compact that call an LLM internally).
prompt_widget = self.query_one("#prompt", ChatTextArea)
self._busy = True
prompt_widget.disabled = True
self._render_status()
try:
# Only gate on agent readiness for commands that need it —
# recovery commands like ``/mcp add`` must run even when
# ``_await_agent_ready`` would hang on a broken MCP load.
parsed = cmd_manager.resolve(command)
cmd = None
cmd_args: list[str] = []
agent = None
if parsed is not None:
cmd, cmd_args = parsed
if cmd is not None and cmd.needs_agent(cmd_args):
try:
agent = await self._await_agent_ready()
except Exception:
# ``_on_agent_load_failure`` already surfaced the error.
return
ctx = CommandContext(
agent=agent,
thread_id=self._conversation_tid,
ui=self,
workspace_dir=self._workspace_dir,
checkpointer=self._checkpointer,
input_tokens_hint=self._status_last_input_tokens,
channel_runtime=self._channel_runtime,
graph_gateway=self._runtime_gateways.graph_gateway,
async_runtime=async_runtime,
)
if await cmd_manager.execute(command, ctx):
if cmd is None:
return
await _sync_tui_command_completion(
self,
ctx,
self._agent_loader.agent,
cmd,
)
return
self._append_system(f"Unknown command: {command}", style="yellow")
self._render_status()
finally:
self._busy = False
if not self._exiting:
prompt_widget.disabled = False
prompt_widget.focus()
async def _render_history(self, thread_id_value: str) -> None:
"""Render conversation history from a saved thread.
Restores human messages and AI responses (with Markdown and
thinking panels). Tool calls and other intermediate steps are
skipped — they are difficult to faithfully reproduce from
checkpoint data.
"""
messages = await self._runtime_gateways.graph_gateway.get_thread_messages(
thread_id_value
)
if not messages:
return
HISTORY_WINDOW = 50
container = self.query_one("#chat", VerticalScroll)
# Only human and ai messages; skip tool/system/other
display = [
m for m in messages if getattr(m, "type", None) in ("human", "ai")
]
if len(display) > HISTORY_WINDOW:
skipped = len(display) - HISTORY_WINDOW
display = display[-HISTORY_WINDOW:]
await container.mount(
SystemMessage(
f"── ... {skipped} earlier messages ──", msg_style="dim"
)
)
else:
await container.mount(
SystemMessage("── Conversation history ──", msg_style="dim")
)
for message in display:
msg_type = getattr(message, "type", None)
content = getattr(message, "content", "") or ""
if msg_type == "human":
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 content:
await container.mount(UserMessage(content))
elif msg_type == "ai":
# Extract thinking and text blocks from content list
thinking_text = ""
text_content = ""
if isinstance(content, list):
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "thinking":
thinking_text += block.get("thinking", "")
elif block.get("type") == "text":
text_content += block.get("text", "")
else:
text_content = content or ""
text_content = text_content.strip()
# Render thinking as collapsed panel (click to expand)
if thinking_text.strip() and show_thinking:
w = ThinkingWidget(show_thinking=True)
await container.mount(w)
w.append_text(thinking_text)
w.finalize()
# Render AI response with full Markdown
if text_content:
await container.mount(AssistantMessage(text_content))
await container.mount(
SystemMessage("── End of history ──", msg_style="dim")
)
# Anchor so the viewport pins to the bottom as Markdown-heavy
# AssistantMessages finish their async layout reflows.
self._anchor_chat(container)
# ── Quit handling ──────────────────────────────────────
def _arm_quit_pending(self, shortcut: str) -> None:
"""Set the pending-quit flag and show a matching hint."""
self._quit_pending = True
quit_timeout = 3 # seconds
self.notify(f"Press {shortcut} again to quit", timeout=quit_timeout)
self.set_timer(
quit_timeout,
lambda: setattr(self, "_quit_pending", False),
)
def force_quit(self) -> None:
"""Exit immediately without double-press confirmation (used by /exit command)."""
self._do_exit()
def _do_exit(self) -> None:
"""Clean up channels, unregister callbacks, and exit."""
self._exiting = True
self._channel_start_stop.set()
event_sink.set_fallback_display(None)
if self._channel_timer is not None:
self._channel_timer.stop()
self._channel_timer = None
self._channel_start_results.clear()
if _channels_is_running():
try:
_channels_stop(runtime=self._channel_runtime)
except Exception:
pass
self.exit()
def action_request_quit(self) -> None:
if self._busy:
self._quit_pending = False
# 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():
from ..stream.display import request_stream_cancel
request_stream_cancel()
self._run_task.cancel()
else:
# Edge case: busy but no task — force reset
self._busy = False
self.query_one("#prompt", ChatTextArea).focus()
self._render_status()
self._append_system(
"\nInterrupted by user", style="dim italic #ffe082"
)
return
# Double Ctrl+C to quit
if self._quit_pending:
self._do_exit()
else:
self._arm_quit_pending("Ctrl+C")
# ── Banner & status ────────────────────────────────────
async def _refresh_status_snapshot(
self,
pending_user_text: str | None = None,
*,
reset_streaming_text: bool = True,
) -> None:
"""Recompute persistent status metrics for the active thread."""
pending = (pending_user_text or "").strip()
if pending:
if self._status_last_input_tokens is not None:
self._status_base_snapshot = apply_user_text_to_snapshot(
make_usage_status_snapshot(
self._status_last_input_tokens,
model_name=self._current_model,
),
pending,
)
else:
self._status_base_snapshot = await build_session_status_snapshot(
self._conversation_tid,
model_name=self._current_model,
pending_user_text=pending,
graph_gateway=self._runtime_gateways.graph_gateway,
)
elif self._status_last_input_tokens is not None:
self._status_base_snapshot = make_usage_status_snapshot(
self._status_last_input_tokens,
model_name=self._current_model,
)
else:
self._status_base_snapshot = await build_session_status_snapshot(
self._conversation_tid,
model_name=self._current_model,
graph_gateway=self._runtime_gateways.graph_gateway,
)
if reset_streaming_text:
self._status_streaming_text = ""
self._rebuild_status_snapshot()
def _set_status_usage_baseline(self, input_tokens: int) -> None:
"""Promote the latest real prompt usage into the status-bar base."""
if input_tokens <= 0:
return
self._status_last_input_tokens = input_tokens
self._status_base_snapshot = make_usage_status_snapshot(
input_tokens,
model_name=self._current_model,
)
self._rebuild_status_snapshot()
def update_status_after_compact(self, tokens_after: int) -> None:
"""Update the status bar immediately after a successful /compact.
Called by CompactCommand so the bar reflects the reduced context
without waiting for the next LLM call.
"""
if tokens_after <= 0:
return
self._status_last_input_tokens = tokens_after
self._status_base_snapshot = make_usage_status_snapshot(
tokens_after,
model_name=self._current_model,
)
self._rebuild_status_snapshot()
def update_status_after_model_change(
self, new_model: str, new_provider: str | None = None
) -> None:
"""Update the status bar and welcome banner after /model switches the LLM."""
self._current_model = new_model
if new_provider is not None:
self._current_provider = new_provider
self._status_base_snapshot = make_empty_status_snapshot(new_model)
self._rebuild_status_snapshot()
self._render_welcome()
def _set_status_streaming_text(self, text: str | None) -> None:
"""Update in-flight assistant text shown in the context bar."""
new_text = text or ""
if new_text == self._status_streaming_text:
return
self._status_streaming_text = new_text
self._rebuild_status_snapshot()
def _rebuild_status_snapshot(self) -> None:
"""Compose the displayed snapshot from base state + live overlay."""
self._status_snapshot = apply_assistant_text_to_snapshot(
self._status_base_snapshot,
self._status_streaming_text,
)
self._render_status()
def _render_welcome(self) -> None:
channels_info: list[tuple[str, bool, str]] | None = None
try:
current = get_channel_startup_results()
if current:
self._channel_start_results = current
if self._channel_start_results:
channels_info = self._channel_start_results
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=self._current_model,
provider=self._current_provider,
ui_backend="tui",
channels=channels_info,
)
)
async def _render_history_then_pending_notice(self, thread_id: str) -> None:
await self._render_history(thread_id)
self._append_pending_skill_proposals_notice()
def _append_pending_skill_proposals_notice(self) -> None:
from .commands import _pending_skill_proposals_message
message = _pending_skill_proposals_message(self._workspace_dir)
if message:
self._append_system(message, style="yellow")
def _render_status(self) -> None:
status = self.query_one("#status", Static)
width = (
getattr(status.size, "width", 0)
or getattr(status.content_region, "width", 0)
or getattr(self.screen.size, "width", 0)
or 80
)
# MCP load progress lives in the dedicated MCPLoaderWidget
# above the input bar — no need to duplicate it here.
if self._busy:
hint_label, hint_style = self._phase_hint_label()
else:
hint_label = "/help for commands"
hint_style = f"on {STATUS_BAR_BG} {STATUS_HINT_IDLE}"
hint = Text.assemble(
(hint_label, hint_style),
)
if (
self._busy
and self._status_phase != ResearchPhase.THINKING
and self._new_content_below
):
hint.append(" │ ", style=f"on {STATUS_BAR_BG} {STATUS_DIM}")
hint.append(
"↓ New content below",
style=f"on {STATUS_BAR_BG} {STATUS_GOOD} bold",
)
hint.append(" │ ", style=f"on {STATUS_BAR_BG} {STATUS_DIM}")
remaining_width = max(1, width - len(hint.plain))
metrics = build_status_text(
self._status_snapshot,
self._status_started_at,
remaining_width,
)
line = Text(no_wrap=True, overflow="crop")
line.append_text(hint)
line.append_text(metrics)
status.update(line)
def _phase_hint_label(self) -> tuple[str, str]:
"""Return (label, style) for the current research phase."""
phase = self._status_phase
busy_style = f"on {STATUS_BAR_BG} {STATUS_HINT_BUSY} bold"
elapsed = ""
if self._turn_started_at:
elapsed = f" ({format_duration_compact(self._turn_started_at)})"
match phase:
case ResearchPhase.THINKING:
return f"Thinking...{elapsed}", busy_style
case ResearchPhase.RESEARCHING:
return f"Researching...{elapsed}", busy_style
case ResearchPhase.WRITING:
return (
f"Writing report...{elapsed}",
f"on {STATUS_BAR_BG} {STATUS_HINT_WRITING} bold",
)
case _:
# Fallback for busy state with no specific phase (e.g. slash commands)
return f"Working...{elapsed}", busy_style
# ── 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: str | None = None
resumed = False
resume_warning = ""
if thread_id:
resolution = await graph_gateway.resolve_thread(thread_id)
if resolution.thread_id:
meta = await graph_gateway.get_thread_metadata(resolution.thread_id)
ws = (meta or {}).get("workspace_dir", "")
mismatch_aborted = False
if ws:
effective_workspace = ws
# Sync langgraph dev subprocess to the resumed
# workspace BEFORE the Textual app takes over the
# terminal. Mirrors interactive.py's Rich-CLI fix.
# Without this, --resume against a thread from a
# different workspace would leave background workers
# and deployed sub-agents operating on the launch
# directory's files.
from ..stream.console import console as _resume_console
try:
from ..langgraph_dev.manager import WorkspaceMismatchError
from .commands import (
_sync_background_agent_server_workspace,
)
await _sync_background_agent_server_workspace(
config,
workspace_dir=ws,
)
except WorkspaceMismatchError as _ws_mismatch_exc:
# Surface the user-actionable message via the
# Rich console (TUI hasn't taken over the terminal
# yet) and abort the resume so the TUI doesn't
# start up pointing at the conflicting workspace
# with background work routed at a server pinned to
# a different workspace.
_resume_console.print(f"[red]{_ws_mismatch_exc}[/red]")
mismatch_aborted = True
except Exception as _ws_sync_exc:
# Non-fatal at startup — async sub-agents fall back
# to sync via the manager's own availability flag,
# and memory workers skip while the server is down.
# Surface the exception so unexpected failures
# (import errors, regressions in
# ensure_langgraph_dev, etc.) don't hide silently.
logging.getLogger(__name__).warning(
"TUI startup workspace sync to langgraph dev "
"failed: %s. Async sub-agents will fall back "
"to in-process sync delegation and EvoMemory "
"workers will skip for this session.",
_ws_sync_exc,
)
if mismatch_aborted:
# Revert the workspace mutation and fall through to the
# ``effective_thread_id is None`` branch below, which
# generates a fresh thread in the original launch
# workspace. User keeps the TUI session; the requested
# ``--thread-id`` resume is what we refuse.
effective_workspace = workspace_dir
resume_warning = (
f"Resume of thread '{thread_id}' aborted due to "
"workspace conflict. Starting new session."
)
else:
effective_thread_id = resolution.thread_id
resumed = True
elif resolution.matches:
resume_warning = (
f"Thread prefix '{thread_id}' is ambiguous "
f"({', '.join(resolution.matches)}). Starting new session."
)
else:
resume_warning = (
f"Thread '{thread_id}' not found. Starting new session."
)
if not effective_thread_id:
effective_thread_id = await graph_gateway.create_thread(
GraphTarget(workspace_dir=effective_workspace)
)
# The TUI opens instantly and starts MCP loading in the
# background; ``on_mount`` in the app kicks off the real
# ``load_agent`` call and awaits it before the first turn.
app = EvoTextualInteractiveApp(
thread_id_value=effective_thread_id,
workspace=effective_workspace,
checkpointer=checkpointer,
runtime_gateways=runtime_gateways,
channel_send_thinking_value=channel_send_thinking,
resumed=resumed,
resume_warning=resume_warning,
)
try:
await app.run_async()
finally:
from .resume_hint import print_resume_hint
try:
from ..middleware.code_interpreter import (
aclose_code_interpreters,
)
await aclose_code_interpreters()
except Exception:
_channel_logger.debug(
"code interpreter cleanup failed",
exc_info=True,
)
# Best-effort resume hint — guarded so failures here (e.g.
# DB teardown race during abnormal shutdown) cannot shadow
# the original run_async traceback.
exit_tid = getattr(app, "_conversation_tid", None)
hint_tid: str | None = None
if exit_tid:
try:
if await graph_gateway.thread_exists(exit_tid):
hint_tid = exit_tid
except Exception:
_channel_logger.debug(
"resume-hint thread_exists lookup failed",
exc_info=True,
)
try:
print_resume_hint(hint_tid)
except Exception:
_channel_logger.debug("print_resume_hint failed", exc_info=True)
asyncio.run(_amain())