8b1451cdda
* 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>
3670 lines
160 KiB
Python
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())
|