"""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())