"""Textual terminal UI for GenericAgent. Run from the project root: python frontends/tuiapp.py Useful options: python frontends/tuiapp.py --help MVP design notes: - One TUI manages multiple GenericAgent instances. - GenericAgent.put_task() returns a per-task display_queue; the TUI records a task_id for every submit. - Agent.run() and display_queue.get() run in daemon threads; UI updates are posted via App.call_from_thread(). - Multiple sessions may run concurrently, but GenericAgent still shares project temp/memory/tool globals. """ from __future__ import annotations import argparse import os import queue import re import sys import threading import time from dataclasses import dataclass, field from itertools import count from typing import Any, Callable, Optional try: from rich.markdown import Markdown from rich.panel import Panel from rich.text import Text from textual import events from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.message import Message from textual.widgets import Footer, Header, RichLog, Static, TextArea except ModuleNotFoundError as exc: # pragma: no cover - exercised by manual missing-dep path if exc.name == "textual": print("Textual is required. Install with: pip install textual", file=sys.stderr) else: print(f"Missing dependency: {exc.name}", file=sys.stderr) raise SystemExit(2) from exc ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if ROOT_DIR not in sys.path: sys.path.insert(0, ROOT_DIR) AgentFactory = Callable[[], Any] @dataclass class ChatMessage: role: str content: str task_id: Optional[int] = None done: bool = True _rendered_panel: Any = field(default=None, repr=False) @dataclass class AgentSession: agent_id: int name: str agent: Any thread: Optional[threading.Thread] = None status: str = "idle" messages: list[ChatMessage] = field(default_factory=list) task_seq: int = 0 current_task_id: Optional[int] = None current_display_queue: Optional[queue.Queue] = None buffer: str = "" def fold_turns(text: str) -> list[dict[str, str]]: """Split GenericAgent turn output into text/fold segments. Completed turns become ``{'type': 'fold', 'title': ..., 'content': ...}``. The latest/incomplete turn remains ``type='text'`` for streaming refresh. """ placeholders: list[str] = [] def stash(match: re.Match[str]) -> str: placeholders.append(match.group(0)) return f"\x00PH{len(placeholders) - 1}\x00" # Line-anchored fence matcher — see tuiapp_v2.fold_turns for rationale. # Unanchored variant mis-paired backticks embedded in file_read output # with later real fences, swallowing turn markers and ballooning the # final "text" segment to MBs (1.85s markdown render on /continue). safe = re.sub(r"^`{4,}.*?^`{4,}\n?", stash, text, flags=re.DOTALL | re.MULTILINE) parts = re.split(r"(\**LLM Running \(Turn \d+\) \.\.\.\**)", safe) def restore(part: str) -> str: return re.sub(r"\x00PH(\d+)\x00", lambda m: placeholders[int(m.group(1))], part) parts = [restore(p) for p in parts] if len(parts) < 4: return [{"type": "text", "content": text}] segments: list[dict[str, str]] = [] if parts[0].strip(): segments.append({"type": "text", "content": parts[0]}) turns: list[tuple[str, str]] = [] for i in range(1, len(parts), 2): marker = parts[i] content = parts[i + 1] if i + 1 < len(parts) else "" turns.append((marker, content)) for idx, (marker, content) in enumerate(turns): if idx < len(turns) - 1: cleaned = re.sub(r"`{3,}.*?`{3,}|.*?", "", content, flags=re.DOTALL) matches = re.findall(r"\s*((?:(?!).)*?)\s*", cleaned, re.DOTALL) if matches: title = matches[0].strip().split("\n", 1)[0] else: title = cleaned.strip().split("\n", 1)[0] or marker.strip("*") # Strip trailing args portion from tool-call lines title = re.sub(r",?\s*args:.*$", "", title) if len(title) > 72: title = title[:72] + "..." segments.append({"type": "fold", "title": title, "content": content}) else: segments.append({"type": "text", "content": marker + content}) return segments def render_folded_text(text: str) -> str: """Render fold segments as terminal-friendly Markdown text. Textual's interactive Collapsible widgets are best for static layouts; the MVP uses a RichLog and re-renders compact summaries for completed turns to keep streaming cheap. """ rendered: list[str] = [] for seg in fold_turns(text): if seg["type"] == "fold": rendered.append(f"\n▸ {seg.get('title') or 'completed turn'}\n\n") else: rendered.append(seg.get("content", "")) return "".join(rendered) def parse_local_command(raw: str) -> tuple[str, list[str]] | None: """Return (command, args) for TUI-owned slash commands; unknown slash is passthrough.""" text = (raw or "").strip() if not text.startswith("/"): return None name, *rest = text.split(maxsplit=1) cmd = name[1:].lower() args = rest[0].split() if rest else [] if cmd in {"help", "status", "new", "switch", "sessions", "stop", "llm", "branch", "rewind", "clear", "close", "quit", "exit"}: return cmd, args return None def default_agent_factory() -> Any: from agentmain import GenericAgent agent = GenericAgent() agent.inc_out = True return agent class PromptInput(TextArea): """Multi-line input: Enter submits, Ctrl+Enter (ctrl+j) inserts newline, paste never auto-submits.""" BINDINGS = [ Binding("ctrl+j", "newline", "Newline", show=False), ] class Submitted(Message): """Posted when the user presses Enter to submit.""" def __init__(self, value: str) -> None: super().__init__() self.value = value def __init__(self, placeholder: str = "", **kwargs) -> None: super().__init__(language=None, show_line_numbers=False, compact=True, placeholder=placeholder, **kwargs) def _on_key(self, event: events.Key) -> None: if event.key == "enter": # Enter → submit event.stop() event.prevent_default() value = self.text.rstrip() self.clear() self.post_message(self.Submitted(value)) elif event.key == "ctrl+j": # Ctrl+Enter (ctrl+j) → insert newline event.stop() event.prevent_default() start, end = self.selection self._replace_via_keyboard("\n", start, end) else: super()._on_key(event) class GenericAgentTUI(App[None]): """Textual app that manages multiple GenericAgent sessions.""" CSS = """ Screen { layout: vertical; } #body { height: 1fr; } #sidebar { width: 30; min-width: 24; border: solid $accent; padding: 0 1; overflow-x: hidden; } #main { width: 1fr; } #status { height: 3; border: solid $primary; padding: 0 1; } #log { height: 1fr; border: solid $primary; padding: 0 1; } #prompt { dock: bottom; height: auto; min-height: 1; max-height: 8; margin-bottom: 1; } .hint { color: $text-muted; } """ BINDINGS = [ ("ctrl+n", "new_session", "New session"), ("ctrl+s", "stop_current", "Stop"), ("ctrl+f", "toggle_fold", "Fold/Unfold"), ("ctrl+q", "quit", "Quit"), Binding("ctrl+left", "prev_session", "←Prev", show=True, priority=True), Binding("ctrl+right", "next_session", "Next→", show=True, priority=True), ] def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None: super().__init__() self.agent_factory: AgentFactory = agent_factory or default_agent_factory self.sessions: dict[int, AgentSession] = {} self.current_id: Optional[int] = None self._ids = count(1) self.fold_mode: bool = True self._last_stream_refresh: float = 0.0 self._stream_throttle_ms: float = 0.15 # seconds between streaming UI refreshes def compose(self) -> ComposeResult: yield Header(show_clock=True) with Horizontal(id="body"): yield Static("", id="sidebar") with Vertical(id="main"): yield Static("", id="status") yield RichLog(id="log", wrap=True, highlight=True, markup=True) yield PromptInput(placeholder="Message, or /help /new /branch /rewind /switch /clear /close /stop /llm /resume", id="prompt") yield Footer() def on_mount(self) -> None: self.add_session("main") self._system("Welcome to GenericAgent TUI. Type /help for commands.") self.query_one("#prompt", PromptInput).focus() def on_resize(self, event) -> None: narrow = self.size.width < 70 self.query_one("#sidebar").styles.display = "none" if narrow else "block" @property def current(self) -> AgentSession: if self.current_id is None: raise RuntimeError("no active session") return self.sessions[self.current_id] def add_session(self, name: Optional[str] = None) -> AgentSession: agent_id = next(self._ids) agent = self.agent_factory() try: agent.inc_out = True except Exception: pass session = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent) thread = threading.Thread(target=agent.run, name=f"ga-tui-agent-{agent_id}", daemon=True) thread.start() session.thread = thread self.sessions[agent_id] = session self.current_id = agent_id self._refresh_all() return session def action_prev_session(self) -> None: """Switch to previous session.""" ids = sorted(self.sessions.keys()) if len(ids) <= 1: return idx = ids.index(self.current_id) self.current_id = ids[(idx - 1) % len(ids)] self._refresh_all() def action_next_session(self) -> None: """Switch to next session.""" ids = sorted(self.sessions.keys()) if len(ids) <= 1: return idx = ids.index(self.current_id) self.current_id = ids[(idx + 1) % len(ids)] self._refresh_all() def action_switch_session(self, n: int) -> None: """Switch to session by id (used by /switch command).""" if n in self.sessions: self.current_id = n self._refresh_all() else: self.notify(f"Session #{n} does not exist.", severity="warning") def action_new_session(self) -> None: self.add_session() self._system(f"Created and switched to session #{self.current_id}.") def action_stop_current(self) -> None: self._cmd_stop([]) def on_prompt_input_submitted(self, event: PromptInput.Submitted) -> None: value = event.value.rstrip() if not value: self._system("Empty input ignored. Type /help for commands.") return parsed = parse_local_command(value) if parsed: cmd, args = parsed self._dispatch_command(cmd, args) return self.submit_user_message(value) def _dispatch_command(self, cmd: str, args: list[str]) -> None: handlers = { "help": self._cmd_help, "status": self._cmd_status, "new": self._cmd_new, "switch": self._cmd_switch, "sessions": self._cmd_sessions, "stop": self._cmd_stop, "llm": self._cmd_llm, "branch": self._cmd_branch, "rewind": self._cmd_rewind, "clear": self._cmd_clear, "close": self._cmd_close, "quit": lambda _args: self.exit(), "exit": lambda _args: self.exit(), } handlers[cmd](args) def submit_user_message(self, text: str) -> int: session = self.current if session.status == "running": self._system(f"Session #{session.agent_id} is already running; wait or /stop before submitting another task.") return -1 session.task_seq += 1 task_id = session.task_seq session.current_task_id = task_id session.buffer = "" session.status = "running" session.messages.append(ChatMessage("user", text)) session.messages.append(ChatMessage("assistant", "", task_id=task_id, done=False)) self._refresh_all() try: display_queue = session.agent.put_task(text, source="user") except Exception as exc: session.status = "error" self._set_assistant_message(session.agent_id, task_id, f"[ERROR] put_task failed: {exc}", done=True) return task_id session.current_display_queue = display_queue threading.Thread( target=self._consume_display_queue, args=(session.agent_id, task_id, display_queue), name=f"ga-tui-consumer-{session.agent_id}-{task_id}", daemon=True, ).start() return task_id def _consume_display_queue(self, agent_id: int, task_id: int, display_queue: queue.Queue) -> None: buffer = "" while True: try: item = display_queue.get(timeout=0.25) except queue.Empty: continue if "next" in item: buffer += str(item.get("next") or "") self.call_from_thread(self._on_stream_update, agent_id, task_id, buffer, False) if "done" in item: done_text = str(item.get("done") or buffer) self.call_from_thread(self._on_stream_update, agent_id, task_id, done_text, True) return def _on_stream_update(self, agent_id: int, task_id: int, text: str, done: bool) -> None: session = self.sessions.get(agent_id) if not session: return if session.current_task_id != task_id: session.messages.append(ChatMessage("system", f"Stale update ignored for task {task_id}.", done=True)) return session.buffer = text if done: session.status = "idle" session.current_display_queue = None self._set_assistant_message(agent_id, task_id, text, done=done) def _set_assistant_message(self, agent_id: int, task_id: int, text: str, *, done: bool) -> None: session = self.sessions.get(agent_id) if not session: return for msg in reversed(session.messages): if msg.role == "assistant" and msg.task_id == task_id: msg.content = text msg.done = done break else: session.messages.append(ChatMessage("assistant", text, task_id=task_id, done=done)) if agent_id == self.current_id: self._refresh_all() else: self._refresh_sidebar() def _cmd_help(self, args: list[str]) -> None: self._system( "Commands:\n" "/help - show this help\n" "/new [name] - create and switch to a new agent session\n" "/branch [name] - fork current session (copies LLM history + display)\n" "/rewind - list rewindable turns; /rewind to truncate history\n" "/switch - switch active session\n" "/sessions - list sessions\n" "/status - show current/all status\n" "/stop - abort current session task\n" "/clear - clear chat display (keeps LLM history)\n" "/close - close current session (cannot close last)\n" "/llm - list models for current session\n" "/llm - switch model for current session\n" "/quit - exit TUI\n\n" "Unknown slash commands (for example /session.x=... or /resume) are sent to GenericAgent." ) def _cmd_new(self, args: list[str]) -> None: name = " ".join(args).strip() or None session = self.add_session(name) self._system(f"Created session #{session.agent_id} {session.name!r}. Shared temp/memory are not isolated.") def _cmd_branch(self, args: list[str]) -> None: import copy old_session = self.current name = " ".join(args).strip() or f"{old_session.name}-branch" new_session = self.add_session(name) # Copy LLM backend history try: new_session.agent.llmclient.backend.history = copy.deepcopy( old_session.agent.llmclient.backend.history ) except Exception as e: self._system(f"Branch warning: failed to copy history: {e}") return # Copy TUI display messages new_session.messages = copy.deepcopy(old_session.messages) new_session.task_seq = old_session.task_seq n = len(new_session.agent.llmclient.backend.history) self._system(f"Branched from #{old_session.agent_id} → #{new_session.agent_id} ({n} messages inherited).") def _cmd_rewind(self, args: list[str]) -> None: session = self.current if session.status == "running": self._system("Cannot rewind while running. /stop first.") return history = session.agent.llmclient.backend.history # Find real user turn boundaries — skip tool_result messages turns = [] # list of (index_in_history, preview_text) for i, msg in enumerate(history): if msg.get("role") != "user": continue content = msg.get("content") # Pure string content is always a real user message if isinstance(content, str): turns.append((i, content[:60])) continue if isinstance(content, list): # Skip if content is purely tool_result blocks has_tool_result = any(b.get("type") == "tool_result" for b in content if isinstance(b, dict)) if has_tool_result: continue texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] if texts and any(t.strip() for t in texts): turns.append((i, (texts[0] or "")[:60])) if not turns: self._system("No rewindable turns in history.") return # Reverse numbering: 1 = most recent turn, 2 = second most recent, etc. # /rewind without args: show list if not args: lines = [f"Rewindable turns ({len(turns)} total, showing last 10):"] show = turns[-10:] for offset, (_, preview) in enumerate(reversed(show), 1): lines.append(f" {offset}) {preview!r}") lines.append("/rewind to rewind n turns (1 = undo last turn).") self._system("\n".join(lines)) return # /rewind : truncate last n turns try: n = int(args[0]) except ValueError: self._system("Usage: /rewind (1 = undo last turn)") return if n < 1 or n > len(turns): self._system(f"Invalid: range is 1-{len(turns)}") return # cut_at = index of the n-th turn from the end cut_at = turns[-n][0] removed = len(history) - cut_at history[:] = history[:cut_at] # Sync TUI messages: keep only messages before the corresponding user message real_user_indices = [i for i, msg in enumerate(session.messages) if msg.role == "user"] if n <= len(real_user_indices): cut_msg = real_user_indices[-n] session.messages = session.messages[:cut_msg] # Mark rewind in agentmain's working memory history try: session.agent.history.append(f"[USER]: /rewind {n}") except Exception: pass self._system(f"Rewound {n} turn(s). Removed {removed} history entries.") def _cmd_clear(self, args: list[str]) -> None: self.current.messages.clear(); self._refresh_all() def _cmd_close(self, args: list[str]) -> None: if len(self.sessions) <= 1: self._system("Cannot close the last session."); return del self.sessions[self.current_id] self.current_id = next(iter(self.sessions)) self._refresh_all() def _cmd_switch(self, args: list[str]) -> None: if not args: self._system("Usage: /switch ") return key = " ".join(args) target: Optional[int] = None if key.isdigit() and int(key) in self.sessions: target = int(key) else: for sid, session in self.sessions.items(): if session.name == key: target = sid break if target is None: self._system(f"No session found for {key!r}.") return self.current_id = target self._refresh_all() self._system(f"Switched to session #{target}.") def _cmd_sessions(self, args: list[str]) -> None: lines = [] for sid, session in self.sessions.items(): mark = "*" if sid == self.current_id else " " lines.append(f"{mark} #{sid} {session.name} [{session.status}] messages={len(session.messages)} task={session.current_task_id}") self._system("Sessions:\n" + "\n".join(lines)) def _cmd_status(self, args: list[str]) -> None: self._cmd_sessions(args) def _cmd_stop(self, args: list[str]) -> None: session = self.current try: session.agent.abort() session.status = "stopping" if session.status == "running" else session.status self._system(f"Stop signal sent to session #{session.agent_id}.") except Exception as exc: self._system(f"Stop failed: {exc}") self._refresh_all() def _cmd_llm(self, args: list[str]) -> None: session = self.current if args: try: session.agent.next_llm(int(args[0])) self._system(f"Switched model to #{int(args[0])}.") except Exception as exc: self._system(f"Model switch failed: {exc}") return try: rows = session.agent.list_llms() self._system("Models:\n" + "\n".join(f"{'*' if cur else ' '} {i}: {name}" for i, name, cur in rows)) except Exception as exc: self._system(f"Listing models failed: {exc}") def _system(self, text: str) -> None: if self.current_id is not None and self.current_id in self.sessions: self.current.messages.append(ChatMessage("system", text)) self._refresh_all() def _refresh_all(self) -> None: if not self.is_mounted: return self._refresh_sidebar() self._refresh_status() self._refresh_log() def _session_last_user_query(self, session: AgentSession) -> str: """Return the last user message content, truncated for sidebar display.""" for msg in reversed(session.messages): if msg.role == "user": text = msg.content.strip().replace("\n", " ") return self._truncate_display(text, 20) return "" def _session_last_summary(self, session: AgentSession) -> str: """Extract the last from the most recent assistant message.""" for msg in reversed(session.messages): if msg.role == "assistant" and msg.content: matches = re.findall(r"\s*(.*?)\s*", msg.content, re.DOTALL) if matches: text = matches[-1].strip().split("\n", 1)[0].replace("\n", " ") return self._truncate_display(text, 20) return "" @staticmethod def _truncate_display(text: str, max_width: int) -> str: """Truncate text by display width (CJK chars count as 2).""" import unicodedata width = 0 result = [] for ch in text: w = 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1 if width + w > max_width: result.append("…") break result.append(ch) width += w return "".join(result) def _refresh_sidebar(self) -> None: sidebar = self.query_one("#sidebar", Static) max_w = 26 # 30 - 2(border) - 2(padding) lines: list[str] = ["[b]Sessions[/b]", ""] for sid, session in self.sessions.items(): mark = "▶" if sid == self.current_id else " " last_q = self._session_last_user_query(session) last_s = self._session_last_summary(session) status_style = "green" if session.status == "running" else "dim" # Header line: "▶ #1 name status" — truncate name if needed prefix = f"{mark} #{sid} " suffix = f" {session.status}" name_max = max_w - len(prefix) - len(suffix) name_disp = self._truncate_display(session.name, max(name_max, 4)) lines.append(f"{prefix}{name_disp} [{status_style}]{session.status}[/{status_style}]") if last_q: lines.append(f" [dim]Q:{last_q}[/dim]") if last_s: lines.append(f" [dim]S:{last_s}[/dim]") lines.append("") lines.append("[dim]/new, /switch, Ctrl+N[/dim]") lines.append("[dim]I have memory, just say what you want[/dim]") sidebar.update("\n".join(lines)) def _refresh_status(self) -> None: status = self.query_one("#status", Static) if self.current_id is None: status.update("No session") return session = self.current try: model = session.agent.get_llm_name(model=True) except Exception: model = "unknown" status.update( f"[b]#{session.agent_id} {session.name}[/b] status={session.status} task={session.current_task_id} model={model}\n" "Enter message or /help. Per-task queue streaming is enabled (inc_out=True)." ) def action_toggle_fold(self) -> None: self.fold_mode = not self.fold_mode # Invalidate cached panels for assistant messages since fold state changed if self.current_id is not None: for msg in self.current.messages: if msg.role == "assistant": msg._rendered_panel = None self._refresh_log() mode_label = "folded" if self.fold_mode else "expanded" self.notify(f"Display mode: {mode_label} (Ctrl+F to toggle)") def _refresh_log(self) -> None: log = self.query_one("#log", RichLog) log.clear() if self.current_id is None: return all_msgs = self.current.messages # Limit to last 150 messages for performance if len(all_msgs) > 150: display_msgs = all_msgs[-150:] log.write(Text(f" ↑ {len(all_msgs) - 150} older messages hidden ↑", style="dim italic")) else: display_msgs = all_msgs # Collect recent task_ids to only expand the latest 3 tasks recent_task_ids: set[int] = set() if not self.fold_mode: seen: list[int] = [] for msg in reversed(display_msgs): if msg.role == "assistant" and msg.task_id not in seen: seen.append(msg.task_id) if len(seen) == 5: break recent_task_ids = set(seen) for msg in display_msgs: if msg.role == "user": if msg._rendered_panel is None: msg._rendered_panel = Panel(Markdown(msg.content), title="You", border_style="blue") log.write(msg._rendered_panel) elif msg.role == "assistant": if msg.done and msg._rendered_panel is not None: log.write(msg._rendered_panel) else: suffix = "" if msg.done else "\n▌" # Fold older tasks even in unfold mode to reduce render cost should_fold = self.fold_mode or (msg.task_id not in recent_task_ids) content = render_folded_text(msg.content) if should_fold else msg.content panel = Panel(Markdown(content + suffix), title=f"Agent task {msg.task_id}", border_style="green") if msg.done: msg._rendered_panel = panel log.write(panel) else: if msg._rendered_panel is None: msg._rendered_panel = Panel(Text(msg.content), title="System", border_style="yellow") log.write(msg._rendered_panel) def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Textual TUI for GenericAgent") return parser def main(argv: Optional[list[str]] = None) -> int: args = build_arg_parser().parse_args(argv) app = GenericAgentTUI() app.run() return 0 if __name__ == "__main__": raise SystemExit(main())