diff --git a/.gitignore b/.gitignore index 3bd5e9b..2dbe9f1 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ __pycache__ !.crew/artifacts/ !.crew/graphs/ !.crew/artifacts/.gitkeep -!.crew/graphs/.gitkeep \ No newline at end of file +!.crew/graphs/.gitkeep.superpowers/ +.superpowers/ diff --git a/agentforge_harness/cli/commands.py b/agentforge_harness/cli/commands.py index b90e96c..9c9c095 100644 --- a/agentforge_harness/cli/commands.py +++ b/agentforge_harness/cli/commands.py @@ -17,7 +17,7 @@ from agentforge_harness.cli.command_result import CommandResult from agentforge_harness.config.config import Config from agentforge_harness.tools.builtin.todo import TodosTool -from agentforge_harness.ui.tui import TUI, get_console +from agentforge_harness.ui.plain import TUI, PlainTUI, get_console console = get_console() diff --git a/agentforge_harness/cli/run.py b/agentforge_harness/cli/run.py index 5172363..06c8fd9 100644 --- a/agentforge_harness/cli/run.py +++ b/agentforge_harness/cli/run.py @@ -9,7 +9,7 @@ from agentforge_harness.cli.report import build_session_report, format_session_report, report_to_json from agentforge_harness.cli.setup import run_setup from agentforge_harness.config.loader import load_config -from agentforge_harness.ui.tui import get_console +from agentforge_harness.ui.plain import get_console console = get_console() @@ -116,7 +116,8 @@ def report(session_id: str | None, json_output: bool, data_dir: Path | None) -> type=click.Path(exists=True, file_okay=False, path_type=Path), help="Current working directory", ) -def run(prompt: str | None, cwd: Path | None) -> None: +@click.option("--plain", is_flag=True, default=False, help="Use plain Rich renderer (no Textual TUI).") +def run(prompt: str | None, cwd: Path | None, plain: bool = False) -> None: """Start AgentForge in interactive or single-prompt mode.""" try: config = load_config(cwd) @@ -142,14 +143,17 @@ def run(prompt: str | None, cwd: Path | None) -> None: console.print(f"[error]Configuration Error : {error}[/error]") sys.exit(1) - cli_obj = CLI(config) - - if prompt: - result = asyncio.run(cli_obj.run_single(prompt)) - if result is None: - sys.exit(1) + if plain or prompt: + cli_obj = CLI(config) + if prompt: + result = asyncio.run(cli_obj.run_single(prompt)) + if result is None: + sys.exit(1) + else: + asyncio.run(cli_obj.run_interactive()) else: - asyncio.run(cli_obj.run_interactive()) + from agentforge_harness.ui.tui import run_tui + asyncio.run(run_tui(config=config)) @cli.command() diff --git a/agentforge_harness/cli/setup.py b/agentforge_harness/cli/setup.py index c4167d4..2e0e007 100644 --- a/agentforge_harness/cli/setup.py +++ b/agentforge_harness/cli/setup.py @@ -5,7 +5,7 @@ from rich.text import Text from rich import box from agentforge_harness.config.loader import get_config_dir -from agentforge_harness.ui.tui import get_console +from agentforge_harness.ui.plain import get_console PROVIDERS = ("openrouter", "openai", "anthropic", "custom") PROVIDER_LABELS = { diff --git a/agentforge_harness/ui/__init__.py b/agentforge_harness/ui/__init__.py index e69de29..b96dcaf 100644 --- a/agentforge_harness/ui/__init__.py +++ b/agentforge_harness/ui/__init__.py @@ -0,0 +1,4 @@ +from agentforge_harness.ui.tui import run_tui, AgentForgeTuiApp +from agentforge_harness.ui.plain import TUI as PlainTUI + +__all__ = ["run_tui", "AgentForgeTuiApp", "PlainTUI"] diff --git a/agentforge_harness/ui/adapter.py b/agentforge_harness/ui/adapter.py new file mode 100644 index 0000000..7d2c2ad --- /dev/null +++ b/agentforge_harness/ui/adapter.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from agentforge_harness.agent.events import AgentEvent, AgentEventType +from agentforge_harness.ui.state import TuiState + + +class TuiEventAdapter: + """Maps AgentEvent instances onto TuiState mutations.""" + + def __init__(self, state: TuiState) -> None: + self._state = state + + def apply(self, event: AgentEvent) -> None: + state = self._state + d = event.data + t = event.type + + if t == AgentEventType.AGENT_START: + state.running = True + + elif t == AgentEventType.AGENT_END: + state.running = False + state.finalize_assistant() + state.finalize_thinking() + + elif t == AgentEventType.TEXT_DELTA: + state.flush_assistant_delta(d.get("content", "")) + + elif t == AgentEventType.TEXT_COMPLETE: + state.finalize_assistant() + + elif t == AgentEventType.THINKING_DELTA: + state.flush_thinking_delta(d.get("content", "")) + + elif t == AgentEventType.TOOL_CALL_START: + state.add_tool_item( + call_id=d.get("call_id", ""), + name=d.get("name", "unknown"), + args=d.get("arguments", {}), + ) + + elif t == AgentEventType.TOOL_CALL_COMPLETE: + state.update_tool_result( + call_id=d.get("call_id", ""), + output=str(d.get("output", "")), + success=bool(d.get("success", False)), + ) + + elif t == AgentEventType.AGENT_ERROR: + state.running = False + state.add_error(d.get("error", "Unknown error")) diff --git a/agentforge_harness/ui/autocomplete.py b/agentforge_harness/ui/autocomplete.py new file mode 100644 index 0000000..5433d8e --- /dev/null +++ b/agentforge_harness/ui/autocomplete.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class CompletionItem: + display: str + replacement: str + start: int + end: int + description: str = "" + + def apply(self, text: str) -> str: + return f"{text[:self.start]}{self.replacement}{text[self.end:]}" + + +@dataclass(frozen=True, slots=True) +class CompletionState: + items: tuple[CompletionItem, ...] = () + selected_index: int = 0 + + @property + def selected(self) -> CompletionItem | None: + if not self.items: + return None + return self.items[self.selected_index] + + def select_next(self) -> CompletionState: + if not self.items: + return self + return CompletionState( + items=self.items, + selected_index=(self.selected_index + 1) % len(self.items), + ) + + def select_previous(self) -> CompletionState: + if not self.items: + return self + return CompletionState( + items=self.items, + selected_index=(self.selected_index - 1) % len(self.items), + ) + + +def build_completion_state( + text: str, + *, + commands: list[str], + cwd: Path | None = None, +) -> CompletionState: + if not text.startswith("/"): + return CompletionState() + prefix = text.lower() + matched = [ + CompletionItem( + display=cmd, + replacement=cmd, + start=0, + end=len(text), + ) + for cmd in sorted(commands) + if cmd.lower().startswith(prefix) + ] + return CompletionState(items=tuple(matched)) diff --git a/agentforge_harness/ui/config.py b/agentforge_harness/ui/config.py new file mode 100644 index 0000000..84155ff --- /dev/null +++ b/agentforge_harness/ui/config.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class TuiRoleStyle: + border: str + body: str + + +@dataclass(frozen=True, slots=True) +class TuiTheme: + name: str + screen_background: str + screen_text: str + accent: str + prompt_border_focused: str + role_styles: dict[str, TuiRoleStyle] + + def css_variables(self) -> dict[str, str]: + return { + "af-background": self.screen_background, + "af-text": self.screen_text, + "af-accent": self.accent, + "af-prompt-border": self.prompt_border_focused, + } + + +DEFAULT_THEME = TuiTheme( + name="af-dark", + screen_background="#1a1a2e", + screen_text="#e0e0e0", + accent="#4ec9b0", + prompt_border_focused="#4ec9b0", + role_styles={ + "user": TuiRoleStyle(border="#7c8ea6", body="#d8dee9"), + "assistant": TuiRoleStyle(border="#4ec9b0", body="#d8dee9"), + "tool": TuiRoleStyle(border="#8a7a52", body="#cbd5e1"), + "error": TuiRoleStyle(border="#ff4f4f", body="#ffb4b4"), + "thinking": TuiRoleStyle(border="#4b5563", body="#9ca3af"), + "status": TuiRoleStyle(border="#555577", body="#aaaacc"), + }, +) + + +@dataclass(frozen=True, slots=True) +class TuiKeybindings: + cancel: str = "escape" + queue_steer: str = "alt+enter" + toggle_thinking: str = "ctrl+t" + toggle_tool_results: str = "ctrl+o" + session_picker: str = "ctrl+r" + branch_picker: str = "ctrl+b" + quit: str = "ctrl+d" + accept_completion: str = "tab" + completion_next: str = "down" + completion_previous: str = "up" diff --git a/agentforge_harness/ui/plain.py b/agentforge_harness/ui/plain.py new file mode 100644 index 0000000..24411c0 --- /dev/null +++ b/agentforge_harness/ui/plain.py @@ -0,0 +1,1196 @@ +from __future__ import annotations + +from pathlib import Path +import json +from typing import Any +from rich.console import Console +from rich.theme import Theme +from rich.rule import Rule +from rich.text import Text +from rich.panel import Panel +from rich.table import Table +from rich import box +from rich.prompt import Prompt +from rich.console import Group +from rich.align import Align +from rich.syntax import Syntax +from rich.markdown import Markdown +from agentforge_harness.config.config import Config +from agentforge_harness.tools.base import ToolConfirmation +from agentforge_harness.utils.paths import display_path_rel_to_cwd +import re +from importlib.metadata import version, PackageNotFoundError + +from agentforge_harness.utils.text import truncate_text + +try: + AGENTFORGE_VERSION = version("agentforge-harness") +except PackageNotFoundError: + AGENTFORGE_VERSION = "0.1.0" + +AGENTFORGE_ASCII = r""" + █████╗ ██████╗ ███████╗███╗ ██╗████████╗ + ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ + ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ + ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ + ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ + ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ + ███████╗ ██████╗ ██████╗ ██████╗ ███████╗ + ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ + █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ + ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ + ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ + ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ +""".strip("\n") + +AGENT_THEME = Theme( + { + # General + "info": "cyan", + "warning": "yellow", + "error": "bright_red bold", + "success": "green", + "dim": "dim", + "muted": "grey50", + "border": "grey35", + "highlight": "bold cyan", + # Roles + "user": "bright_blue bold", + "assistant": "bright_white", + # Tools + "tool": "bright_magenta bold", + "tool.read": "cyan", + "tool.write": "yellow", + "tool.shell": "magenta", + "tool.network": "bright_blue", + "tool.memory": "green", + "tool.mcp": "bright_cyan", + # Code / blocks + "code": "white", + } +) + +_console: Console | None = None + + +def get_console() -> Console: + global _console + if _console is None: + _console = Console(theme=AGENT_THEME, highlight=False) + + return _console + + +class TUI: + def __init__( + self, + config: Config, + console: Console | None = None, + ) -> None: + self.console = console or get_console() + self._assistant_stream_open = False + self._tool_args_by_call_id: dict[str, dict[str, Any]] = {} + self.config = config + self.cwd = self.config.cwd + self._max_block_tokens = 2500 + + def begin_assistant(self) -> None: + self.console.print() + self.console.print(Rule(Text("Assistant", style="assistant"))) + self._assistant_stream_open = True + + def end_assistant(self) -> None: + if self._assistant_stream_open: + self.console.print() + self._assistant_stream_open = False + + def stream_assistant_delta(self, content: str) -> None: + self.console.print(content, end="", markup=False) + + def _ordered_args(self, tool_name: str, args: dict[str, Any]) -> list[tuple]: + _PREFERRED_ORDER = { + "read_file": ["path", "offset", "limit"], + "write_file": ["path", "create_directories", "content"], + "edit": ["path", "replace_all", "old_string", "new_string"], + "shell": ["command", "timeout", "cwd"], + "list_dir": ["path", "include_hidden"], + "grep": ["path", "case_insensitive", "pattern"], + "glob": ["path", "pattern"], + "todos": ["id", "action", "content"], + "memory": ["action", "key", "value"], + } + + preferred = _PREFERRED_ORDER.get(tool_name, []) + ordered: list[tuple[str, Any]] = [] + seen = set() + + for key in preferred: + if key in args: + ordered.append((key, args[key])) + seen.add(key) + + remaining_keys = set(args.keys() - seen) + ordered.extend((key, args[key]) for key in remaining_keys) + + return ordered + + def _render_args_table(self, tool_name: str, args: dict[str, Any]) -> Table: + table = Table.grid(padding=(0, 1)) + table.add_column(style="muted", justify="right", no_wrap=True) + table.add_column(style="code", overflow="fold") + + for key, value in self._ordered_args(tool_name, args): + if isinstance(value, str): + if key in {"content", "old_string", "new_string"}: + line_count = len(value.splitlines()) or 0 + byte_count = len(value.encode("utf-8", errors="replace")) + value = f"<{line_count} lines • {byte_count} bytes>" + + if isinstance(value, bool): + value = str(value).lower() + elif isinstance(value, (int, float)): + value = str(value) + elif isinstance(value, dict): + value = str(value) + elif not isinstance(value, str): + value = repr(value) + + table.add_row(key, value) + + return table + + def tool_call_start( + self, + call_id: str, + name: str, + tool_kind: str | None, + arguments: dict[str, Any], + ) -> None: + self._tool_args_by_call_id[call_id] = arguments + border_style = f"tool.{tool_kind}" if tool_kind else "tool" + + title = Text.assemble( + ("⏺ ", "muted"), + (name, "tool"), + (" ", "muted"), + (f"#{call_id[:8]}", "muted"), + ) + + display_args = dict(arguments) + for key in ("path", "cwd"): + val = display_args.get(key) + if isinstance(val, str) and self.cwd: + display_args[key] = str(display_path_rel_to_cwd(val, self.cwd)) + + panel = Panel( + ( + self._render_args_table(name, display_args) + if display_args + else Text( + "(no args)", + style="muted", + ) + ), + title=title, + title_align="left", + subtitle=Text("running", style="muted"), + subtitle_align="right", + border_style=border_style, + box=box.ROUNDED, + padding=(1, 2), + ) + self.console.print() + self.console.print(panel) + + def _extract_read_file_code(self, text: str) -> tuple[int, str] | None: + body = text + if "\n\n" in text: + possible_header, possible_body = text.split("\n\n", 1) + if not re.match(r"^\s*\d+\|", possible_header) and re.match( + r"^\s*\d+\|", + possible_body, + ): + body = possible_body + + code_lines: list[str] = [] + start_line: int | None = None + + for line in body.splitlines(): + # 1|def main(): + # 2| print() + m = re.match(r"^\s*(\d+)\|(.*)$", line) + if not m: + return None + line_no = int(m.group(1)) + if start_line is None: + start_line = line_no + code_lines.append(m.group(2)) + + if start_line is None: + return None + + return start_line, "\n".join(code_lines) + + def _guess_language(self, path: str | None) -> str: + if not path: + return "text" + suffix = Path(path).suffix.lower() + return { + ".py": "python", + ".js": "javascript", + ".jsx": "jsx", + ".ts": "typescript", + ".tsx": "tsx", + ".json": "json", + ".toml": "toml", + ".yaml": "yaml", + ".yml": "yaml", + ".md": "markdown", + ".sh": "bash", + ".bash": "bash", + ".zsh": "bash", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".kt": "kotlin", + ".swift": "swift", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".hpp": "cpp", + ".css": "css", + ".html": "html", + ".xml": "xml", + ".sql": "sql", + }.get(suffix, "text") + + def print_welcome(self, title: str, lines: list[str], mode: str | None = None) -> None: + logo_lines = AGENTFORGE_ASCII.split("\n") + colored_logo_lines = [] + for i, line in enumerate(logo_lines): + colors = ["bold cyan", "cyan", "bright_blue", "blue", "bright_magenta", "magenta"] + c = colors[i % len(colors)] + colored_logo_lines.append(Text(line, style=c)) + logo_text = Text("\n").join(colored_logo_lines) + main_logo = Align.center(logo_text) + + info = Group( + Text(""), + Text(f" AgentForge v{AGENTFORGE_VERSION}", style="bold white"), + Text(""), + ) + + table = Table.grid(padding=(0, 2)) + table.add_column(style="muted", no_wrap=True) + table.add_column(style="code", overflow="fold") + + for line in lines: + if ":" in line: + key, value = line.split(":", 1) + table.add_row(key.strip(), value.strip()) + else: + table.add_row("", line) + + if mode: + mode_style = "tool.read" if mode == "plan" else "tool.shell" + table.add_row("mode", f"[{mode_style}]{mode.upper()}[/{mode_style}]") + + self.console.print( + Panel( + Group( + main_logo, + info, + table, + ), + title=None, + subtitle=Text("type /help for commands", style="muted"), + subtitle_align="right", + border_style="bright_cyan", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + self.console.print( + Panel( + Text("AgentForge ready — what are we building today?", style="cyan"), + border_style="border", + box=box.ROUNDED, + padding=(0, 2), + ) + ) + + def show_mode(self, mode: str) -> None: + style = "tool.read" if mode == "plan" else "tool.shell" + self.console.print( + Panel( + Text(f"Switched to {mode.upper()} mode", style="code"), + title=Text("Mode", style=style), + title_align="left", + border_style=style, + box=box.ROUNDED, + padding=(0, 2), + ) + ) + + def show_notice(self, message: str, title: str = "Status") -> None: + self.console.print( + Panel( + Text(message, style="code"), + title=Text(title, style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(0, 2), + ) + ) + + def show_error(self, message: str, title: str = "Error") -> None: + self.console.print( + Panel( + Text(message, style="error"), + title=Text(title, style="error"), + title_align="left", + border_style="error", + box=box.ROUNDED, + padding=(0, 2), + ) + ) + + def show_config(self, config: dict[str, Any]) -> None: + table = Table.grid(padding=(0, 2)) + table.add_column(style="muted", no_wrap=True) + table.add_column(style="code", overflow="fold") + + _SKIP_KEYS = {"mcp_servers", "hooks", "subagents", "skill_roots", "shell_environment"} + _SECTION_KEYS = {"model", "approval", "max_turns", "max_tool_output_tokens", "cwd"} + + def flatten(d: dict[str, Any], prefix: str = "") -> list[tuple[str, str]]: + rows: list[tuple[str, str]] = [] + for k, v in d.items(): + key = f"{prefix}.{k}" if prefix else k + if isinstance(v, dict) and k not in _SKIP_KEYS: + rows.extend(flatten(v, key)) + elif not isinstance(v, (dict, list)): + rows.append((key, str(v))) + return rows + + rows = flatten(config) + for key, value in rows: + table.add_row(key, value) + + self.console.print( + Panel( + table, + title=Text("Configuration", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_key_values( + self, + title: str, + rows: list[tuple[str, str]], + *, + border_style: str = "border", + footer: str = "", + ) -> None: + table = Table.grid(padding=(0, 2)) + table.add_column(style="muted", no_wrap=True) + table.add_column(style="code", overflow="fold") + + for key, value in rows: + table.add_row(key, value) + + blocks: list[Any] = [table if rows else Text("No values", style="muted")] + if footer: + blocks.append(Text(footer, style="muted")) + + self.console.print( + Panel( + Group(*blocks), + title=Text(title, style="highlight"), + title_align="left", + border_style=border_style, + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_models( + self, + provider: str, + current_model: str, + models: list[Any], + live: bool, + message: str = "", + page: int = 1, + total_pages: int = 1, + total_count: int | None = None, + ) -> None: + table = Table( + box=box.SIMPLE, + show_header=True, + header_style="highlight", + expand=True, + ) + table.add_column("Model", style="code", overflow="fold") + table.add_column("Source", style="muted", no_wrap=True) + table.add_column("Note", style="muted", overflow="fold") + + for model in models: + name = str(getattr(model, "name", "")) + label = f"{name} (current)" if name == current_model else name + table.add_row( + label, + str(getattr(model, "source", "")), + str(getattr(model, "note", "")), + ) + + status = "live" if live else "suggestions" + count_text = f", {total_count} total" if total_count is not None else "" + subtitle = f"{provider} models - {status} - page {page}/{total_pages}{count_text}" + if message: + subtitle = f"{subtitle}\n{message}\nUse /model to switch. Use /models --page N for more." + else: + subtitle = f"{subtitle}\nUse /model to switch. Use /models --page N for more." + + self.console.print( + Panel( + Group(Text(subtitle, style="muted"), table if models else Text("No models found", style="muted")), + title=Text("Models", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_tools(self, tools: list[Any]) -> None: + table = Table( + box=box.SIMPLE, + show_header=True, + header_style="highlight", + expand=True, + ) + table.add_column("Tool", style="tool", no_wrap=True) + table.add_column("Kind", style="muted", no_wrap=True) + table.add_column("Description", style="code", overflow="fold") + + for tool in sorted(tools, key=lambda item: (item.kind.value, item.name)): + description = tool.description + if len(description) > 120: + description = description[:117] + "..." + table.add_row(tool.name, tool.kind.value, description) + + self.console.print( + Panel( + table if tools else Text("No tools registered", style="muted"), + title=Text("Tools", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_skills( + self, + skills: list[Any], + active_skills: list[str] | None = None, + ) -> None: + active = set(active_skills or []) + table = Table( + box=box.SIMPLE, + show_header=True, + header_style="highlight", + expand=True, + ) + table.add_column("Skill", style="tool", no_wrap=True) + table.add_column("State", style="muted", no_wrap=True) + table.add_column("Description", style="code", overflow="fold") + table.add_column("Tools", style="muted", overflow="fold") + + for skill in sorted(skills, key=lambda item: item.name): + state = "active" if skill.name in active else "available" + tools = ", ".join(skill.allowed_tools or []) + table.add_row(skill.name, state, skill.description, tools or "-") + + self.console.print( + Panel( + table if skills else Text("No skills discovered", style="muted"), + title=Text("Skills index", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_mcp_servers(self, servers: list[dict[str, Any]]) -> None: + table = Table( + box=box.SIMPLE, + show_header=True, + header_style="highlight", + expand=True, + ) + table.add_column("Server", style="tool.mcp", no_wrap=True) + table.add_column("Status", style="code", no_wrap=True) + table.add_column("Tools", style="muted", justify="right") + + for server in servers: + table.add_row( + str(server.get("name", "")), + str(server.get("status", "")), + str(server.get("tools", 0)), + ) + + self.console.print( + Panel( + table if servers else Text("No MCP servers configured", style="muted"), + title=Text("MCP Servers", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_todos_list(self, todos: list[tuple[str, str]]) -> None: + if not todos: + self.console.print( + Panel( + Text("No active todos", style="muted"), + title=Text("Todos", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(0, 2), + ) + ) + return + + table = Table.grid(padding=(0, 2)) + table.add_column(style="muted", no_wrap=True) + table.add_column(style="code", overflow="fold") + + for todo_id, content in todos: + table.add_row(f"[{todo_id}]", content) + + self.console.print( + Panel( + table, + title=Text(f"Todos ({len(todos)})", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_branch_choices(self, choices: list[dict[str, Any]]) -> None: + if not choices: + self.console.print( + Panel( + Text("No branchable messages in current session", style="muted"), + title=Text("Branch / Rewind", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(0, 2), + ) + ) + return + + table = Table( + box=box.SIMPLE, + show_header=True, + header_style="highlight", + expand=True, + ) + table.add_column("#", style="muted", no_wrap=True, width=4) + table.add_column("Role", style="code", no_wrap=True, width=12) + table.add_column("Preview", style="assistant", overflow="fold") + table.add_column("ID", style="muted", no_wrap=True, width=14) + + for choice in choices: + pos = str(choice.get("position", "")) + role = str(choice.get("role", "")) + preview = str(choice.get("preview", "")) + entry_id = str(choice.get("id", "")) + table.add_row(pos, role, preview, entry_id[:12] + "…") + + self.console.print( + Panel( + table, + title=Text(f"Branch / Rewind ({len(choices)} points)", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + subtitle=Text("Usage: /branch <#> or /branch ", style="muted"), + ) + ) + + def show_stats(self, stats: dict[str, Any]) -> None: + table = Table.grid(padding=(0, 2)) + table.add_column(style="muted", no_wrap=True) + table.add_column(style="code", justify="right") + + for key, value in stats.items(): + table.add_row(key.replace("_", " "), str(value)) + + self.console.print( + Panel( + table, + title=Text("Session Stats", style="highlight"), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_sessions( + self, + sessions: list[dict[str, Any]], + *, + page: int = 1, + total_pages: int = 1, + total_count: int | None = None, + ) -> None: + table = Table( + box=box.SIMPLE, + show_header=True, + header_style="highlight", + expand=True, + ) + table.add_column("Name", style="highlight", no_wrap=True) + table.add_column("Session ID", style="code", overflow="fold") + table.add_column("Updated", style="muted", no_wrap=True) + table.add_column("Turns", style="code", justify="right") + table.add_column("Mode", style="muted", no_wrap=True) + + for session in sessions: + sid = str(session.get("session_id", "")) + name = str(session.get("name", "")) or sid[:8] + table.add_row( + name, + sid, + str(session.get("updated_at", "")), + str(session.get("turn_count", 0)), + str(session.get("mode", "")), + ) + + self.console.print( + Panel( + table if sessions else Text("No saved sessions", style="muted"), + title=Text( + f"Saved Sessions {page}/{total_pages}" + + (f" ({total_count})" if total_count is not None else ""), + style="highlight", + ), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def show_checkpoints( + self, + checkpoints: list[dict[str, Any]], + *, + page: int = 1, + total_pages: int = 1, + total_count: int | None = None, + ) -> None: + table = Table( + box=box.SIMPLE, + show_header=True, + header_style="highlight", + expand=True, + ) + table.add_column("Checkpoint", style="code", overflow="fold") + table.add_column("Session", style="muted", overflow="fold") + table.add_column("Turns", style="code", justify="right") + table.add_column("Mode", style="muted", no_wrap=True) + table.add_column("CWD", style="code", overflow="fold") + + for checkpoint in checkpoints: + table.add_row( + str(checkpoint.get("checkpoint_id", "")), + str(checkpoint.get("session_id", "")), + str(checkpoint.get("turn_count", 0)), + str(checkpoint.get("mode", "")), + str(checkpoint.get("cwd", "")), + ) + + self.console.print( + Panel( + table if checkpoints else Text("No checkpoints", style="muted"), + title=Text( + f"Checkpoints {page}/{total_pages}" + + (f" ({total_count})" if total_count is not None else ""), + style="highlight", + ), + title_align="left", + border_style="border", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + def tool_call_complete( + self, + call_id: str, + name: str, + tool_kind: str | None, + success: bool, + output: str, + error: str | None, + metadata: dict[str, Any] | None, + diff: str | None = None, + truncated: bool = False, + exit_code: int | None = None, + ) -> None: + border_style = f"tool.{tool_kind}" if tool_kind else "tool" + status_icon = "✓" if success else "✗" + status_style = "success" if success else "error" + + title = Text.assemble( + (f"{status_icon} ", status_style), + (name, "tool"), + (" ", "muted"), + (f"#{call_id[:8]}", "muted"), + ) + + args = self._tool_args_by_call_id.get(call_id, {}) + + primary_path = None + blocks = [] + if isinstance(metadata, dict) and isinstance(metadata.get("path"), str): + primary_path = metadata.get("path") + + if name == "read_file" and success: + if primary_path: + extracted = self._extract_read_file_code(output) + + if extracted: + start_line, code = extracted + shown_start = metadata.get("shown_start") + shown_end = metadata.get("shown_end") + total_lines = metadata.get("total_lines") + has_trailing_newline = metadata.get("has_trailing_newline") + pl = self._guess_language(primary_path) + + header_parts = [display_path_rel_to_cwd(primary_path, self.cwd)] + + if shown_start and shown_end and total_lines: + header_parts.append( + f"lines {shown_start}-{shown_end} of {total_lines}" + ) + if has_trailing_newline is False and shown_end == total_lines: + header_parts.append("no trailing newline") + + blocks.append(Text(" • ".join(header_parts), style="muted")) + blocks.append( + Syntax( + code, + pl, + theme="monokai", + line_numbers=True, + start_line=start_line, + word_wrap=True, + ) + ) + else: + output_display = truncate_text( + output, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=False, + ) + ) + else: + output_display = truncate_text( + output, + "", + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=False, + ) + ) + elif name in {"write_file", "append_file", "edit", "apply_patch"} and success and diff: + output_line = output.strip() if output.strip() else "Completed" + blocks.append(Text(output_line, style="muted")) + if metadata.get("fallback"): + blocks.append(Text("applied with line-based fallback", style="warning")) + diff_text = diff + diff_display = truncate_text( + diff_text, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + diff_display, + "diff", + theme="monokai", + word_wrap=True, + ) + ) + elif name == "shell" and success: + command = args.get("command") + if isinstance(command, str) and command.strip(): + blocks.append(Text(f"$ {command.strip()}", style="muted")) + + if exit_code is not None: + blocks.append(Text(f"exit_code={exit_code}", style="muted")) + + output_display = truncate_text( + output, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + elif name == "list_dir" and success: + entries = metadata.get("entries") + path = metadata.get("path") + summary = [] + if isinstance(path, str): + summary.append(path) + + if isinstance(entries, int): + summary.append(f"{entries} entries") + + if summary: + blocks.append(Text(" • ".join(summary), style="muted")) + + output_display = truncate_text( + output, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + elif name == "grep" and success: + matches = metadata.get("matches") + files_searched = metadata.get("files_searched") + summary = [] + if isinstance(matches, int): + summary.append(f"{matches} matches") + if isinstance(files_searched, int): + summary.append(f"searched {files_searched} files") + + if summary: + blocks.append(Text(" • ".join(summary), style="muted")) + + output_display = truncate_text( + output, self.config.model_name, self._max_block_tokens + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + elif name == "glob" and success: + matches = metadata.get("matches") + if isinstance(matches, int): + blocks.append(Text(f"{matches} matches", style="muted")) + + output_display = truncate_text( + output, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + elif name == "web_search" and success: + results = metadata.get("results") + query = args.get("query") + summary = [] + if isinstance(query, str): + summary.append(query) + if isinstance(results, int): + summary.append(f"{results} results") + + if summary: + blocks.append(Text(" • ".join(summary), style="muted")) + + output_display = truncate_text( + output, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + elif name == "web_fetch" and success: + status_code = metadata.get("status_code") + content_length = metadata.get("content_length") + url = args.get("url") + summary = [] + if isinstance(status_code, int): + summary.append(str(status_code)) + if isinstance(content_length, int): + summary.append(f"{content_length} bytes") + if isinstance(url, str): + summary.append(url) + + if summary: + blocks.append(Text(" • ".join(summary), style="muted")) + + output_display = truncate_text( + output, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + elif name == "todos" and success: + output_display = truncate_text( + output, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + elif name == "memory" and success: + action = args.get("action") + key = args.get("key") + found = metadata.get("found") + summary = [] + if isinstance(action, str) and action: + summary.append(action) + if isinstance(key, str) and key: + summary.append(key) + if isinstance(found, bool): + summary.append("found" if found else "missing") + + if summary: + blocks.append(Text(" • ".join(summary), style="muted")) + output_display = truncate_text( + output, + self.config.model_name, + self._max_block_tokens, + ) + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + else: + if error and not success: + blocks.append(Text(error, style="error")) + + output_display = truncate_text( + output, self.config.model_name, self._max_block_tokens + ) + if output_display.strip(): + blocks.append( + Syntax( + output_display, + "text", + theme="monokai", + word_wrap=True, + ) + ) + else: + blocks.append(Text("(no output)", style="muted")) + + if truncated: + blocks.append(Text("note: tool output was truncated", style="warning")) + + panel = Panel( + Group( + *blocks, + ), + title=title, + title_align="left", + subtitle=Text("done" if success else "failed", style=status_style), + subtitle_align="right", + border_style=border_style, + box=box.ROUNDED, + padding=(1, 2), + ) + self.console.print() + self.console.print(panel) + + def handle_confirmation(self, confirmation: ToolConfirmation) -> bool: + output = [ + Text(confirmation.tool_name, style="tool"), + Text(confirmation.description, style="code"), + ] + + if confirmation.command: + output.append(Text(f"$ {confirmation.command}", style="warning")) + + if confirmation.diff: + diff_text = confirmation.diff.to_diff() + output.append( + Syntax( + diff_text, + "diff", + theme="monokai", + word_wrap=True, + ) + ) + else: + diff_text = confirmation.get_diff_text() + if diff_text: + output.append( + Syntax( + diff_text, + "diff", + theme="monokai", + word_wrap=True, + ) + ) + + self.console.print() + self.console.print( + Panel( + Group(*output), + title=Text("Approval required", style="warning"), + title_align="left", + border_style="warning", + box=box.ROUNDED, + padding=(1, 2), + ) + ) + + response = Prompt.ask( + "\nApprove?", choices=["y", "n", "yes", "no"], default="n" + ) + + return response.lower() in {"y", "yes"} + + def show_export(self, markdown: str, path: str) -> None: + self.console.print( + Panel( + Text(f"Session exported to {path}", style="success"), + title=Text("Export", style="highlight"), + title_align="left", + border_style="success", + box=box.ROUNDED, + padding=(0, 2), + ) + ) + + def show_help(self) -> None: + help_text = """ +## Commands + +- `/help` - Show this help +- `/exit` or `/quit` - Exit the agent +- `/new` - Start a fresh session (clears conversation) +- `/reload` - Reload config from disk +- `/version` - Show AgentForge version +- `/retry` - Resend the last user message +- `/history [n]` - Show last N messages (default 10) +- `/report` - Show session summary report +- `/clear` - Clear conversation history +- `/config` - Show current configuration +- `/doctor` - Check local config and runtime readiness +- `/doctor fix` - Apply safe doctor fixes +- `/provider [name]` - Show or switch provider +- `/models` - List model suggestions for the current provider +- `/model list` - Alias for `/models` +- `/model [name]` - Show or change the model +- `/fallbacks` - Show or edit fallback model chain +- `/paths` - Show config, data, session, checkpoint, and skill paths +- `/compact` - Compact older context now +- `/errors` - Show recent model/tool errors +- `/approval ` - Change approval mode +- `/export` - Export session as markdown (saves to ./session-{id}.md) +- `/stats` - Show session statistics +- `/todos` - Show active todos +- `/todos --clear` - Clear all todos +- `/tools` - List available tools +- `/skills` - List available skills +- `/skill ` - Activate a skill +- `/unskill ` - Deactivate a skill +- `/mcp` - Show MCP server status +- `/name` - Show or set session name (e.g. `/name my-session`) +- `/plan` - Switch to plan mode (read-only, for designing a plan) +- `/build` - Switch to build mode (full tool access, for implementing) +- `/save` - Save current session +- `/checkpoint` - Create a checkpoint +- `/checkpoints` - List available checkpoints +- `/restore ` - Restore a checkpoint +- `/sessions` - List saved sessions +- `/resume ` - Resume a saved session +- `/branch` - Show branchable messages; `/branch ` rewinds to that message +- `/rewind ` - Alias for `/branch ` +- `/steer ` - Queue a steering prompt to be injected at the next tool checkpoint +- `/follow-up ` - Queue a follow-up message to be sent after the current turn ends + +## Tips + +- Just type your message to chat with the agent +- The agent can read, write, and execute code +- Some operations require approval (can be configured) +- Use `/plan` to design a plan before implementing with `/build` +""" + self.console.print(Markdown(help_text)) + + +# Alias for explicit import from the plain renderer path +PlainTUI = TUI diff --git a/agentforge_harness/ui/state.py b/agentforge_harness/ui/state.py new file mode 100644 index 0000000..18ff863 --- /dev/null +++ b/agentforge_harness/ui/state.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Literal + +ChatItemRole = Literal["user", "assistant", "tool", "error", "status", "thinking"] + + +@dataclass +class ChatItem: + role: ChatItemRole + text: str + tool_call_id: str = "" + always_show: bool = False + + +@dataclass +class TuiState: + items: list[ChatItem] = field(default_factory=list) + assistant_buffer: str = "" + thinking_buffer: str = "" + running: bool = False + show_thinking: bool = False + show_tool_results: bool = True + + def add_user_message(self, text: str) -> None: + self.items.append(ChatItem(role="user", text=text)) + + def flush_assistant_delta(self, delta: str) -> None: + self.assistant_buffer += delta + existing = [i for i in self.items if i.role == "assistant"] + if existing: + existing[-1].text = self.assistant_buffer + else: + self.items.append(ChatItem(role="assistant", text=self.assistant_buffer)) + + def finalize_assistant(self) -> None: + self.assistant_buffer = "" + + def flush_thinking_delta(self, delta: str) -> None: + self.thinking_buffer += delta + existing = [i for i in self.items if i.role == "thinking"] + if existing: + existing[-1].text = self.thinking_buffer + else: + self.items.append(ChatItem(role="thinking", text=self.thinking_buffer)) + + def finalize_thinking(self) -> None: + self.thinking_buffer = "" + + def add_tool_item(self, call_id: str, name: str, args: dict) -> None: + text = f"[{name}] {json.dumps(args, ensure_ascii=False)[:200]}" + self.items.append(ChatItem(role="tool", text=text, tool_call_id=call_id)) + + def update_tool_result(self, call_id: str, output: str, success: bool) -> None: + for item in reversed(self.items): + if item.role == "tool" and item.tool_call_id == call_id: + status = "✓" if success else "✗" + item.text = item.text.split("\n")[0] + f"\n{status} {output[:300]}" + return + + def add_error(self, text: str) -> None: + self.items.append(ChatItem(role="error", text=text)) + + def add_status(self, text: str) -> None: + self.items.append(ChatItem(role="status", text=text)) + + def toggle_thinking(self) -> None: + self.show_thinking = not self.show_thinking + + def toggle_tool_results(self) -> None: + self.show_tool_results = not self.show_tool_results + + def clear(self) -> None: + self.items.clear() + self.assistant_buffer = "" + self.thinking_buffer = "" + self.running = False diff --git a/agentforge_harness/ui/tui.py b/agentforge_harness/ui/tui.py index d50e684..177f072 100644 --- a/agentforge_harness/ui/tui.py +++ b/agentforge_harness/ui/tui.py @@ -1,1190 +1,392 @@ -from pathlib import Path -import json -from typing import Any -from rich.console import Console -from rich.theme import Theme -from rich.rule import Rule -from rich.text import Text -from rich.panel import Panel -from rich.table import Table -from rich import box -from rich.prompt import Prompt -from rich.console import Group -from rich.align import Align -from rich.syntax import Syntax -from rich.markdown import Markdown -from agentforge_harness.config.config import Config -from agentforge_harness.tools.base import ToolConfirmation -from agentforge_harness.utils.paths import display_path_rel_to_cwd -import re -from importlib.metadata import version, PackageNotFoundError - -from agentforge_harness.utils.text import truncate_text - -try: - AGENTFORGE_VERSION = version("agentforge-harness") -except PackageNotFoundError: - AGENTFORGE_VERSION = "0.1.0" - -AGENTFORGE_ASCII = r""" - █████╗ ██████╗ ███████╗███╗ ██╗████████╗ - ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝ - ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ - ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ - ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ - ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ - ███████╗ ██████╗ ██████╗ ██████╗ ███████╗ - ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ - █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ - ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ - ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ - ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ -""".strip("\n") - -AGENT_THEME = Theme( - { - # General - "info": "cyan", - "warning": "yellow", - "error": "bright_red bold", - "success": "green", - "dim": "dim", - "muted": "grey50", - "border": "grey35", - "highlight": "bold cyan", - # Roles - "user": "bright_blue bold", - "assistant": "bright_white", - # Tools - "tool": "bright_magenta bold", - "tool.read": "cyan", - "tool.write": "yellow", - "tool.shell": "magenta", - "tool.network": "bright_blue", - "tool.memory": "green", - "tool.mcp": "bright_cyan", - # Code / blocks - "code": "white", - } -) - -_console: Console | None = None - - -def get_console() -> Console: - global _console - if _console is None: - _console = Console(theme=AGENT_THEME, highlight=False) - - return _console - - -class TUI: - def __init__( - self, - config: Config, - console: Console | None = None, - ) -> None: - self.console = console or get_console() - self._assistant_stream_open = False - self._tool_args_by_call_id: dict[str, dict[str, Any]] = {} - self.config = config - self.cwd = self.config.cwd - self._max_block_tokens = 2500 - - def begin_assistant(self) -> None: - self.console.print() - self.console.print(Rule(Text("Assistant", style="assistant"))) - self._assistant_stream_open = True - - def end_assistant(self) -> None: - if self._assistant_stream_open: - self.console.print() - self._assistant_stream_open = False - - def stream_assistant_delta(self, content: str) -> None: - self.console.print(content, end="", markup=False) - - def _ordered_args(self, tool_name: str, args: dict[str, Any]) -> list[tuple]: - _PREFERRED_ORDER = { - "read_file": ["path", "offset", "limit"], - "write_file": ["path", "create_directories", "content"], - "edit": ["path", "replace_all", "old_string", "new_string"], - "shell": ["command", "timeout", "cwd"], - "list_dir": ["path", "include_hidden"], - "grep": ["path", "case_insensitive", "pattern"], - "glob": ["path", "pattern"], - "todos": ["id", "action", "content"], - "memory": ["action", "key", "value"], - } - - preferred = _PREFERRED_ORDER.get(tool_name, []) - ordered: list[tuple[str, Any]] = [] - seen = set() - - for key in preferred: - if key in args: - ordered.append((key, args[key])) - seen.add(key) - - remaining_keys = set(args.keys() - seen) - ordered.extend((key, args[key]) for key in remaining_keys) - - return ordered - - def _render_args_table(self, tool_name: str, args: dict[str, Any]) -> Table: - table = Table.grid(padding=(0, 1)) - table.add_column(style="muted", justify="right", no_wrap=True) - table.add_column(style="code", overflow="fold") - - for key, value in self._ordered_args(tool_name, args): - if isinstance(value, str): - if key in {"content", "old_string", "new_string"}: - line_count = len(value.splitlines()) or 0 - byte_count = len(value.encode("utf-8", errors="replace")) - value = f"<{line_count} lines • {byte_count} bytes>" - - if isinstance(value, bool): - value = str(value).lower() - elif isinstance(value, (int, float)): - value = str(value) - elif isinstance(value, dict): - value = str(value) - elif not isinstance(value, str): - value = repr(value) - - table.add_row(key, value) - - return table - - def tool_call_start( - self, - call_id: str, - name: str, - tool_kind: str | None, - arguments: dict[str, Any], - ) -> None: - self._tool_args_by_call_id[call_id] = arguments - border_style = f"tool.{tool_kind}" if tool_kind else "tool" - - title = Text.assemble( - ("⏺ ", "muted"), - (name, "tool"), - (" ", "muted"), - (f"#{call_id[:8]}", "muted"), - ) - - display_args = dict(arguments) - for key in ("path", "cwd"): - val = display_args.get(key) - if isinstance(val, str) and self.cwd: - display_args[key] = str(display_path_rel_to_cwd(val, self.cwd)) - - panel = Panel( - ( - self._render_args_table(name, display_args) - if display_args - else Text( - "(no args)", - style="muted", - ) - ), - title=title, - title_align="left", - subtitle=Text("running", style="muted"), - subtitle_align="right", - border_style=border_style, - box=box.ROUNDED, - padding=(1, 2), - ) - self.console.print() - self.console.print(panel) - - def _extract_read_file_code(self, text: str) -> tuple[int, str] | None: - body = text - if "\n\n" in text: - possible_header, possible_body = text.split("\n\n", 1) - if not re.match(r"^\s*\d+\|", possible_header) and re.match( - r"^\s*\d+\|", - possible_body, - ): - body = possible_body - - code_lines: list[str] = [] - start_line: int | None = None - - for line in body.splitlines(): - # 1|def main(): - # 2| print() - m = re.match(r"^\s*(\d+)\|(.*)$", line) - if not m: - return None - line_no = int(m.group(1)) - if start_line is None: - start_line = line_no - code_lines.append(m.group(2)) - - if start_line is None: - return None - - return start_line, "\n".join(code_lines) - - def _guess_language(self, path: str | None) -> str: - if not path: - return "text" - suffix = Path(path).suffix.lower() - return { - ".py": "python", - ".js": "javascript", - ".jsx": "jsx", - ".ts": "typescript", - ".tsx": "tsx", - ".json": "json", - ".toml": "toml", - ".yaml": "yaml", - ".yml": "yaml", - ".md": "markdown", - ".sh": "bash", - ".bash": "bash", - ".zsh": "bash", - ".rs": "rust", - ".go": "go", - ".java": "java", - ".kt": "kotlin", - ".swift": "swift", - ".c": "c", - ".h": "c", - ".cpp": "cpp", - ".hpp": "cpp", - ".css": "css", - ".html": "html", - ".xml": "xml", - ".sql": "sql", - }.get(suffix, "text") - - def print_welcome(self, title: str, lines: list[str], mode: str | None = None) -> None: - logo_lines = AGENTFORGE_ASCII.split("\n") - colored_logo_lines = [] - for i, line in enumerate(logo_lines): - colors = ["bold cyan", "cyan", "bright_blue", "blue", "bright_magenta", "magenta"] - c = colors[i % len(colors)] - colored_logo_lines.append(Text(line, style=c)) - logo_text = Text("\n").join(colored_logo_lines) - main_logo = Align.center(logo_text) - - info = Group( - Text(""), - Text(f" AgentForge v{AGENTFORGE_VERSION}", style="bold white"), - Text(""), - ) - - table = Table.grid(padding=(0, 2)) - table.add_column(style="muted", no_wrap=True) - table.add_column(style="code", overflow="fold") - - for line in lines: - if ":" in line: - key, value = line.split(":", 1) - table.add_row(key.strip(), value.strip()) - else: - table.add_row("", line) - - if mode: - mode_style = "tool.read" if mode == "plan" else "tool.shell" - table.add_row("mode", f"[{mode_style}]{mode.upper()}[/{mode_style}]") - - self.console.print( - Panel( - Group( - main_logo, - info, - table, - ), - title=None, - subtitle=Text("type /help for commands", style="muted"), - subtitle_align="right", - border_style="bright_cyan", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - self.console.print( - Panel( - Text("AgentForge ready — what are we building today?", style="cyan"), - border_style="border", - box=box.ROUNDED, - padding=(0, 2), - ) - ) - - def show_mode(self, mode: str) -> None: - style = "tool.read" if mode == "plan" else "tool.shell" - self.console.print( - Panel( - Text(f"Switched to {mode.upper()} mode", style="code"), - title=Text("Mode", style=style), - title_align="left", - border_style=style, - box=box.ROUNDED, - padding=(0, 2), - ) - ) - - def show_notice(self, message: str, title: str = "Status") -> None: - self.console.print( - Panel( - Text(message, style="code"), - title=Text(title, style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(0, 2), - ) - ) - - def show_error(self, message: str, title: str = "Error") -> None: - self.console.print( - Panel( - Text(message, style="error"), - title=Text(title, style="error"), - title_align="left", - border_style="error", - box=box.ROUNDED, - padding=(0, 2), - ) - ) - - def show_config(self, config: dict[str, Any]) -> None: - table = Table.grid(padding=(0, 2)) - table.add_column(style="muted", no_wrap=True) - table.add_column(style="code", overflow="fold") - - _SKIP_KEYS = {"mcp_servers", "hooks", "subagents", "skill_roots", "shell_environment"} - _SECTION_KEYS = {"model", "approval", "max_turns", "max_tool_output_tokens", "cwd"} - - def flatten(d: dict[str, Any], prefix: str = "") -> list[tuple[str, str]]: - rows: list[tuple[str, str]] = [] - for k, v in d.items(): - key = f"{prefix}.{k}" if prefix else k - if isinstance(v, dict) and k not in _SKIP_KEYS: - rows.extend(flatten(v, key)) - elif not isinstance(v, (dict, list)): - rows.append((key, str(v))) - return rows - - rows = flatten(config) - for key, value in rows: - table.add_row(key, value) - - self.console.print( - Panel( - table, - title=Text("Configuration", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def show_key_values( - self, - title: str, - rows: list[tuple[str, str]], - *, - border_style: str = "border", - footer: str = "", - ) -> None: - table = Table.grid(padding=(0, 2)) - table.add_column(style="muted", no_wrap=True) - table.add_column(style="code", overflow="fold") - - for key, value in rows: - table.add_row(key, value) - - blocks: list[Any] = [table if rows else Text("No values", style="muted")] - if footer: - blocks.append(Text(footer, style="muted")) - - self.console.print( - Panel( - Group(*blocks), - title=Text(title, style="highlight"), - title_align="left", - border_style=border_style, - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def show_models( - self, - provider: str, - current_model: str, - models: list[Any], - live: bool, - message: str = "", - page: int = 1, - total_pages: int = 1, - total_count: int | None = None, - ) -> None: - table = Table( - box=box.SIMPLE, - show_header=True, - header_style="highlight", - expand=True, - ) - table.add_column("Model", style="code", overflow="fold") - table.add_column("Source", style="muted", no_wrap=True) - table.add_column("Note", style="muted", overflow="fold") +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, Input, Static + +from agentforge_harness.ui.adapter import TuiEventAdapter +from agentforge_harness.ui.autocomplete import build_completion_state +from agentforge_harness.ui.config import DEFAULT_THEME, TuiTheme +from agentforge_harness.ui.state import TuiState +from agentforge_harness.ui.widgets import SessionSidebar, StatusBar, TranscriptView + +if TYPE_CHECKING: + from agentforge_harness.config.config import Config + + +class AgentForgeTuiApp(App): + """Full Textual TUI for AgentForge.""" + + TITLE = "AgentForge" + SUB_TITLE = "" + + BINDINGS = [ + Binding("ctrl+d", "quit", "Quit"), + Binding("ctrl+t", "toggle_thinking", "Thinking"), + Binding("ctrl+o", "toggle_tool_results", "Tools"), + Binding("ctrl+r", "session_picker", "Sessions"), + Binding("escape", "cancel_run", "Cancel"), + ] + + CSS = """ +Screen { + background: #0d0d0d; + color: #cccccc; + layers: base overlay; +} +#layout { + height: 1fr; +} +#main { + height: 1fr; +} +#transcript { + height: 1fr; + padding: 0 1; + background: #0d0d0d; +} +#input-row { + height: auto; + border-top: solid #1a1a1a; + padding: 0; + background: #0d0d0d; +} +#prompt-prefix { + width: 3; + padding: 0 0 0 1; + content-align: center middle; + color: #d4a04a; +} +#prompt { + width: 1fr; + border: none; + background: transparent; + color: #cccccc; + padding: 0 1; +} +Input:focus { + border: none; + background: transparent; +} +#autocomplete { + height: auto; + padding: 0 4; + color: #333344; + background: #0d0d0d; +} +TranscriptMessageWidget { + padding: 0 0 1 0; +} +Footer { + background: #050505; + color: #555566; +} +""" - for model in models: - name = str(getattr(model, "name", "")) - label = f"{name} (current)" if name == current_model else name - table.add_row( - label, - str(getattr(model, "source", "")), - str(getattr(model, "note", "")), - ) + def __init__(self, config: Config, theme: TuiTheme = DEFAULT_THEME, **kwargs) -> None: + super().__init__(**kwargs) + self._config = config + self._theme = theme + self.state = TuiState() + self._adapter = TuiEventAdapter(self.state) + self._agent = None + self._run_task: asyncio.Task | None = None + self._last_user_message: str = "" + + def compose(self) -> ComposeResult: + from textual.containers import Horizontal, Vertical + + with Horizontal(id="layout"): + yield SessionSidebar(id="sidebar") + with Vertical(id="main"): + yield TranscriptView(self.state, theme=self._theme, id="transcript") + yield Static("", id="autocomplete") + with Horizontal(id="input-row"): + yield Static("❯", id="prompt-prefix") + yield Input( + placeholder="Ask AgentForge… Enter submits, Alt+Enter steers", + id="prompt", + ) + yield StatusBar(id="statusbar") + yield Footer() + + async def on_mount(self) -> None: + from agentforge_harness.agent.agent import Agent + + self._agent = Agent(config=self._config) + await self._agent.__aenter__() + self.query_one("#prompt", Input).focus() + self._update_sidebar() + + async def on_unmount(self) -> None: + if self._agent is not None: + try: + await self._agent.__aexit__(None, None, None) + except Exception: + pass + + def on_input_submitted(self, event: Input.Submitted) -> None: + text = event.value.strip() + if not text: + return + event.input.value = "" + self.query_one("#autocomplete", Static).update("") - status = "live" if live else "suggestions" - count_text = f", {total_count} total" if total_count is not None else "" - subtitle = f"{provider} models - {status} - page {page}/{total_pages}{count_text}" - if message: - subtitle = f"{subtitle}\n{message}\nUse /model to switch. Use /models --page N for more." + if text.startswith("/"): + self.run_worker(self._handle_command(text), exclusive=False) + elif self.state.running: + self._queue_message(text, mode="steer") else: - subtitle = f"{subtitle}\nUse /model to switch. Use /models --page N for more." - - self.console.print( - Panel( - Group(Text(subtitle, style="muted"), table if models else Text("No models found", style="muted")), - title=Text("Models", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def show_tools(self, tools: list[Any]) -> None: - table = Table( - box=box.SIMPLE, - show_header=True, - header_style="highlight", - expand=True, - ) - table.add_column("Tool", style="tool", no_wrap=True) - table.add_column("Kind", style="muted", no_wrap=True) - table.add_column("Description", style="code", overflow="fold") - - for tool in sorted(tools, key=lambda item: (item.kind.value, item.name)): - description = tool.description - if len(description) > 120: - description = description[:117] + "..." - table.add_row(tool.name, tool.kind.value, description) - - self.console.print( - Panel( - table if tools else Text("No tools registered", style="muted"), - title=Text("Tools", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def show_skills( - self, - skills: list[Any], - active_skills: list[str] | None = None, - ) -> None: - active = set(active_skills or []) - table = Table( - box=box.SIMPLE, - show_header=True, - header_style="highlight", - expand=True, - ) - table.add_column("Skill", style="tool", no_wrap=True) - table.add_column("State", style="muted", no_wrap=True) - table.add_column("Description", style="code", overflow="fold") - table.add_column("Tools", style="muted", overflow="fold") + self._last_user_message = text + self.run_worker(self._run_prompt(text), exclusive=False) - for skill in sorted(skills, key=lambda item: item.name): - state = "active" if skill.name in active else "available" - tools = ", ".join(skill.allowed_tools or []) - table.add_row(skill.name, state, skill.description, tools or "-") - - self.console.print( - Panel( - table if skills else Text("No skills discovered", style="muted"), - title=Text("Skills index", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def show_mcp_servers(self, servers: list[dict[str, Any]]) -> None: - table = Table( - box=box.SIMPLE, - show_header=True, - header_style="highlight", - expand=True, - ) - table.add_column("Server", style="tool.mcp", no_wrap=True) - table.add_column("Status", style="code", no_wrap=True) - table.add_column("Tools", style="muted", justify="right") - - for server in servers: - table.add_row( - str(server.get("name", "")), - str(server.get("status", "")), - str(server.get("tools", 0)), - ) - - self.console.print( - Panel( - table if servers else Text("No MCP servers configured", style="muted"), - title=Text("MCP Servers", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def show_todos_list(self, todos: list[tuple[str, str]]) -> None: - if not todos: - self.console.print( - Panel( - Text("No active todos", style="muted"), - title=Text("Todos", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(0, 2), - ) - ) + def on_input_changed(self, event: Input.Changed) -> None: + text = event.value + if not text.startswith("/"): + self.query_one("#autocomplete", Static).update("") return - - table = Table.grid(padding=(0, 2)) - table.add_column(style="muted", no_wrap=True) - table.add_column(style="code", overflow="fold") - - for todo_id, content in todos: - table.add_row(f"[{todo_id}]", content) - - self.console.print( - Panel( - table, - title=Text(f"Todos ({len(todos)})", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) + registry = self._get_registry() + commands = [f"/{name}" if not name.startswith("/") else name + for name in registry.known_commands] + completion = build_completion_state(text, commands=commands) + if completion.items: + suggestions = " ".join(item.display for item in completion.items[:8]) + self.query_one("#autocomplete", Static).update(f"[dim]{suggestions}[/dim]") + else: + self.query_one("#autocomplete", Static).update("") + + def on_key(self, event) -> None: + key = event.key + if key == "alt+enter": + prompt_widget = self.query_one("#prompt", Input) + text = prompt_widget.value.strip() + if text: + prompt_widget.value = "" + self._queue_message(text, mode="steer") + elif key == "tab": + prompt_widget = self.query_one("#prompt", Input) + text = prompt_widget.value + if text.startswith("/"): + registry = self._get_registry() + commands = [f"/{name}" if not name.startswith("/") else name + for name in registry.known_commands] + completion = build_completion_state(text, commands=commands) + if completion.selected: + prompt_widget.value = completion.selected.replacement + prompt_widget.cursor_position = len(prompt_widget.value) + event.prevent_default() + + def _get_registry(self): + from agentforge_harness.cli.command_registry import get_registry + return get_registry() + + async def _handle_command(self, text: str) -> None: + from agentforge_harness.cli.command_registry import CommandContext, get_registry + + parts = text.split(None, 1) + name = parts[0] + argument = parts[1] if len(parts) > 1 else "" + + session = self._agent.session if self._agent else None + ctx = CommandContext( + session=session, + config=self._config, + agent=self._agent, + last_user_message=self._last_user_message, ) - - def show_branch_choices(self, choices: list[dict[str, Any]]) -> None: - if not choices: - self.console.print( - Panel( - Text("No branchable messages in current session", style="muted"), - title=Text("Branch / Rewind", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(0, 2), - ) - ) + try: + result = await get_registry().dispatch(name, argument, ctx) + except Exception as exc: + self.state.add_error(f"Command error: {exc}") + self._refresh_transcript() return - table = Table( - box=box.SIMPLE, - show_header=True, - header_style="highlight", - expand=True, - ) - table.add_column("#", style="muted", no_wrap=True, width=4) - table.add_column("Role", style="code", no_wrap=True, width=12) - table.add_column("Preview", style="assistant", overflow="fold") - table.add_column("ID", style="muted", no_wrap=True, width=14) - - for choice in choices: - pos = str(choice.get("position", "")) - role = str(choice.get("role", "")) - preview = str(choice.get("preview", "")) - entry_id = str(choice.get("id", "")) - table.add_row(pos, role, preview, entry_id[:12] + "…") - - self.console.print( - Panel( - table, - title=Text(f"Branch / Rewind ({len(choices)} points)", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - subtitle=Text("Usage: /branch <#> or /branch ", style="muted"), - ) - ) - - def show_stats(self, stats: dict[str, Any]) -> None: - table = Table.grid(padding=(0, 2)) - table.add_column(style="muted", no_wrap=True) - table.add_column(style="code", justify="right") - - for key, value in stats.items(): - table.add_row(key.replace("_", " "), str(value)) - - self.console.print( - Panel( - table, - title=Text("Session Stats", style="highlight"), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def show_sessions( - self, - sessions: list[dict[str, Any]], - *, - page: int = 1, - total_pages: int = 1, - total_count: int | None = None, - ) -> None: - table = Table( - box=box.SIMPLE, - show_header=True, - header_style="highlight", - expand=True, - ) - table.add_column("Name", style="highlight", no_wrap=True) - table.add_column("Session ID", style="code", overflow="fold") - table.add_column("Updated", style="muted", no_wrap=True) - table.add_column("Turns", style="code", justify="right") - table.add_column("Mode", style="muted", no_wrap=True) - - for session in sessions: - sid = str(session.get("session_id", "")) - name = str(session.get("name", "")) or sid[:8] - table.add_row( - name, - sid, - str(session.get("updated_at", "")), - str(session.get("turn_count", 0)), - str(session.get("mode", "")), - ) - - self.console.print( - Panel( - table if sessions else Text("No saved sessions", style="muted"), - title=Text( - f"Saved Sessions {page}/{total_pages}" - + (f" ({total_count})" if total_count is not None else ""), - style="highlight", - ), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def show_checkpoints( - self, - checkpoints: list[dict[str, Any]], - *, - page: int = 1, - total_pages: int = 1, - total_count: int | None = None, - ) -> None: - table = Table( - box=box.SIMPLE, - show_header=True, - header_style="highlight", - expand=True, - ) - table.add_column("Checkpoint", style="code", overflow="fold") - table.add_column("Session", style="muted", overflow="fold") - table.add_column("Turns", style="code", justify="right") - table.add_column("Mode", style="muted", no_wrap=True) - table.add_column("CWD", style="code", overflow="fold") - - for checkpoint in checkpoints: - table.add_row( - str(checkpoint.get("checkpoint_id", "")), - str(checkpoint.get("session_id", "")), - str(checkpoint.get("turn_count", 0)), - str(checkpoint.get("mode", "")), - str(checkpoint.get("cwd", "")), - ) - - self.console.print( - Panel( - table if checkpoints else Text("No checkpoints", style="muted"), - title=Text( - f"Checkpoints {page}/{total_pages}" - + (f" ({total_count})" if total_count is not None else ""), - style="highlight", - ), - title_align="left", - border_style="border", - box=box.ROUNDED, - padding=(1, 2), - ) - ) - - def tool_call_complete( - self, - call_id: str, - name: str, - tool_kind: str | None, - success: bool, - output: str, - error: str | None, - metadata: dict[str, Any] | None, - diff: str | None = None, - truncated: bool = False, - exit_code: int | None = None, - ) -> None: - border_style = f"tool.{tool_kind}" if tool_kind else "tool" - status_icon = "✓" if success else "✗" - status_style = "success" if success else "error" - - title = Text.assemble( - (f"{status_icon} ", status_style), - (name, "tool"), - (" ", "muted"), - (f"#{call_id[:8]}", "muted"), - ) - - args = self._tool_args_by_call_id.get(call_id, {}) - - primary_path = None - blocks = [] - if isinstance(metadata, dict) and isinstance(metadata.get("path"), str): - primary_path = metadata.get("path") - - if name == "read_file" and success: - if primary_path: - extracted = self._extract_read_file_code(output) - - if extracted: - start_line, code = extracted - shown_start = metadata.get("shown_start") - shown_end = metadata.get("shown_end") - total_lines = metadata.get("total_lines") - has_trailing_newline = metadata.get("has_trailing_newline") - pl = self._guess_language(primary_path) - - header_parts = [display_path_rel_to_cwd(primary_path, self.cwd)] - - if shown_start and shown_end and total_lines: - header_parts.append( - f"lines {shown_start}-{shown_end} of {total_lines}" - ) - if has_trailing_newline is False and shown_end == total_lines: - header_parts.append("no trailing newline") - - blocks.append(Text(" • ".join(header_parts), style="muted")) - blocks.append( - Syntax( - code, - pl, - theme="monokai", - line_numbers=True, - start_line=start_line, - word_wrap=True, - ) - ) - else: - output_display = truncate_text( - output, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=False, - ) - ) - else: - output_display = truncate_text( - output, - "", - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=False, - ) - ) - elif name in {"write_file", "append_file", "edit", "apply_patch"} and success and diff: - output_line = output.strip() if output.strip() else "Completed" - blocks.append(Text(output_line, style="muted")) - if metadata.get("fallback"): - blocks.append(Text("applied with line-based fallback", style="warning")) - diff_text = diff - diff_display = truncate_text( - diff_text, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - diff_display, - "diff", - theme="monokai", - word_wrap=True, - ) - ) - elif name == "shell" and success: - command = args.get("command") - if isinstance(command, str) and command.strip(): - blocks.append(Text(f"$ {command.strip()}", style="muted")) - - if exit_code is not None: - blocks.append(Text(f"exit_code={exit_code}", style="muted")) - - output_display = truncate_text( - output, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - elif name == "list_dir" and success: - entries = metadata.get("entries") - path = metadata.get("path") - summary = [] - if isinstance(path, str): - summary.append(path) - - if isinstance(entries, int): - summary.append(f"{entries} entries") - - if summary: - blocks.append(Text(" • ".join(summary), style="muted")) - - output_display = truncate_text( - output, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - elif name == "grep" and success: - matches = metadata.get("matches") - files_searched = metadata.get("files_searched") - summary = [] - if isinstance(matches, int): - summary.append(f"{matches} matches") - if isinstance(files_searched, int): - summary.append(f"searched {files_searched} files") - - if summary: - blocks.append(Text(" • ".join(summary), style="muted")) - - output_display = truncate_text( - output, self.config.model_name, self._max_block_tokens - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - elif name == "glob" and success: - matches = metadata.get("matches") - if isinstance(matches, int): - blocks.append(Text(f"{matches} matches", style="muted")) - - output_display = truncate_text( - output, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - elif name == "web_search" and success: - results = metadata.get("results") - query = args.get("query") - summary = [] - if isinstance(query, str): - summary.append(query) - if isinstance(results, int): - summary.append(f"{results} results") - - if summary: - blocks.append(Text(" • ".join(summary), style="muted")) - - output_display = truncate_text( - output, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - elif name == "web_fetch" and success: - status_code = metadata.get("status_code") - content_length = metadata.get("content_length") - url = args.get("url") - summary = [] - if isinstance(status_code, int): - summary.append(str(status_code)) - if isinstance(content_length, int): - summary.append(f"{content_length} bytes") - if isinstance(url, str): - summary.append(url) - - if summary: - blocks.append(Text(" • ".join(summary), style="muted")) - - output_display = truncate_text( - output, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - elif name == "todos" and success: - output_display = truncate_text( - output, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - elif name == "memory" and success: - action = args.get("action") - key = args.get("key") - found = metadata.get("found") - summary = [] - if isinstance(action, str) and action: - summary.append(action) - if isinstance(key, str) and key: - summary.append(key) - if isinstance(found, bool): - summary.append("found" if found else "missing") - - if summary: - blocks.append(Text(" • ".join(summary), style="muted")) - output_display = truncate_text( - output, - self.config.model_name, - self._max_block_tokens, - ) - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - else: - if error and not success: - blocks.append(Text(error, style="error")) - - output_display = truncate_text( - output, self.config.model_name, self._max_block_tokens - ) - if output_display.strip(): - blocks.append( - Syntax( - output_display, - "text", - theme="monokai", - word_wrap=True, - ) - ) - else: - blocks.append(Text("(no output)", style="muted")) - - if truncated: - blocks.append(Text("note: tool output was truncated", style="warning")) - - panel = Panel( - Group( - *blocks, - ), - title=title, - title_align="left", - subtitle=Text("done" if success else "failed", style=status_style), - subtitle_align="right", - border_style=border_style, - box=box.ROUNDED, - padding=(1, 2), - ) - self.console.print() - self.console.print(panel) + if result.exit: + await self.action_quit() + return - def handle_confirmation(self, confirmation: ToolConfirmation) -> bool: - output = [ - Text(confirmation.tool_name, style="tool"), - Text(confirmation.description, style="code"), - ] + if result.clear: + self.state.clear() + self._refresh_transcript() - if confirmation.command: - output.append(Text(f"$ {confirmation.command}", style="warning")) + if result.error: + self.state.add_error(f"{result.error_title}: {result.error}") + self._refresh_transcript() - if confirmation.diff: - diff_text = confirmation.diff.to_diff() - output.append( - Syntax( - diff_text, - "diff", - theme="monokai", - word_wrap=True, - ) - ) - else: - diff_text = confirmation.get_diff_text() - if diff_text: - output.append( - Syntax( - diff_text, - "diff", - theme="monokai", - word_wrap=True, - ) - ) + if result.notice: + self.state.add_status(result.notice) + self._refresh_transcript() - self.console.print() - self.console.print( - Panel( - Group(*output), - title=Text("Approval required", style="warning"), - title_align="left", - border_style="warning", - box=box.ROUNDED, - padding=(1, 2), - ) - ) + if not result.handled: + self.state.add_error(f"Unknown command: {name}") + self._refresh_transcript() - response = Prompt.ask( - "\nApprove?", choices=["y", "n", "yes", "no"], default="n" + async def _run_prompt(self, message: str) -> None: + if self._run_task and not self._run_task.done(): + # Previous run still active — queue as steer instead + self._queue_message(message, mode="steer") + return + if self._agent is None: + return + self.state.add_user_message(message) + self._refresh_transcript() + + async def _stream() -> None: + try: + async for event in self._agent.run(message): + self._adapter.apply(event) + self._refresh_transcript() + except asyncio.CancelledError: + self.state.add_status("[Cancelled]") + self.state.running = False + self._refresh_transcript() + except Exception as exc: + self.state.add_error(f"Run error: {exc}") + self.state.running = False + self._refresh_transcript() + finally: + self._update_sidebar() + self._run_task = None + + self._run_task = asyncio.create_task(_stream()) + + def _queue_message(self, text: str, mode: str = "follow_up") -> None: + if self._agent and self._agent.session: + try: + self._agent.session.prompt(text, mode) + self.state.add_status(f"[Queued {mode}] {text[:60]}") + self._refresh_transcript() + except Exception as exc: + self.state.add_error(f"Queue error: {exc}") + self._refresh_transcript() + + def action_toggle_thinking(self) -> None: + self.state.toggle_thinking() + self._refresh_transcript() + + def action_toggle_tool_results(self) -> None: + self.state.toggle_tool_results() + self._refresh_transcript() + + def action_cancel_run(self) -> None: + if self._run_task and not self._run_task.done(): + self._run_task.cancel() + if self._agent and self._agent.session: + try: + self._agent.session.request_cancel() + except Exception: + pass + + async def action_quit(self) -> None: + self.action_cancel_run() + self.exit() + + def action_session_picker(self) -> None: + self.state.add_status( + "Session picker: use /sessions to list sessions, /resume to restore." ) + self._refresh_transcript() - return response.lower() in {"y", "yes"} + def _update_sidebar(self) -> None: + try: + sidebar = self.query_one("#sidebar", SessionSidebar) + except Exception: + return - def show_export(self, markdown: str, path: str) -> None: - self.console.print( - Panel( - Text(f"Session exported to {path}", style="success"), - title=Text("Export", style="highlight"), - title_align="left", - border_style="success", - box=box.ROUNDED, - padding=(0, 2), - ) + session = self._agent.session if self._agent else None + mode = session.mode.value if session and session.mode else "build" + model = self._config.model_name + turn = getattr(session, "_turn_count", 0) if session else 0 + prompt_tokens = 0 + completion_tokens = 0 + max_tokens = 200_000 + + if session and session.context_manager is not None: + try: + usage = session.context_manager.get_total_usage() + prompt_tokens = usage.prompt_tokens or 0 + completion_tokens = usage.completion_tokens or 0 + budget = session.context_manager.get_context_budget() + max_tokens = budget.get("total_tokens", max_tokens) + except Exception: + pass + + # Tools — session.tool_names is a property returning list[str] + tools: list[str] = [] + if session: + try: + tools = list(session.tool_names) + except Exception: + pass + + # Skills — all known from skills_manager, active subset first + skills: list[str] = [] + skill_total = 0 + if session: + try: + all_meta = session.skills_manager.list_skills() + skill_total = len(all_meta) + active = list(session.active_skill_names) + known = [s.name for s in all_meta if s.name not in active] + skills = active + known + except Exception: + pass + + sidebar.update_from_info( + mode=mode, + model=model, + turn=turn, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + tools=tools or None, + skills=skills or None, + skill_total=skill_total, ) - def show_help(self) -> None: - help_text = """ -## Commands - -- `/help` - Show this help -- `/exit` or `/quit` - Exit the agent -- `/new` - Start a fresh session (clears conversation) -- `/reload` - Reload config from disk -- `/version` - Show AgentForge version -- `/retry` - Resend the last user message -- `/history [n]` - Show last N messages (default 10) -- `/report` - Show session summary report -- `/clear` - Clear conversation history -- `/config` - Show current configuration -- `/doctor` - Check local config and runtime readiness -- `/doctor fix` - Apply safe doctor fixes -- `/provider [name]` - Show or switch provider -- `/models` - List model suggestions for the current provider -- `/model list` - Alias for `/models` -- `/model [name]` - Show or change the model -- `/fallbacks` - Show or edit fallback model chain -- `/paths` - Show config, data, session, checkpoint, and skill paths -- `/compact` - Compact older context now -- `/errors` - Show recent model/tool errors -- `/approval ` - Change approval mode -- `/export` - Export session as markdown (saves to ./session-{id}.md) -- `/stats` - Show session statistics -- `/todos` - Show active todos -- `/todos --clear` - Clear all todos -- `/tools` - List available tools -- `/skills` - List available skills -- `/skill ` - Activate a skill -- `/unskill ` - Deactivate a skill -- `/mcp` - Show MCP server status -- `/name` - Show or set session name (e.g. `/name my-session`) -- `/plan` - Switch to plan mode (read-only, for designing a plan) -- `/build` - Switch to build mode (full tool access, for implementing) -- `/save` - Save current session -- `/checkpoint` - Create a checkpoint -- `/checkpoints` - List available checkpoints -- `/restore ` - Restore a checkpoint -- `/sessions` - List saved sessions -- `/resume ` - Resume a saved session -- `/branch` - Show branchable messages; `/branch ` rewinds to that message -- `/rewind ` - Alias for `/branch ` -- `/steer ` - Queue a steering prompt to be injected at the next tool checkpoint -- `/follow-up ` - Queue a follow-up message to be sent after the current turn ends - -## Tips - -- Just type your message to chat with the agent -- The agent can read, write, and execute code -- Some operations require approval (can be configured) -- Use `/plan` to design a plan before implementing with `/build` -""" - self.console.print(Markdown(help_text)) + # Status bar + try: + import os + import subprocess + + cwd = os.path.basename(os.getcwd()) + try: + branch = subprocess.check_output( + ["git", "branch", "--show-current"], + capture_output=True, + text=True, + timeout=1, + ).stdout.strip() or "main" + except Exception: + branch = "main" + statusbar = self.query_one("#statusbar", StatusBar) + statusbar.update_status( + cwd=f"~/{cwd}", + branch=branch, + prompt_tokens=prompt_tokens, + max_tokens=max_tokens, + model=model, + mode=mode, + ) + except Exception: + pass + + def _refresh_transcript(self) -> None: + try: + transcript = self.query_one("#transcript", TranscriptView) + transcript.refresh_from_state() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +async def run_tui(config: Config) -> None: + """Launch the Textual TUI and run until the user quits.""" + app = AgentForgeTuiApp(config=config) + await app.run_async() diff --git a/agentforge_harness/ui/widgets.py b/agentforge_harness/ui/widgets.py new file mode 100644 index 0000000..6a2c38a --- /dev/null +++ b/agentforge_harness/ui/widgets.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import VerticalScroll +from textual.widget import Widget +from textual.widgets import Static + +from agentforge_harness.ui.config import DEFAULT_THEME, TuiTheme +from agentforge_harness.ui.state import ChatItem, TuiState + + +# --------------------------------------------------------------------------- +# Module-level helper functions +# --------------------------------------------------------------------------- + + +def _format_role_label(role: str, theme: TuiTheme) -> str: + style = theme.role_styles.get(role) + color = style.border if style else "#888888" + labels = { + "user": "you", + "assistant": "agent", + "tool": "tool", + "error": "error", + "thinking": "thinking", + "status": "info", + } + label = labels.get(role, role) + return f"[{color}]▌ {label}[/{color}]" + + +def _item_to_markup( + item: ChatItem, + theme: TuiTheme, + *, + show_tool_results: bool = True, + show_thinking: bool = True, +) -> str: + if item.role == "thinking" and not show_thinking: + return "[dim][ thinking hidden — Ctrl+T to show ][/dim]" + style = theme.role_styles.get(item.role) + color = style.body if style else "#cccccc" + safe_text = item.text.replace("[", "\\[") + return f"[{color}]{safe_text}[/{color}]" + + +# --------------------------------------------------------------------------- +# TranscriptMessageWidget +# --------------------------------------------------------------------------- + + +class TranscriptMessageWidget(Static): + """Renders a single ChatItem as a labelled message block.""" + + def __init__( + self, + item: ChatItem, + theme: TuiTheme = DEFAULT_THEME, + show_tool_results: bool = True, + show_thinking: bool = True, + **kwargs, + ) -> None: + self._item = item + self._theme = theme + self._show_tool_results = show_tool_results + self._show_thinking = show_thinking + content = self._build_markup(item) + super().__init__(content, **kwargs) + + def _build_markup(self, item: ChatItem) -> str: + label = _format_role_label(item.role, self._theme) + body = _item_to_markup( + item, + self._theme, + show_tool_results=self._show_tool_results, + show_thinking=self._show_thinking, + ) + return f"{label}\n{body}" + + def update_item(self, item: ChatItem) -> None: + self._item = item + self.update(self._build_markup(item)) + + +# --------------------------------------------------------------------------- +# TranscriptView +# --------------------------------------------------------------------------- + + +class TranscriptView(VerticalScroll): + """Scrollable view of all chat transcript items.""" + + def __init__( + self, + state: TuiState, + theme: TuiTheme = DEFAULT_THEME, + **kwargs, + ) -> None: + self._state = state + self._theme = theme + super().__init__(**kwargs) + + def compose(self) -> ComposeResult: + for item in self._state.items: + yield TranscriptMessageWidget( + item, + theme=self._theme, + show_tool_results=self._state.show_tool_results, + show_thinking=self._state.show_thinking, + ) + + def refresh_from_state(self) -> None: + self.query("TranscriptMessageWidget").remove() + for item in self._state.items: + self.mount( + TranscriptMessageWidget( + item, + theme=self._theme, + show_tool_results=self._state.show_tool_results, + show_thinking=self._state.show_thinking, + ) + ) + self.scroll_end(animate=False) + + def append_delta(self, delta: str) -> None: + assistant_widgets = [ + w + for w in self.children + if isinstance(w, TranscriptMessageWidget) and w._item.role == "assistant" + ] + if assistant_widgets: + last = assistant_widgets[-1] + last.update_item(last._item) + self.scroll_end(animate=False) + + +# --------------------------------------------------------------------------- +# SessionSidebar +# --------------------------------------------------------------------------- + + +class SessionSidebar(Static): + """Sidebar panel showing current session metadata.""" + + _SIDEBAR_W = 28 # inner content width for separators + + DEFAULT_CSS = """SessionSidebar { + width: 30; + padding: 1 1; + border-right: solid #1a1a1a; + background: #050505; + color: #aaaaaa; +}""" + + def _sep(self) -> str: + return f"[#222233]{'─' * self._SIDEBAR_W}[/#222233]" + + def update_from_info( + self, + *, + mode: str, + model: str, + turn: int, + prompt_tokens: int, + completion_tokens: int, + theme: TuiTheme = DEFAULT_THEME, + tools: list[str] | None = None, + skills: list[str] | None = None, + skill_total: int = 0, + ) -> None: + amber = "#d4a04a" + dim = "#555566" + text = "#aaaaaa" + sep = self._sep() + + # Truncate model to fit without wrapping (sidebar inner width = 28) + raw_model = str(model) + if len(raw_model) > self._SIDEBAR_W: + raw_model = raw_model[: self._SIDEBAR_W - 1] + "…" + safe_model = raw_model.replace("[", "\\[").replace("]", "\\]") + safe_mode = str(mode).replace("[", "\\[").replace("]", "\\]") + + lines = [ + # Logo + f"[bold white]AgentForge[/bold white]", + "", + # Session section + f"[bold {amber}]session[/bold {amber}]", + f"[{dim}]mode [/{dim}][{text}]{safe_mode}[/{text}]", + f"[{dim}]model[/{dim}]", + f"[{text}]{safe_model}[/{text}]", + f"[{dim}]turn [/{dim}][{text}]{turn}[/{text}]", + f"[{dim}]in [/{dim}][{text}]{prompt_tokens:,}[/{text}]", + f"[{dim}]out [/{dim}][{text}]{completion_tokens:,}[/{text}]", + sep, + ] + + if tools: + lines.append(f"[bold {amber}]tools[/bold {amber}]") + for t in tools: + safe_t = str(t).replace("[", "\\[") + lines.append(f"[{dim}]• [/{dim}][{text}]{safe_t}[/{text}]") + lines.append(sep) + + if skills is not None: + total = skill_total or len(skills) + lines.append(f"[bold {amber}]skills[/bold {amber}] [{dim}]{total}[/{dim}]") + for s in skills[:40]: + safe_s = str(s).replace("[", "\\[") + lines.append(f"[{dim}]• [/{dim}][{text}]{safe_s}[/{text}]") + + self.update("\n".join(lines)) + + +# --------------------------------------------------------------------------- +# StatusBar +# --------------------------------------------------------------------------- + + +class StatusBar(Static): + """Single-line status bar at the bottom showing cwd + context info.""" + + DEFAULT_CSS = """StatusBar { + height: 1; + background: #050505; + border-top: solid #1a1a1a; + padding: 0 1; + color: #555566; +}""" + + def update_status( + self, + *, + cwd: str, + branch: str, + prompt_tokens: int, + max_tokens: int, + model: str, + mode: str, + ) -> None: + amber = "#d4a04a" + dim = "#555566" + # Keep model short: strip provider prefix if any (e.g. "nvidia/…" → "…") + short_model = model.split("/")[-1] if "/" in model else model + if len(short_model) > 22: + short_model = short_model[:21] + "…" + ptk = prompt_tokens // 1000 + mtk = max_tokens // 1000 + left = f"[{dim}]{cwd} [/{dim}][{amber}]({branch})[/{amber}]" + right = f"[{dim}]{ptk}k/{mtk}k context {short_model} ({mode})[/{dim}]" + self.update(f"{left} {right}") diff --git a/docs/IMPROVEMENT-PLAN.md b/docs/IMPROVEMENT-PLAN.md index f7c68b8..7078a60 100644 --- a/docs/IMPROVEMENT-PLAN.md +++ b/docs/IMPROVEMENT-PLAN.md @@ -8,9 +8,9 @@ --- -## 1. Current status (as of PR #7 merged) +## 1. Current status (as of PR #10 merged) -`master` is green: **413 tests pass** (`/.venv/bin/python -m pytest -q`). +`master` is green: **489 tests pass** (`/.venv/bin/python -m pytest -q`). | Phase / item | Status | PR | |---|---|---| @@ -22,13 +22,13 @@ | **Audit bug fixes A–J** (thinking-delta regression, MCP, path traversal, approval bypass, SSRF, …) | ✅ merged | #6 | | **P2.1** — append-only session tree: foundation | ✅ merged | #4 | | **P2.1** — session tree live integration + audit fixes | ✅ merged | #7 | -| **P2.1 layer 4** — branching API (`tree_choices`/`branch_to_entry`) | ✅ merged | — | -| **P2.3b** — command registry (`handle_command → CommandResult`) | 🔄 in progress | — | -| **P2.3c** — re-entrant steering / follow-up queue (`session.prompt(steer/follow_up)`) | ⬜ pending (highest risk) | — | -| **P2.4** — secondary hardening + remaining LOW bugs | ⬜ pending | — | -| **Phase 3** — Textual TUI replacement | ⬜ blocked on P2.3b/c + P2.1 layer 4 | — | +| **P2.1 layer 4** — branching API (`tree_choices`/`branch_to_entry`) | ✅ merged | #8 | +| **P2.3b** — command registry (`handle_command → CommandResult`) | ✅ merged | #8 | +| **P2.3c** — re-entrant steering / follow-up queue (`session.prompt(steer/follow_up)`) | ✅ merged | #9 | +| **P2.4** — secondary hardening + remaining LOW bugs | ✅ merged | #10 | +| **Phase 3** — Textual TUI replacement | ⬜ pending | — | -**Recommended next step:** P2.1 layer 4 (branching API) — it completes the session-tree feature and is lower risk than steering. +**Recommended next step:** Phase 3 — Textual TUI replacement (all blockers resolved). --- @@ -110,35 +110,56 @@ agentforge_harness/ **P2.1 (#4, #7):** append-only `SessionEntry` tree (model + pure reconstruction) and JSONL store + migration; live integration with faithful non-destructive compaction on restore, `/new` reset, flat-restore seeding, dangling-leaf graceful fallback. +**P2.3b (#8):** `CommandRegistry` replaces the CLI's `if name == "/x"` chain; every handler returns a typed `CommandResult`; CLI becomes a thin render layer. 21 unit tests. + +**P2.3c (#9):** `SteeringQueue` (two-lane FIFO deque); `Session.prompt(text, mode)` enqueues messages; agent loop drains the steer lane at every tool-batch boundary and the follow-up lane at turn-end; `/steer` and `/follow-up` CLI commands; `asyncio.to_thread` wraps blocking `console.input()`; `CancelledError` re-raised (never swallowed). User-created tools can import `agentforge_harness.*` (sys.path fix in discovery). Tests: `test_steering_queue.py`, `test_session_prompt.py`. + +**P2.4 (#10):** `MCPClient.reconnect()` with exponential backoff + stale-tool-state clear; `SkillManager.get_active_allowed_tools()` union enforcement wired into `Session.activate_skill/deactivate_skill`; 32 000-char cap on injected skill bodies; per-run UUID threaded into `AGENT_START`/`AGENT_END` events + JSONL diagnostics via `PersistenceManager.append_run_diagnostic()`; `context_manager=None` guards on `/stats` and `/report`; `schema_version` future-version rejection in `load_session()` and `load_checkpoint()`; `add_assistant_message(content: str | None, ...)` annotation fix. 76 new tests. + --- ## 5. Remaining work (specs) -### P2.1 layer 4 — Branching API ⬅ recommended next -Make the tree user-visible. Build on `session_tree.py`/`session_store.py`/`ContextManager`. -- `Session.tree_choices() -> list[...]`: list branchable points = the `KIND_MESSAGE` entries on the **active path** (id, short preview of content, role, position). Read from `context_manager.get_entries()` + `active_leaf_id`/`path_to_entry`. -- `Session.branch_to_entry(entry_id, *, summarize=False)`: append a `KIND_LEAF` entry pointing at `entry_id` (the new active tip), then `context_manager.load_from_entries(entries)` so the live messages become that branch's path. Persist via `tree_store.write_all`. Optional: when `summarize=True`, record a `BranchSummaryEntry`/compaction of the abandoned tail. -- Add a `/branch` (or `/rewind`) CLI command listing choices and switching. -- **Tests:** branch to a past message → live messages == that path; new messages after branching extend the new branch; original branch still reconstructable from its leaf; save→restore preserves the active branch. - -### P2.3b — Command registry -Refactor the CLI's `if name == "/x"` chain (`cli/commands.py`) into a registry that maps command → handler and returns a structured `CommandResult` (fields like `exit`, `clear`, `notice`, `compact`, `switch_mode`, `error`, …) so both the CLI and the future TUI share one command layer. `Session.handle_command(text) -> CommandResult`. Keep behavior identical; this is enabling, not behavioral. Risk: medium (touches every command). - -### P2.3c — Re-entrant steering / follow-up queue (highest risk) -`Session.prompt(text, streaming_behavior="steer"|"follow_up")` with an internal queue so the user can inject a message mid-run without cancelling: *steer* = insert after the current tool batch; *follow_up* = run when the agent would otherwise stop. Emit `QueueUpdateEvent` (already defined in `events.py`) on queue changes. Also expose `is_running` (exists), `pop_latest_follow_up_message()`. No existing base to build on — design carefully, lots of edge cases. - -### P2.4 — Secondary hardening + remaining LOW bugs -- MCP reconnection/backoff (a dropped server is permanently `ERROR`). -- Enforce skills `allowed_tools`; cap injected `SKILL.md` body size. -- Structured per-run diagnostics (UUID per run → JSONL) for debuggability. -- **Deferred LOW bugs (from the audit, still open):** - - CLI `/stats`,`/report`,`/save`,`/checkpoint` lack a `context_manager`-None guard (only bites before `initialize()`). - - `persistence.py` `schema_version` is read but never validated/migrated (`load_session`). - - `agent.add_assistant_message` typing: param is `str` but called with `str | None` (works, but annotation lies). - - (Fixed already in #7: atomic `memory.json` write; `write_file` bare excepts.) - -### Phase 3 — Textual TUI replacement (blocked) -Replace the Rich print-renderer (`ui/tui.py`) with a full Textual app: non-blocking input, scrollback, slash + `@file` autocomplete, sidebar (model/provider/thinking/context tokens), session/tree pickers, steering input, thinking panel. **Depends on:** typed events ✅, session contract (P2.3b command registry + P2.3c steering + accessors ✅), and the branching API (P2.1 layer 4) for the tree picker. Keep a `--plain` fallback renderer. Preserve the current per-tool rich rendering. +### P2.1 layer 4 — Branching API ✅ done (#8) +`Session.tree_choices()` / `Session.branch_to_entry(entry_id)` + `/branch` CLI command. Branch to a past message, live messages reconstruct that path, new messages extend the new branch, save→restore preserves active branch. + +### P2.3b — Command registry ✅ done (#8) +`CommandRegistry` + `CommandResult` replace the CLI `if/elif` chain. CLI is now a thin render layer. + +### P2.3c — Re-entrant steering / follow-up queue ✅ done (#9) +`SteeringQueue` two-lane FIFO; `Session.prompt(text, mode)` enqueues; agent drains at tool-batch boundaries (steer) and turn-end (follow-up); `asyncio.to_thread` for blocking input; `CancelledError` re-raised. + +### P2.4 — Secondary hardening ✅ done (#10) +All items resolved: MCP reconnect backoff, `allowed_tools` enforcement, 32 k skill-body cap, per-run UUID/JSONL diagnostics, `context_manager=None` guards, schema version validation, type annotation fix. + +--- + +### Phase 3 — Textual TUI replacement ⬅ recommended next + +All prerequisites met. Replace the Rich print-renderer (`ui/tui.py`) with a full [Textual](https://textual.textualize.io/) app. + +**Capabilities to build:** +- Non-blocking input field (no more `asyncio.to_thread` hack) +- Scrollback panel for assistant/tool output +- Slash + `@file` autocomplete in the input bar +- Sidebar: model / provider / thinking level / context-token meter +- Session picker (list + switch) +- Tree/branch picker (uses `Session.tree_choices()` + `Session.branch_to_entry()`) +- Steering input (uses `Session.prompt(text, "steer")` / `"follow_up"`) +- Thinking panel (collapsible, streams `THINKING_DELTA` events) +- Tool call panels (expandable diff/output, success/error styling) + +**Architecture constraints:** +- `CommandResult` is the command layer — the TUI calls `get_registry().dispatch(...)` exactly as the CLI does today; no business logic in the TUI +- All events consumed via `AgentEvent` typed stream (`agent/events.py`) — no raw string parsing +- Keep a `--plain` / `--no-tui` flag that falls back to the current Rich renderer (`ui/tui.py`) for headless/pipe use +- The current `ui/tui.py` should be renamed `ui/plain.py`; `ui/tui.py` becomes the Textual app +- New dependency: `textual` (add to `pyproject.toml`) + +**Tests:** +- Unit-test the TUI's event-to-widget mapping in isolation (mock widget tree) +- Integration-test the `--plain` fallback path to avoid regressions +- Snapshot tests for the sidebar stat display and tool-call panels --- diff --git a/docs/superpowers/plans/2026-06-27-phase3-textual-tui.md b/docs/superpowers/plans/2026-06-27-phase3-textual-tui.md new file mode 100644 index 0000000..f57f167 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-phase3-textual-tui.md @@ -0,0 +1,1499 @@ +# Phase 3: Textual TUI Replacement — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Rich print-renderer (`agentforge_harness/ui/tui.py`) with a full Textual interactive terminal app, retaining the Rich renderer as a `--plain` fallback at `agentforge_harness/ui/plain.py`. + +**Architecture:** A thin display layer receives typed `AgentEvent` objects via a `TuiEventAdapter` that mutates a pure `TuiState` dataclass; the Textual `App` subclass drives widget updates from that state. All command dispatch goes exclusively through `get_registry().dispatch(name, argument, ctx) → CommandResult` — zero business logic in TUI files. The `--plain` flag keeps the Rich renderer alive for headless/pipe use. + +**Tech Stack:** Python 3.11+, textual>=1.0, rich (retained for plain fallback), pytest, pytest-asyncio. + +## Global Constraints + +- `from __future__ import annotations` in every new file +- `textual>=1.0` added to `pyproject.toml` project dependencies — no other new top-level deps +- Zero business logic in TUI layer — all commands go through `get_registry().dispatch()` +- Events consumed only via typed `AgentEvent` subclasses from `agent/events.py` +- `--plain` / `--no-tui` flag falls back to the Rich renderer (no feature regressions) +- Current `ui/tui.py` renamed to `ui/plain.py`; new `ui/tui.py` = Textual app +- Do NOT name any external reference project in commits or PR text +- Run `python -m pytest tests/ -x -q` before every commit; keep master green + +--- + +## File Structure + +**New files:** +- `agentforge_harness/ui/state.py` — `TuiState` dataclass (display-only: items, buffer, running flag) +- `agentforge_harness/ui/adapter.py` — `TuiEventAdapter` (AgentEvent → TuiState mutations) +- `agentforge_harness/ui/widgets.py` — `TranscriptView`, `TranscriptMessageWidget`, `StreamingMessageWidget`, `SessionSidebar` +- `agentforge_harness/ui/autocomplete.py` — `CompletionItem`, `CompletionState`, `build_completion_state()` +- `agentforge_harness/ui/config.py` — `TuiTheme`, `TuiKeybindings` (frozen dataclasses) +- `agentforge_harness/ui/tui.py` — `AgentForgeTuiApp(App)` (main Textual application) + +**Modified files:** +- `agentforge_harness/ui/plain.py` — renamed from current `ui/tui.py` (Rich print-renderer, unchanged) +- `agentforge_harness/cli/commands.py` — add `--plain` flag, route to plain.py or new TUI +- `agentforge_harness/cli/run.py` — wire `--plain` flag through to CLI constructor +- `pyproject.toml` — add `textual>=1.0` dependency + +--- + +### Task 1: Foundation — rename plain.py, add textual dep, --plain flag + +**Files:** +- Rename: `agentforge_harness/ui/tui.py` → `agentforge_harness/ui/plain.py` +- Modify: `agentforge_harness/cli/commands.py` +- Modify: `agentforge_harness/cli/run.py` (or wherever CLI entry is) +- Modify: `pyproject.toml` +- Test: `tests/test_plain_renderer.py` + +**Interfaces:** +- Produces: `PlainTUI` class in `ui/plain.py` (rename of `TUI` class, or add alias `PlainTUI = TUI`) +- Produces: `--plain` / `--no-tui` CLI flag that selects `PlainTUI` over the Textual app + +- [ ] **Step 1: Rename tui.py to plain.py** + +```bash +git mv agentforge_harness/ui/tui.py agentforge_harness/ui/plain.py +``` + +- [ ] **Step 2: Add PlainTUI alias to plain.py** + +Open `agentforge_harness/ui/plain.py`. Add at the end of the file: + +```python +# Alias for explicit import from the plain renderer path +PlainTUI = TUI +``` + +Also update any `from agentforge_harness.ui.tui import TUI` imports in the codebase: + +```bash +grep -rn "from agentforge_harness.ui.tui import\|from .tui import\|ui.tui" agentforge_harness/ --include="*.py" +``` + +For each hit, change `from agentforge_harness.ui.tui import TUI` to `from agentforge_harness.ui.plain import TUI`. + +- [ ] **Step 3: Add textual to pyproject.toml** + +In `pyproject.toml`, find the `[project]` `dependencies` list and add: +``` +"textual>=1.0", +``` + +Install: `pip install textual>=1.0` + +- [ ] **Step 4: Add --plain flag to CLI** + +In `agentforge_harness/cli/run.py` (or wherever `typer.run` / argparse entry is), add a `--plain` boolean flag: + +```python +import typer + +app = typer.Typer() + +@app.command() +def main( + plain: bool = typer.Option(False, "--plain", "--no-tui", help="Use plain Rich renderer instead of TUI"), + # ... existing flags ... +) -> None: + from agentforge_harness.config.config import Config + config = Config.from_env() + if plain: + from agentforge_harness.cli.commands import CLI + import asyncio + cli = CLI(config=config) + asyncio.run(cli.run_interactive()) + else: + from agentforge_harness.ui.tui import run_tui + import asyncio + asyncio.run(run_tui(config=config)) +``` + +- [ ] **Step 5: Write failing test** + +Create `tests/test_plain_renderer.py`: + +```python +from __future__ import annotations +import pytest +from agentforge_harness.ui.plain import TUI, PlainTUI + + +def test_plain_tui_alias(): + """PlainTUI and TUI refer to the same class.""" + assert PlainTUI is TUI + + +def test_tui_class_has_required_methods(): + """The plain renderer exposes the same interface as before the rename.""" + assert hasattr(TUI, "show_help") + assert hasattr(TUI, "show_error") + assert hasattr(TUI, "begin_assistant") + assert hasattr(TUI, "stream_assistant_delta") + assert hasattr(TUI, "end_assistant") + assert hasattr(TUI, "tool_call_start") + assert hasattr(TUI, "tool_call_complete") +``` + +- [ ] **Step 6: Run test — must pass** + +```bash +python -m pytest tests/test_plain_renderer.py -v +``` + +Expected: PASS (TUI class already has these methods). + +- [ ] **Step 7: Run full suite** + +```bash +python -m pytest tests/ -x -q 2>&1 | tail -5 +``` + +Expected: same pass count as before (no regressions from rename). + +- [ ] **Step 8: Commit** + +```bash +git add agentforge_harness/ui/plain.py agentforge_harness/cli/run.py pyproject.toml tests/test_plain_renderer.py +git add -u # pick up any imports updated in step 2 +git commit -m "feat(tui): rename Rich renderer to plain.py, add --plain flag and textual dep" +``` + +--- + +### Task 2: TuiState + TuiEventAdapter + +**Files:** +- Create: `agentforge_harness/ui/state.py` +- Create: `agentforge_harness/ui/adapter.py` +- Test: `tests/test_tui_state.py` +- Test: `tests/test_tui_adapter.py` + +**Interfaces:** +- Consumes: `AgentEvent` typed subclasses from `agentforge_harness/agent/events.py` +- Produces: `TuiState` — mutable display state consumed by Task 6 (TUI app) +- Produces: `TuiEventAdapter.apply(event)` — the bridge from events to state + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_tui_state.py`: + +```python +from __future__ import annotations +import pytest +from agentforge_harness.ui.state import TuiState, ChatItem, ChatItemRole + + +def test_initial_state_is_empty(): + state = TuiState() + assert state.items == [] + assert state.assistant_buffer == "" + assert state.running is False + assert state.show_thinking is False + assert state.show_tool_results is True + + +def test_add_user_message(): + state = TuiState() + state.add_user_message("hello") + assert len(state.items) == 1 + assert state.items[0].role == "user" + assert state.items[0].text == "hello" + + +def test_add_assistant_delta_accumulates(): + state = TuiState() + state.flush_assistant_delta("hello ") + state.flush_assistant_delta("world") + items = [i for i in state.items if i.role == "assistant"] + assert len(items) == 1 + assert items[0].text == "hello world" + + +def test_clear_resets_state(): + state = TuiState() + state.add_user_message("hi") + state.running = True + state.clear() + assert state.items == [] + assert state.running is False + + +def test_toggle_thinking(): + state = TuiState() + assert state.show_thinking is False + state.toggle_thinking() + assert state.show_thinking is True + + +def test_toggle_tool_results(): + state = TuiState() + assert state.show_tool_results is True + state.toggle_tool_results() + assert state.show_tool_results is False +``` + +- [ ] **Step 2: Run test — must FAIL** + +```bash +python -m pytest tests/test_tui_state.py -v 2>&1 | head -20 +``` + +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement state.py** + +Create `agentforge_harness/ui/state.py`: + +```python +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Literal + +ChatItemRole = Literal["user", "assistant", "tool", "error", "status", "thinking"] + + +@dataclass +class ChatItem: + role: ChatItemRole + text: str + tool_call_id: str = "" + always_show: bool = False + + +@dataclass +class TuiState: + items: list[ChatItem] = field(default_factory=list) + assistant_buffer: str = "" + thinking_buffer: str = "" + running: bool = False + show_thinking: bool = False + show_tool_results: bool = True + + def add_user_message(self, text: str) -> None: + self.items.append(ChatItem(role="user", text=text)) + + def flush_assistant_delta(self, delta: str) -> None: + self.assistant_buffer += delta + existing = [i for i in self.items if i.role == "assistant"] + if existing: + existing[-1].text = self.assistant_buffer + else: + self.items.append(ChatItem(role="assistant", text=self.assistant_buffer)) + + def finalize_assistant(self) -> None: + self.assistant_buffer = "" + + def flush_thinking_delta(self, delta: str) -> None: + self.thinking_buffer += delta + existing = [i for i in self.items if i.role == "thinking"] + if existing: + existing[-1].text = self.thinking_buffer + else: + self.items.append(ChatItem(role="thinking", text=self.thinking_buffer)) + + def finalize_thinking(self) -> None: + self.thinking_buffer = "" + + def add_tool_item(self, call_id: str, name: str, args: dict) -> None: + import json + text = f"[{name}] {json.dumps(args, ensure_ascii=False)[:200]}" + self.items.append(ChatItem(role="tool", text=text, tool_call_id=call_id)) + + def update_tool_result(self, call_id: str, output: str, success: bool) -> None: + for item in reversed(self.items): + if item.role == "tool" and item.tool_call_id == call_id: + status = "✓" if success else "✗" + item.text = item.text.split("\n")[0] + f"\n{status} {output[:300]}" + return + + def add_error(self, text: str) -> None: + self.items.append(ChatItem(role="error", text=text)) + + def add_status(self, text: str) -> None: + self.items.append(ChatItem(role="status", text=text)) + + def toggle_thinking(self) -> None: + self.show_thinking = not self.show_thinking + + def toggle_tool_results(self) -> None: + self.show_tool_results = not self.show_tool_results + + def clear(self) -> None: + self.items.clear() + self.assistant_buffer = "" + self.thinking_buffer = "" + self.running = False +``` + +- [ ] **Step 4: Write failing adapter test** + +Create `tests/test_tui_adapter.py`: + +```python +from __future__ import annotations +import pytest +from unittest.mock import MagicMock +from agentforge_harness.ui.state import TuiState +from agentforge_harness.ui.adapter import TuiEventAdapter +from agentforge_harness.agent.events import AgentEventType + + +def _make_event(event_type, data=None): + evt = MagicMock() + evt.type = event_type + evt.data = data or {} + return evt + + +def test_agents_start_sets_running(): + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENTS_START)) + assert state.running is True + + +def test_agents_end_clears_running(): + state = TuiState() + state.running = True + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENTS_END, {"content": "done"})) + assert state.running is False + + +def test_text_delta_appends_to_buffer(): + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENTS_START)) + adapter.apply(_make_event(AgentEventType.TEXT_DELTA, {"content": "hello"})) + adapter.apply(_make_event(AgentEventType.TEXT_DELTA, {"content": " world"})) + items = [i for i in state.items if i.role == "assistant"] + assert items[0].text == "hello world" + + +def test_agent_error_adds_error_item(): + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENT_ERROR, {"error": "boom"})) + errors = [i for i in state.items if i.role == "error"] + assert len(errors) == 1 + assert "boom" in errors[0].text +``` + +- [ ] **Step 5: Implement adapter.py** + +Create `agentforge_harness/ui/adapter.py`: + +```python +from __future__ import annotations +from agentforge_harness.ui.state import TuiState +from agentforge_harness.agent.events import AgentEventType + + +class TuiEventAdapter: + """Translates AgentEvent objects into TuiState mutations.""" + + def __init__(self, state: TuiState) -> None: + self._state = state + + def apply(self, event) -> None: + t = event.type + d = event.data or {} + + if t == AgentEventType.AGENTS_START: + self._state.running = True + + elif t == AgentEventType.AGENTS_END: + self._state.running = False + self._state.finalize_assistant() + self._state.finalize_thinking() + + elif t == AgentEventType.TEXT_DELTA: + self._state.flush_assistant_delta(d.get("content", "")) + + elif t == AgentEventType.TEXT_COMPLETE: + self._state.finalize_assistant() + + elif t == AgentEventType.THINKING_DELTA: + self._state.flush_thinking_delta(d.get("content", "")) + + elif t == AgentEventType.TOOL_CALL_START: + self._state.add_tool_item( + call_id=d.get("call_id", ""), + name=d.get("name", "unknown"), + args=d.get("arguments", {}), + ) + + elif t == AgentEventType.TOOL_CALL_COMPLETE: + self._state.update_tool_result( + call_id=d.get("call_id", ""), + output=str(d.get("output", "")), + success=d.get("success", False), + ) + + elif t == AgentEventType.AGENT_ERROR: + self._state.running = False + self._state.add_error(d.get("error", "Unknown error")) +``` + +- [ ] **Step 6: Run tests — must pass** + +```bash +python -m pytest tests/test_tui_state.py tests/test_tui_adapter.py -v +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add agentforge_harness/ui/state.py agentforge_harness/ui/adapter.py tests/test_tui_state.py tests/test_tui_adapter.py +git commit -m "feat(tui): add TuiState dataclass and TuiEventAdapter" +``` + +--- + +### Task 3: TuiTheme + TuiKeybindings config + +**Files:** +- Create: `agentforge_harness/ui/config.py` +- Test: `tests/test_tui_config.py` + +**Interfaces:** +- Produces: `TuiTheme` frozen dataclass with role colors, used by Tasks 4 and 6 +- Produces: `TuiKeybindings` frozen dataclass, used by Task 6 + +- [ ] **Step 1: Write failing test** + +Create `tests/test_tui_config.py`: + +```python +from __future__ import annotations +import pytest +from agentforge_harness.ui.config import TuiTheme, TuiKeybindings, DEFAULT_THEME + + +def test_default_theme_is_frozen(): + with pytest.raises((AttributeError, TypeError)): + DEFAULT_THEME.screen_background = "red" + + +def test_theme_has_role_styles(): + assert "user" in DEFAULT_THEME.role_styles + assert "assistant" in DEFAULT_THEME.role_styles + assert "tool" in DEFAULT_THEME.role_styles + assert "error" in DEFAULT_THEME.role_styles + assert "thinking" in DEFAULT_THEME.role_styles + + +def test_default_keybindings(): + kb = TuiKeybindings() + assert kb.cancel == "escape" + assert kb.queue_steer == "alt+enter" + assert kb.toggle_thinking == "ctrl+t" + assert kb.toggle_tool_results == "ctrl+o" + assert kb.session_picker == "ctrl+r" +``` + +- [ ] **Step 2: Run test — must FAIL** + +```bash +python -m pytest tests/test_tui_config.py -v 2>&1 | head -10 +``` + +- [ ] **Step 3: Implement config.py** + +Create `agentforge_harness/ui/config.py`: + +```python +from __future__ import annotations +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class TuiRoleStyle: + border: str + body: str + + +@dataclass(frozen=True, slots=True) +class TuiTheme: + name: str + screen_background: str + screen_text: str + accent: str + prompt_border_focused: str + role_styles: dict[str, TuiRoleStyle] + + def css_variables(self) -> dict[str, str]: + return { + "af-background": self.screen_background, + "af-text": self.screen_text, + "af-accent": self.accent, + "af-prompt-border": self.prompt_border_focused, + } + + +DEFAULT_THEME = TuiTheme( + name="af-dark", + screen_background="#1a1a2e", + screen_text="#e0e0e0", + accent="#4ec9b0", + prompt_border_focused="#4ec9b0", + role_styles={ + "user": TuiRoleStyle(border="#7c8ea6", body="#d8dee9"), + "assistant": TuiRoleStyle(border="#4ec9b0", body="#d8dee9"), + "tool": TuiRoleStyle(border="#8a7a52", body="#cbd5e1"), + "error": TuiRoleStyle(border="#ff4f4f", body="#ffb4b4"), + "thinking": TuiRoleStyle(border="#4b5563", body="#9ca3af"), + "status": TuiRoleStyle(border="#555577", body="#aaaacc"), + }, +) + + +@dataclass(frozen=True, slots=True) +class TuiKeybindings: + cancel: str = "escape" + queue_steer: str = "alt+enter" + toggle_thinking: str = "ctrl+t" + toggle_tool_results: str = "ctrl+o" + session_picker: str = "ctrl+r" + branch_picker: str = "ctrl+b" + quit: str = "ctrl+d" + accept_completion: str = "tab" + completion_next: str = "down" + completion_previous: str = "up" +``` + +- [ ] **Step 4: Run test — must pass** + +```bash +python -m pytest tests/test_tui_config.py -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add agentforge_harness/ui/config.py tests/test_tui_config.py +git commit -m "feat(tui): add TuiTheme and TuiKeybindings config dataclasses" +``` + +--- + +### Task 4: Autocomplete engine + +**Files:** +- Create: `agentforge_harness/ui/autocomplete.py` +- Test: `tests/test_tui_autocomplete.py` + +**Interfaces:** +- Consumes: command names from `get_registry().known_commands` (list of str) +- Produces: `CompletionState` with `CompletionItem` list; `build_completion_state(text, *, commands, cwd)` factory + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_tui_autocomplete.py`: + +```python +from __future__ import annotations +import pytest +from pathlib import Path +from agentforge_harness.ui.autocomplete import ( + build_completion_state, + CompletionState, + CompletionItem, +) + + +def test_slash_prefix_suggests_commands(): + state = build_completion_state("/he", commands=["/help", "/history", "/exit"]) + assert len(state.items) >= 1 + names = [i.replacement for i in state.items] + assert "/help" in names + + +def test_no_prefix_returns_empty(): + state = build_completion_state("hello world", commands=["/help"]) + assert state.items == [] + + +def test_exact_slash_shows_all_commands(): + commands = ["/help", "/exit", "/stats"] + state = build_completion_state("/", commands=commands) + replacements = [i.replacement for i in state.items] + for cmd in commands: + assert cmd in replacements + + +def test_select_next_wraps(): + state = CompletionState(items=[ + CompletionItem(display="/help", replacement="/help", start=0, end=1), + CompletionItem(display="/history", replacement="/history", start=0, end=1), + ]) + assert state.selected_index == 0 + state2 = state.select_next() + assert state2.selected_index == 1 + state3 = state2.select_next() + assert state3.selected_index == 0 # wraps + + +def test_apply_replaces_text(): + item = CompletionItem(display="/help", replacement="/help", start=0, end=3) + result = item.apply("/he") + assert result == "/help" +``` + +- [ ] **Step 2: Run test — must FAIL** + +```bash +python -m pytest tests/test_tui_autocomplete.py -v 2>&1 | head -15 +``` + +- [ ] **Step 3: Implement autocomplete.py** + +Create `agentforge_harness/ui/autocomplete.py`: + +```python +from __future__ import annotations +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class CompletionItem: + display: str + replacement: str + start: int + end: int + description: str = "" + + def apply(self, text: str) -> str: + return f"{text[:self.start]}{self.replacement}{text[self.end:]}" + + +@dataclass(frozen=True, slots=True) +class CompletionState: + items: tuple[CompletionItem, ...] = () + selected_index: int = 0 + + @property + def selected(self) -> CompletionItem | None: + if not self.items: + return None + return self.items[self.selected_index] + + def select_next(self) -> CompletionState: + if not self.items: + return self + return CompletionState( + items=self.items, + selected_index=(self.selected_index + 1) % len(self.items), + ) + + def select_previous(self) -> CompletionState: + if not self.items: + return self + return CompletionState( + items=self.items, + selected_index=(self.selected_index - 1) % len(self.items), + ) + + +def build_completion_state( + text: str, + *, + commands: list[str], + cwd: Path | None = None, +) -> CompletionState: + """Build completion suggestions for the current prompt text.""" + if not text.startswith("/"): + return CompletionState() + + # Find slash commands matching the prefix + prefix = text.lower() + matched = [ + CompletionItem( + display=cmd, + replacement=cmd, + start=0, + end=len(text), + ) + for cmd in sorted(commands) + if cmd.lower().startswith(prefix) + ] + return CompletionState(items=tuple(matched)) +``` + +- [ ] **Step 4: Run tests — must pass** + +```bash +python -m pytest tests/test_tui_autocomplete.py -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add agentforge_harness/ui/autocomplete.py tests/test_tui_autocomplete.py +git commit -m "feat(tui): add autocomplete engine for slash-command completion" +``` + +--- + +### Task 5: Textual Widgets — TranscriptView, SessionSidebar + +**Files:** +- Create: `agentforge_harness/ui/widgets.py` +- Test: `tests/test_tui_widgets.py` + +**Interfaces:** +- Consumes: `TuiState` from `ui/state.py`, `TuiTheme` from `ui/config.py` +- Produces: `TranscriptView`, `TranscriptMessageWidget`, `SessionSidebar` — Textual widget classes + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_tui_widgets.py`: + +```python +from __future__ import annotations +import pytest +from agentforge_harness.ui.state import TuiState, ChatItem +from agentforge_harness.ui.config import DEFAULT_THEME +from agentforge_harness.ui.widgets import ( + TranscriptView, + SessionSidebar, + _format_role_label, + _item_to_markup, +) + + +def test_format_role_label_user(): + label = _format_role_label("user", DEFAULT_THEME) + assert "user" in label.lower() or "▌" in label + + +def test_item_to_markup_assistant(): + item = ChatItem(role="assistant", text="hello world") + markup = _item_to_markup(item, DEFAULT_THEME, show_tool_results=True) + assert "hello world" in markup + + +def test_item_to_markup_error(): + item = ChatItem(role="error", text="something broke") + markup = _item_to_markup(item, DEFAULT_THEME, show_tool_results=True) + assert "something broke" in markup + + +def test_item_to_markup_thinking_hidden(): + item = ChatItem(role="thinking", text="internal reasoning") + markup = _item_to_markup(item, DEFAULT_THEME, show_tool_results=True, show_thinking=False) + assert "internal reasoning" not in markup + assert "[thinking hidden]" in markup or markup == "" + + +def test_transcript_view_is_textual_widget(): + from textual.widget import Widget + assert issubclass(TranscriptView, Widget) + + +def test_session_sidebar_is_textual_widget(): + from textual.widget import Widget + assert issubclass(SessionSidebar, Widget) +``` + +- [ ] **Step 2: Run test — must FAIL** + +```bash +python -m pytest tests/test_tui_widgets.py -v 2>&1 | head -20 +``` + +- [ ] **Step 3: Implement widgets.py** + +Create `agentforge_harness/ui/widgets.py`: + +```python +from __future__ import annotations +from typing import TYPE_CHECKING +from textual.widget import Widget +from textual.widgets import Static +from textual.scroll_view import ScrollView +from textual.app import ComposeResult +from agentforge_harness.ui.state import TuiState, ChatItem, ChatItemRole +from agentforge_harness.ui.config import TuiTheme, DEFAULT_THEME + +if TYPE_CHECKING: + pass + + +def _format_role_label(role: ChatItemRole, theme: TuiTheme) -> str: + style = theme.role_styles.get(role) + color = style.border if style else "#888888" + labels = { + "user": "you", + "assistant": "agent", + "tool": "tool", + "error": "error", + "thinking": "thinking", + "status": "info", + } + label = labels.get(role, role) + return f"[{color}]▌ {label}[/{color}]" + + +def _item_to_markup( + item: ChatItem, + theme: TuiTheme, + *, + show_tool_results: bool = True, + show_thinking: bool = True, +) -> str: + if item.role == "thinking" and not show_thinking: + return "[dim][ thinking hidden — Ctrl+T to show ][/dim]" + style = theme.role_styles.get(item.role) + color = style.body if style else "#cccccc" + # Escape Rich markup in user content + safe_text = item.text.replace("[", "\\[") + return f"[{color}]{safe_text}[/{color}]" + + +class TranscriptMessageWidget(Static): + """A single rendered transcript message.""" + + def __init__( + self, + item: ChatItem, + theme: TuiTheme = DEFAULT_THEME, + show_tool_results: bool = True, + show_thinking: bool = True, + **kwargs, + ) -> None: + self._item = item + self._theme = theme + self._show_tool_results = show_tool_results + self._show_thinking = show_thinking + label = _format_role_label(item.role, theme) + body = _item_to_markup(item, theme, show_tool_results=show_tool_results, show_thinking=show_thinking) + content = f"{label}\n{body}" if body else label + super().__init__(content, **kwargs) + + def update_item(self, item: ChatItem) -> None: + self._item = item + label = _format_role_label(item.role, self._theme) + body = _item_to_markup( + item, + self._theme, + show_tool_results=self._show_tool_results, + show_thinking=self._show_thinking, + ) + self.update(f"{label}\n{body}" if body else label) + + +class TranscriptView(Widget): + """Scrollable transcript panel with lazy-mounted message widgets.""" + + DEFAULT_CSS = """ + TranscriptView { + height: 1fr; + overflow-y: scroll; + padding: 0 1; + } + TranscriptMessageWidget { + margin-bottom: 1; + } + """ + + def __init__(self, state: TuiState, theme: TuiTheme = DEFAULT_THEME, **kwargs) -> None: + self._state = state + self._theme = theme + super().__init__(**kwargs) + + def compose(self) -> ComposeResult: + for item in self._state.items: + if item.role == "thinking" and not self._state.show_thinking: + yield TranscriptMessageWidget( + item, + theme=self._theme, + show_thinking=False, + ) + else: + yield TranscriptMessageWidget( + item, + theme=self._theme, + show_tool_results=self._state.show_tool_results, + show_thinking=self._state.show_thinking, + ) + + def refresh_from_state(self) -> None: + """Full redraw — call after state mutations.""" + self.remove_children() + for item in self._state.items: + self.mount( + TranscriptMessageWidget( + item, + theme=self._theme, + show_tool_results=self._state.show_tool_results, + show_thinking=self._state.show_thinking, + ) + ) + self.scroll_end(animate=False) + + def append_delta(self, delta: str) -> None: + """Efficiently append streamed text to the last assistant widget.""" + assistant_widgets = [ + w for w in self.children + if isinstance(w, TranscriptMessageWidget) and w._item.role == "assistant" + ] + if assistant_widgets: + last = assistant_widgets[-1] + last.update_item(last._item) + self.scroll_end(animate=False) + + +class SessionSidebar(Static): + """Left sidebar showing session metadata.""" + + DEFAULT_CSS = """ + SessionSidebar { + width: 28; + padding: 1; + border-right: solid #333355; + } + """ + + def update_from_info( + self, + *, + mode: str, + model: str, + turn: int, + prompt_tokens: int, + completion_tokens: int, + theme: TuiTheme = DEFAULT_THEME, + ) -> None: + accent = theme.accent + content = ( + f"[{accent}]◆ {mode.upper()}[/{accent}]\n" + f"model: {model}\n" + f"turn: #{turn}\n" + f"in: {prompt_tokens:,}\n" + f"out: {completion_tokens:,}" + ) + self.update(content) +``` + +- [ ] **Step 4: Run tests — must pass** + +```bash +python -m pytest tests/test_tui_widgets.py -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add agentforge_harness/ui/widgets.py tests/test_tui_widgets.py +git commit -m "feat(tui): add TranscriptView, TranscriptMessageWidget and SessionSidebar widgets" +``` + +--- + +### Task 6: Main TUI App — AgentForgeTuiApp + +**Files:** +- Create: `agentforge_harness/ui/tui.py` +- Test: `tests/test_tui_app.py` + +**Interfaces:** +- Consumes: All of Tasks 2–5 (state, adapter, config, widgets, autocomplete) +- Consumes: `get_registry().dispatch()` → `CommandResult` from `cli/command_registry.py` +- Consumes: `Agent` from `agent/agent.py` (for async event iteration) +- Produces: `AgentForgeTuiApp(App)` + `run_tui(config)` entry point + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_tui_app.py`: + +```python +from __future__ import annotations +import pytest +from agentforge_harness.ui.tui import AgentForgeTuiApp, run_tui + + +def test_run_tui_is_callable(): + """run_tui must exist and be importable.""" + import inspect + assert inspect.iscoroutinefunction(run_tui) + + +def test_app_class_exists(): + from textual.app import App + assert issubclass(AgentForgeTuiApp, App) + + +def test_app_has_required_bindings(): + """Key bindings include quit and toggle_thinking.""" + binding_keys = [b.key for b in AgentForgeTuiApp.BINDINGS] + assert "ctrl+d" in binding_keys + assert "ctrl+t" in binding_keys + assert "ctrl+o" in binding_keys +``` + +- [ ] **Step 2: Run test — must FAIL** + +```bash +python -m pytest tests/test_tui_app.py -v 2>&1 | head -15 +``` + +- [ ] **Step 3: Implement tui.py** + +Create `agentforge_harness/ui/tui.py`: + +```python +from __future__ import annotations +import asyncio +from typing import Any +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Input, Footer, Header, Static +from textual.containers import Horizontal, Vertical +from agentforge_harness.ui.state import TuiState +from agentforge_harness.ui.adapter import TuiEventAdapter +from agentforge_harness.ui.config import DEFAULT_THEME, TuiTheme, TuiKeybindings +from agentforge_harness.ui.widgets import TranscriptView, SessionSidebar +from agentforge_harness.ui.autocomplete import build_completion_state, CompletionState +from agentforge_harness.agent.agent import Agent +from agentforge_harness.agent.events import AgentEventType +from agentforge_harness.cli.command_registry import CommandContext, get_registry +from agentforge_harness.config.config import Config + +_SIDEBAR_MIN_WIDTH = 96 + + +class PromptInput(Input): + """Single-line prompt input with slash-command awareness.""" + + def __init__(self, **kwargs) -> None: + super().__init__(placeholder="Type a message or /command...", **kwargs) + + +class AgentForgeTuiApp(App): + """Full Textual TUI for AgentForge.""" + + CSS = """ + Screen { + background: #1a1a2e; + } + #workspace { + height: 1fr; + } + #sidebar { + width: 28; + border-right: solid #333355; + padding: 1; + } + #main-pane { + height: 1fr; + } + #transcript { + height: 1fr; + } + #prompt-row { + height: auto; + border-top: solid #333355; + padding: 0 1; + } + #prompt-prefix { + width: 3; + padding-top: 1; + color: #4ec9b0; + } + #prompt { + height: auto; + border: none; + background: transparent; + } + #autocomplete { + height: auto; + max-height: 10; + background: #222244; + border: solid #444466; + } + AgentForgeTuiApp.-hide-sidebar #sidebar { + display: none; + } + """ + + BINDINGS = [ + Binding("ctrl+d", "quit", "Quit"), + Binding("ctrl+t", "toggle_thinking", "Thinking"), + Binding("ctrl+o", "toggle_tool_results", "Tools"), + Binding("ctrl+r", "session_picker", "Sessions"), + Binding("escape", "cancel_run", "Cancel"), + ] + + def __init__(self, config: Config, **kwargs) -> None: + super().__init__(**kwargs) + self.config = config + self.state = TuiState() + self.theme = DEFAULT_THEME + self.keybindings = TuiKeybindings() + self._agent: Agent | None = None + self._run_task: asyncio.Task | None = None + self._completion_state = CompletionState() + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with Horizontal(id="workspace"): + yield SessionSidebar(id="sidebar") + with Vertical(id="main-pane"): + yield TranscriptView(self.state, self.theme, id="transcript") + with Horizontal(id="prompt-row"): + yield Static("❯", id="prompt-prefix") + yield PromptInput(id="prompt") + yield Static("", id="autocomplete") + yield Footer() + + async def on_mount(self) -> None: + self._agent = Agent(config=self.config) + await self._agent.__aenter__() + self.query_one("#prompt", PromptInput).focus() + self._update_sidebar() + self._update_responsive() + + async def on_unmount(self) -> None: + if self._agent: + await self._agent.__aexit__(None, None, None) + + def on_resize(self) -> None: + self._update_responsive() + + def _update_responsive(self) -> None: + self.set_class(self.size.width < _SIDEBAR_MIN_WIDTH, "-hide-sidebar") + + def _update_sidebar(self) -> None: + sidebar = self.query_one("#sidebar", SessionSidebar) + if self._agent and self._agent.session: + s = self._agent.session + try: + usage = s.context_manager.get_total_usage() if s.context_manager else None + except Exception: + usage = None + sidebar.update_from_info( + mode=s.mode.value if s.mode else "build", + model=self.config.model_name, + turn=s._turn_count if hasattr(s, "_turn_count") else 0, + prompt_tokens=usage.prompt_tokens if usage else 0, + completion_tokens=usage.completion_tokens if usage else 0, + theme=self.theme, + ) + + async def on_input_submitted(self, event: Input.Submitted) -> None: + """User pressed Enter in the prompt.""" + text = event.value.strip() + event.input.clear() + if not text: + return + + # Clear autocomplete + self._completion_state = CompletionState() + self.query_one("#autocomplete", Static).update("") + + if text.startswith("/"): + await self._handle_command(text) + else: + if self.state.running: + # Inject as steer while running + await self._queue_message(text, mode="steer") + else: + await self._run_prompt(text) + + async def on_input_changed(self, event: Input.Changed) -> None: + """Update autocomplete as user types.""" + text = event.value + if not text.startswith("/"): + self._completion_state = CompletionState() + self.query_one("#autocomplete", Static).update("") + return + + commands = list(get_registry().known_commands) + self._completion_state = build_completion_state(text, commands=commands) + if self._completion_state.items: + items_text = " ".join(i.display for i in self._completion_state.items[:8]) + self.query_one("#autocomplete", Static).update(f"[dim]{items_text}[/dim]") + else: + self.query_one("#autocomplete", Static).update("") + + async def on_key(self, event) -> None: + """Handle special keys for steering and autocomplete.""" + if event.key == "alt+enter": + event.stop() + prompt = self.query_one("#prompt", PromptInput) + text = prompt.value.strip() + if text and self.state.running: + prompt.clear() + await self._queue_message(text, mode="steer") + + elif event.key == "tab" and self._completion_state.selected: + event.stop() + prompt = self.query_one("#prompt", PromptInput) + item = self._completion_state.selected + prompt.value = item.apply(prompt.value) + prompt.cursor_position = len(prompt.value) + self._completion_state = CompletionState() + self.query_one("#autocomplete", Static).update("") + + async def _handle_command(self, text: str) -> None: + parts = text.split(maxsplit=1) + name = parts[0].lower() + argument = parts[1].strip() if len(parts) > 1 else "" + ctx = CommandContext( + session=self._agent.session if self._agent else None, + config=self.config, + agent=self._agent, + last_user_message="", + ) + result = await get_registry().dispatch(name, argument, ctx) + if result.exit: + await self.action_quit() + return + if result.error: + self.state.add_error(result.error) + elif result.notice: + self.state.add_status(result.notice) + self._refresh_transcript() + + async def _run_prompt(self, message: str) -> None: + if not self._agent: + return + self.state.add_user_message(message) + self._refresh_transcript() + adapter = TuiEventAdapter(self.state) + + async def _stream(): + async for event in self._agent.run(message): + adapter.apply(event) + transcript = self.query_one("#transcript", TranscriptView) + if event.type == AgentEventType.TEXT_DELTA: + transcript.append_delta(event.data.get("content", "")) + else: + transcript.refresh_from_state() + self._update_sidebar() + + self._run_task = asyncio.create_task(_stream()) + try: + await self._run_task + except asyncio.CancelledError: + pass + finally: + self.state.running = False + self._run_task = None + self._refresh_transcript() + + async def _queue_message(self, text: str, mode: str = "follow_up") -> None: + if self._agent and self._agent.session: + self._agent.session.prompt(text, mode=mode) + self.state.add_status(f"[queued {mode}]: {text[:60]}") + self._refresh_transcript() + + def _refresh_transcript(self) -> None: + try: + self.query_one("#transcript", TranscriptView).refresh_from_state() + except Exception: + pass + + def action_toggle_thinking(self) -> None: + self.state.toggle_thinking() + self._refresh_transcript() + + def action_toggle_tool_results(self) -> None: + self.state.toggle_tool_results() + self._refresh_transcript() + + def action_cancel_run(self) -> None: + if self._run_task and not self._run_task.done(): + self._run_task.cancel() + if self._agent and self._agent.session: + self._agent.session.request_cancel() + + def action_session_picker(self) -> None: + self.state.add_status("Session picker: use /new or /resume ") + self._refresh_transcript() + + async def action_quit(self) -> None: + self.action_cancel_run() + self.exit() + + +async def run_tui(config: Config) -> None: + """Entry point: run the Textual TUI.""" + app = AgentForgeTuiApp(config=config) + await app.run_async() +``` + +- [ ] **Step 4: Run tests — must pass** + +```bash +python -m pytest tests/test_tui_app.py -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add agentforge_harness/ui/tui.py tests/test_tui_app.py +git commit -m "feat(tui): add AgentForgeTuiApp main Textual application" +``` + +--- + +### Task 7: Integration wiring + smoke test + +**Files:** +- Modify: `agentforge_harness/cli/run.py` — wire `--plain` flag to select renderer +- Modify: `agentforge_harness/ui/__init__.py` — export `run_tui` and `PlainTUI` +- Test: `tests/test_tui_integration.py` + +**Interfaces:** +- Consumes: `run_tui` from `ui/tui.py`, `CLI` from `cli/commands.py` +- Produces: `--plain` flag selects plain renderer; default selects Textual TUI + +- [ ] **Step 1: Write failing integration test** + +Create `tests/test_tui_integration.py`: + +```python +from __future__ import annotations +import pytest +from agentforge_harness.ui import run_tui +from agentforge_harness.ui.plain import TUI as PlainTUI +from agentforge_harness.ui.tui import AgentForgeTuiApp +from agentforge_harness.ui.state import TuiState +from agentforge_harness.ui.adapter import TuiEventAdapter +from agentforge_harness.ui.config import DEFAULT_THEME +from agentforge_harness.ui.autocomplete import build_completion_state +from unittest.mock import MagicMock +from agentforge_harness.agent.events import AgentEventType + + +def test_ui_package_exports(): + """run_tui is importable from the ui package.""" + import inspect + assert inspect.iscoroutinefunction(run_tui) + + +def test_plain_tui_still_works(): + """PlainTUI is importable and has the same interface as before.""" + assert hasattr(PlainTUI, "show_error") + assert hasattr(PlainTUI, "show_help") + + +def test_event_pipeline_end_to_end(): + """Events flow: AGENTS_START → TEXT_DELTA → AGENTS_END → state updated.""" + state = TuiState() + adapter = TuiEventAdapter(state) + + def evt(t, data=None): + m = MagicMock() + m.type = t + m.data = data or {} + return m + + adapter.apply(evt(AgentEventType.AGENTS_START)) + assert state.running is True + + adapter.apply(evt(AgentEventType.TEXT_DELTA, {"content": "hello "})) + adapter.apply(evt(AgentEventType.TEXT_DELTA, {"content": "world"})) + + adapter.apply(evt(AgentEventType.AGENTS_END, {"content": "hello world"})) + assert state.running is False + + assistant_items = [i for i in state.items if i.role == "assistant"] + assert len(assistant_items) == 1 + assert "hello" in assistant_items[0].text + + +def test_autocomplete_slash_commands(): + cmds = ["/help", "/exit", "/stats", "/history"] + state = build_completion_state("/hi", commands=cmds) + assert len(state.items) == 1 + assert state.items[0].replacement == "/history" + + +def test_state_theme_role_coverage(): + """Every role in TuiState has a theme entry.""" + roles = ["user", "assistant", "tool", "error", "thinking", "status"] + for role in roles: + assert role in DEFAULT_THEME.role_styles, f"Missing theme for role: {role}" +``` + +- [ ] **Step 2: Update ui/__init__.py** + +Edit `agentforge_harness/ui/__init__.py` to expose: + +```python +from agentforge_harness.ui.tui import run_tui, AgentForgeTuiApp +from agentforge_harness.ui.plain import TUI as PlainTUI + +__all__ = ["run_tui", "AgentForgeTuiApp", "PlainTUI"] +``` + +- [ ] **Step 3: Wire --plain in run.py** + +In `agentforge_harness/cli/run.py`, update the entry-point function so `--plain` selects the plain renderer: + +```python +import typer +import asyncio + +app_cli = typer.Typer(add_completion=False) + +@app_cli.command() +def main( + plain: bool = typer.Option(False, "--plain", "--no-tui", help="Use plain Rich renderer"), + message: str = typer.Argument(None, help="Single message (non-interactive)"), +) -> None: + from agentforge_harness.config.loader import load_config + config = load_config() + if plain or message: + from agentforge_harness.cli.commands import CLI + cli = CLI(config=config) + if message: + asyncio.run(cli.run_single(message)) + else: + asyncio.run(cli.run_interactive()) + else: + from agentforge_harness.ui.tui import run_tui + asyncio.run(run_tui(config=config)) +``` + +(Read the actual run.py first and match its import patterns before applying this.) + +- [ ] **Step 4: Run all tests** + +```bash +python -m pytest tests/ -x -q 2>&1 | tail -10 +``` + +Expected: all tests pass including the new integration suite. + +- [ ] **Step 5: Verify textual imports work** + +```bash +python -c "from agentforge_harness.ui.tui import AgentForgeTuiApp; print('OK')" +python -c "from agentforge_harness.ui.plain import TUI; print('OK')" +python -c "from agentforge_harness.ui import run_tui; print('OK')" +``` + +- [ ] **Step 6: Commit** + +```bash +git add agentforge_harness/ui/__init__.py agentforge_harness/cli/run.py tests/test_tui_integration.py +git commit -m "feat(tui): wire --plain flag and expose run_tui from ui package" +``` + +--- + +## GSTACK REVIEW REPORT + +| Run | Reviewer | Status | Findings | Verdict | +|-----|----------|--------|----------|---------| +| — | — | NO REVIEWS YET — run `/autoplan` | — | — | diff --git a/pyproject.toml b/pyproject.toml index 2ffb5bb..6220daf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "tomli; python_version < '3.11'", "ddgs", "fastmcp", + "textual>=1.0", ] [project.urls] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 81fb172..4c5c734 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -18,7 +18,7 @@ ModelConfig, ModelProvider, ) -from agentforge_harness.ui.tui import AGENT_THEME +from agentforge_harness.ui.plain import AGENT_THEME def _statuses(report): diff --git a/tests/test_plain_renderer.py b/tests/test_plain_renderer.py new file mode 100644 index 0000000..f072fac --- /dev/null +++ b/tests/test_plain_renderer.py @@ -0,0 +1,17 @@ +from __future__ import annotations +import pytest +from agentforge_harness.ui.plain import TUI, PlainTUI + + +def test_plain_tui_alias(): + assert PlainTUI is TUI + + +def test_tui_class_has_required_methods(): + assert hasattr(TUI, "show_help") + assert hasattr(TUI, "show_error") + assert hasattr(TUI, "begin_assistant") + assert hasattr(TUI, "stream_assistant_delta") + assert hasattr(TUI, "end_assistant") + assert hasattr(TUI, "tool_call_start") + assert hasattr(TUI, "tool_call_complete") diff --git a/tests/test_tui_adapter.py b/tests/test_tui_adapter.py new file mode 100644 index 0000000..21a3ed1 --- /dev/null +++ b/tests/test_tui_adapter.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock + +from agentforge_harness.agent.events import AgentEventType +from agentforge_harness.ui.adapter import TuiEventAdapter +from agentforge_harness.ui.state import TuiState + + +def _make_event(event_type: AgentEventType, data: dict | None = None) -> MagicMock: + evt = MagicMock() + evt.type = event_type + evt.data = data or {} + return evt + + +def test_agents_start_sets_running() -> None: + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENT_START)) + assert state.running is True + + +def test_agents_end_clears_running() -> None: + state = TuiState() + state.running = True + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENT_END, {"content": "done"})) + assert state.running is False + + +def test_text_delta_appends_to_buffer() -> None: + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENT_START)) + adapter.apply(_make_event(AgentEventType.TEXT_DELTA, {"content": "hello"})) + adapter.apply(_make_event(AgentEventType.TEXT_DELTA, {"content": " world"})) + items = [i for i in state.items if i.role == "assistant"] + assert items[0].text == "hello world" + + +def test_agent_error_adds_error_item() -> None: + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENT_ERROR, {"error": "boom"})) + errors = [i for i in state.items if i.role == "error"] + assert len(errors) == 1 + assert "boom" in errors[0].text + + +def test_text_complete_clears_buffer() -> None: + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.TEXT_DELTA, {"content": "hi"})) + assert state.assistant_buffer == "hi" + adapter.apply(_make_event(AgentEventType.TEXT_COMPLETE, {"content": "hi"})) + assert state.assistant_buffer == "" + + +def test_thinking_delta_appends() -> None: + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.THINKING_DELTA, {"content": "step 1"})) + adapter.apply(_make_event(AgentEventType.THINKING_DELTA, {"content": " step 2"})) + items = [i for i in state.items if i.role == "thinking"] + assert items[0].text == "step 1 step 2" + + +def test_tool_call_start_adds_tool_item() -> None: + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event( + AgentEventType.TOOL_CALL_START, + {"call_id": "t1", "name": "bash", "arguments": {"cmd": "ls"}}, + )) + items = [i for i in state.items if i.role == "tool"] + assert len(items) == 1 + assert "bash" in items[0].text + assert items[0].tool_call_id == "t1" + + +def test_tool_call_complete_updates_result() -> None: + state = TuiState() + adapter = TuiEventAdapter(state) + adapter.apply(_make_event( + AgentEventType.TOOL_CALL_START, + {"call_id": "t2", "name": "read", "arguments": {}}, + )) + adapter.apply(_make_event( + AgentEventType.TOOL_CALL_COMPLETE, + {"call_id": "t2", "output": "contents", "success": True}, + )) + items = [i for i in state.items if i.role == "tool"] + assert "✓" in items[0].text + + +def test_agent_error_sets_running_false() -> None: + state = TuiState() + state.running = True + adapter = TuiEventAdapter(state) + adapter.apply(_make_event(AgentEventType.AGENT_ERROR, {"error": "crash"})) + assert state.running is False diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py new file mode 100644 index 0000000..85390af --- /dev/null +++ b/tests/test_tui_app.py @@ -0,0 +1,17 @@ +from __future__ import annotations +import inspect +import pytest +from textual.app import App +from agentforge_harness.ui.tui import AgentForgeTuiApp, run_tui + +def test_run_tui_is_coroutine(): + assert inspect.iscoroutinefunction(run_tui) + +def test_app_is_textual_app(): + assert issubclass(AgentForgeTuiApp, App) + +def test_app_has_required_bindings(): + binding_keys = [b.key for b in AgentForgeTuiApp.BINDINGS] + assert "ctrl+d" in binding_keys + assert "ctrl+t" in binding_keys + assert "ctrl+o" in binding_keys diff --git a/tests/test_tui_autocomplete.py b/tests/test_tui_autocomplete.py new file mode 100644 index 0000000..d566df1 --- /dev/null +++ b/tests/test_tui_autocomplete.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import pytest + +from agentforge_harness.ui.autocomplete import ( + CompletionItem, + CompletionState, + build_completion_state, +) + + +def test_slash_prefix_suggests_commands() -> None: + state = build_completion_state("/he", commands=["/help", "/history", "/exit"]) + names = [i.replacement for i in state.items] + assert "/help" in names + + +def test_no_prefix_returns_empty() -> None: + state = build_completion_state("hello world", commands=["/help"]) + assert state.items == () + + +def test_exact_slash_shows_all_commands() -> None: + commands = ["/help", "/exit", "/stats"] + state = build_completion_state("/", commands=commands) + replacements = [i.replacement for i in state.items] + for cmd in commands: + assert cmd in replacements + + +def test_select_next_wraps() -> None: + state = CompletionState( + items=( + CompletionItem(display="/help", replacement="/help", start=0, end=1), + CompletionItem(display="/history", replacement="/history", start=0, end=1), + ) + ) + assert state.selected_index == 0 + state2 = state.select_next() + assert state2.selected_index == 1 + state3 = state2.select_next() + assert state3.selected_index == 0 + + +def test_apply_replaces_text() -> None: + item = CompletionItem(display="/help", replacement="/help", start=0, end=3) + result = item.apply("/he") + assert result == "/help" + + +def test_select_previous_wraps() -> None: + state = CompletionState( + items=( + CompletionItem(display="/a", replacement="/a", start=0, end=1), + CompletionItem(display="/b", replacement="/b", start=0, end=1), + CompletionItem(display="/c", replacement="/c", start=0, end=1), + ) + ) + state2 = state.select_previous() + assert state2.selected_index == 2 + + +def test_selected_returns_none_when_empty() -> None: + state = CompletionState() + assert state.selected is None + + +def test_selected_returns_correct_item() -> None: + item = CompletionItem(display="/help", replacement="/help", start=0, end=1) + state = CompletionState(items=(item,)) + assert state.selected is item + + +def test_select_next_on_empty_returns_same() -> None: + state = CompletionState() + state2 = state.select_next() + assert state2.selected_index == 0 + assert state2.items == () + + +def test_select_previous_on_empty_returns_same() -> None: + state = CompletionState() + state2 = state.select_previous() + assert state2.selected_index == 0 + + +def test_completion_items_sorted() -> None: + state = build_completion_state("/", commands=["/zzz", "/aaa", "/mmm"]) + displays = [i.display for i in state.items] + assert displays == sorted(displays) + + +def test_case_insensitive_prefix_matching() -> None: + state = build_completion_state("/HE", commands=["/help", "/hero", "/exit"]) + names = [i.replacement for i in state.items] + assert "/help" in names + assert "/hero" in names + assert "/exit" not in names + + +def test_non_matching_prefix_returns_empty() -> None: + state = build_completion_state("/xyz", commands=["/help", "/exit"]) + assert state.items == () diff --git a/tests/test_tui_config.py b/tests/test_tui_config.py new file mode 100644 index 0000000..b733ff4 --- /dev/null +++ b/tests/test_tui_config.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import pytest + +from agentforge_harness.ui.config import DEFAULT_THEME, TuiKeybindings, TuiTheme + + +def test_default_theme_is_frozen() -> None: + with pytest.raises((AttributeError, TypeError)): + DEFAULT_THEME.screen_background = "red" # type: ignore[misc] + + +def test_theme_has_role_styles() -> None: + for role in ["user", "assistant", "tool", "error", "thinking"]: + assert role in DEFAULT_THEME.role_styles + + +def test_default_keybindings() -> None: + kb = TuiKeybindings() + assert kb.cancel == "escape" + assert kb.queue_steer == "alt+enter" + assert kb.toggle_thinking == "ctrl+t" + assert kb.toggle_tool_results == "ctrl+o" + assert kb.session_picker == "ctrl+r" + + +def test_theme_css_variables_returns_dict() -> None: + css_vars = DEFAULT_THEME.css_variables() + assert isinstance(css_vars, dict) + assert "af-background" in css_vars + assert "af-text" in css_vars + assert "af-accent" in css_vars + assert "af-prompt-border" in css_vars + + +def test_theme_css_variables_values_match_theme() -> None: + css_vars = DEFAULT_THEME.css_variables() + assert css_vars["af-background"] == DEFAULT_THEME.screen_background + assert css_vars["af-accent"] == DEFAULT_THEME.accent + + +def test_role_style_is_frozen() -> None: + style = DEFAULT_THEME.role_styles["user"] + with pytest.raises((AttributeError, TypeError)): + style.border = "red" # type: ignore[misc] + + +def test_keybindings_is_frozen() -> None: + kb = TuiKeybindings() + with pytest.raises((AttributeError, TypeError)): + kb.cancel = "q" # type: ignore[misc] + + +def test_keybindings_all_defaults_set() -> None: + kb = TuiKeybindings() + assert kb.branch_picker == "ctrl+b" + assert kb.quit == "ctrl+d" + assert kb.accept_completion == "tab" + assert kb.completion_next == "down" + assert kb.completion_previous == "up" + + +def test_theme_has_status_role_style() -> None: + assert "status" in DEFAULT_THEME.role_styles diff --git a/tests/test_tui_integration.py b/tests/test_tui_integration.py new file mode 100644 index 0000000..c6cd466 --- /dev/null +++ b/tests/test_tui_integration.py @@ -0,0 +1,48 @@ +from __future__ import annotations +import inspect +import pytest +from unittest.mock import MagicMock +from agentforge_harness.ui import run_tui +from agentforge_harness.ui.plain import TUI as PlainTUI +from agentforge_harness.ui.tui import AgentForgeTuiApp +from agentforge_harness.ui.state import TuiState +from agentforge_harness.ui.adapter import TuiEventAdapter +from agentforge_harness.ui.config import DEFAULT_THEME +from agentforge_harness.ui.autocomplete import build_completion_state +from agentforge_harness.agent.events import AgentEventType + +def test_ui_package_exports(): + assert inspect.iscoroutinefunction(run_tui) + +def test_plain_tui_still_works(): + assert hasattr(PlainTUI, "show_error") + assert hasattr(PlainTUI, "show_help") + +def test_event_pipeline_end_to_end(): + state = TuiState() + adapter = TuiEventAdapter(state) + def evt(t, data=None): + m = MagicMock() + m.type = t + m.data = data or {} + return m + adapter.apply(evt(AgentEventType.AGENT_START)) + assert state.running is True + adapter.apply(evt(AgentEventType.TEXT_DELTA, {"content": "hello "})) + adapter.apply(evt(AgentEventType.TEXT_DELTA, {"content": "world"})) + adapter.apply(evt(AgentEventType.AGENT_END, {"content": "hello world"})) + assert state.running is False + assistant_items = [i for i in state.items if i.role == "assistant"] + assert len(assistant_items) == 1 + assert "hello" in assistant_items[0].text + +def test_autocomplete_slash_commands(): + cmds = ["/help", "/exit", "/stats", "/history"] + state = build_completion_state("/hi", commands=cmds) + assert len(state.items) == 1 + assert state.items[0].replacement == "/history" + +def test_state_theme_role_coverage(): + roles = ["user", "assistant", "tool", "error", "thinking", "status"] + for role in roles: + assert role in DEFAULT_THEME.role_styles, f"Missing theme for role: {role}" diff --git a/tests/test_tui_state.py b/tests/test_tui_state.py new file mode 100644 index 0000000..b567955 --- /dev/null +++ b/tests/test_tui_state.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import pytest + +from agentforge_harness.ui.state import ChatItem, TuiState + + +def test_initial_state_is_empty() -> None: + state = TuiState() + assert state.items == [] + assert state.assistant_buffer == "" + assert state.running is False + assert state.show_thinking is False + assert state.show_tool_results is True + + +def test_add_user_message() -> None: + state = TuiState() + state.add_user_message("hello") + assert len(state.items) == 1 + assert state.items[0].role == "user" + assert state.items[0].text == "hello" + + +def test_add_assistant_delta_accumulates() -> None: + state = TuiState() + state.flush_assistant_delta("hello ") + state.flush_assistant_delta("world") + items = [i for i in state.items if i.role == "assistant"] + assert len(items) == 1 + assert items[0].text == "hello world" + + +def test_clear_resets_state() -> None: + state = TuiState() + state.add_user_message("hi") + state.running = True + state.clear() + assert state.items == [] + assert state.running is False + + +def test_toggle_thinking() -> None: + state = TuiState() + assert state.show_thinking is False + state.toggle_thinking() + assert state.show_thinking is True + + +def test_toggle_tool_results() -> None: + state = TuiState() + assert state.show_tool_results is True + state.toggle_tool_results() + assert state.show_tool_results is False + + +def test_flush_thinking_delta_accumulates() -> None: + state = TuiState() + state.flush_thinking_delta("thinking ") + state.flush_thinking_delta("more") + items = [i for i in state.items if i.role == "thinking"] + assert len(items) == 1 + assert items[0].text == "thinking more" + + +def test_add_tool_item_and_update_result() -> None: + state = TuiState() + state.add_tool_item(call_id="c1", name="bash", args={"cmd": "ls"}) + assert len(state.items) == 1 + assert state.items[0].role == "tool" + assert state.items[0].tool_call_id == "c1" + state.update_tool_result(call_id="c1", output="file1.txt", success=True) + assert "✓" in state.items[0].text + assert "file1.txt" in state.items[0].text + + +def test_update_tool_result_failure_marker() -> None: + state = TuiState() + state.add_tool_item(call_id="c2", name="bash", args={}) + state.update_tool_result(call_id="c2", output="error msg", success=False) + assert "✗" in state.items[0].text + + +def test_add_error() -> None: + state = TuiState() + state.add_error("something went wrong") + assert len(state.items) == 1 + assert state.items[0].role == "error" + assert "something went wrong" in state.items[0].text + + +def test_add_status() -> None: + state = TuiState() + state.add_status("initializing…") + assert state.items[0].role == "status" + + +def test_finalize_assistant_clears_buffer() -> None: + state = TuiState() + state.flush_assistant_delta("partial") + assert state.assistant_buffer == "partial" + state.finalize_assistant() + assert state.assistant_buffer == "" + + +def test_finalize_thinking_clears_buffer() -> None: + state = TuiState() + state.flush_thinking_delta("thought") + state.finalize_thinking() + assert state.thinking_buffer == "" diff --git a/tests/test_tui_widgets.py b/tests/test_tui_widgets.py new file mode 100644 index 0000000..156f687 --- /dev/null +++ b/tests/test_tui_widgets.py @@ -0,0 +1,33 @@ +from __future__ import annotations +import pytest +from agentforge_harness.ui.state import ChatItem +from agentforge_harness.ui.config import DEFAULT_THEME +from agentforge_harness.ui.widgets import ( + TranscriptView, SessionSidebar, _format_role_label, _item_to_markup, +) +from textual.widget import Widget + +def test_format_role_label_user(): + label = _format_role_label("user", DEFAULT_THEME) + assert "you" in label or "user" in label + +def test_item_to_markup_assistant(): + item = ChatItem(role="assistant", text="hello world") + markup = _item_to_markup(item, DEFAULT_THEME) + assert "hello world" in markup + +def test_item_to_markup_error(): + item = ChatItem(role="error", text="something broke") + markup = _item_to_markup(item, DEFAULT_THEME) + assert "something broke" in markup + +def test_item_to_markup_thinking_hidden(): + item = ChatItem(role="thinking", text="internal reasoning") + markup = _item_to_markup(item, DEFAULT_THEME, show_thinking=False) + assert "internal reasoning" not in markup + +def test_transcript_view_is_widget(): + assert issubclass(TranscriptView, Widget) + +def test_session_sidebar_is_widget(): + assert issubclass(SessionSidebar, Widget)