Files
2026-07-13 12:49:17 +08:00

368 lines
13 KiB
Python

# Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
# SPDX-License-Identifier: MIT
"""Rich CLI Console implementation using Textual TUI."""
import asyncio
import os
from typing import override
from rich.panel import Panel
from rich.text import Text
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.reactive import reactive
from textual.suggester import SuggestFromList
from textual.widgets import Footer, Header, Input, RichLog, Static
from trae_agent.agent.agent_basics import AgentExecution, AgentStep, AgentStepState
from trae_agent.utils.cli.cli_console import (
AGENT_STATE_INFO,
CLIConsole,
ConsoleMode,
ConsoleStep,
generate_agent_step_table,
)
from trae_agent.utils.config import LakeviewConfig
class TokenDisplay(Static):
"""Widget to display real-time token usage."""
total_tokens: reactive[int] = reactive(0)
input_tokens: reactive[int] = reactive(0)
output_tokens: reactive[int] = reactive(0)
@override
def render(self) -> Text:
"""Render the token display."""
if self.total_tokens > 0:
return Text(
f"Tokens: {self.total_tokens:,} total | "
+ f"Input: {self.input_tokens:,} | "
+ f"Output: {self.output_tokens:,}",
style="bold blue",
)
return Text("Tokens: 0 total", style="dim")
def update_tokens(self, agent_execution: AgentExecution):
"""Update token counts from agent execution."""
if agent_execution and agent_execution.total_tokens:
self.input_tokens = agent_execution.total_tokens.input_tokens
self.output_tokens = agent_execution.total_tokens.output_tokens
self.total_tokens = self.input_tokens + self.output_tokens
class RichConsoleApp(App[None]):
"""Textual app for the rich console."""
CSS_PATH = "rich_console.tcss"
BINDINGS = [
("ctrl+c", "quit", "Quit"),
("ctrl+q", "quit", "Quit"),
]
def __init__(self, console_impl: "RichCLIConsole"):
super().__init__()
self.console_impl: "RichCLIConsole" = console_impl
self.execution_log: RichLog | None = None
self.task_input: Input | None = None
self.task_display: Static | None = None
self.token_display: TokenDisplay | None = None
self.current_task: str | None = None
self.is_running_task: bool = False
self.options: list[str] = ["help", "exit", "status", "clear"]
@override
def compose(self) -> ComposeResult:
"""Compose the UI layout."""
yield Header(show_clock=True)
# Top container for agent execution
with Container(id="execution_container"):
yield RichLog(id="execution_log", wrap=True, markup=True)
# Bottom container for input/task display
with Container(id="input_container"):
if self.console_impl.mode == ConsoleMode.INTERACTIVE:
yield Input(
placeholder="Enter your task...",
id="task_input",
suggester=SuggestFromList(self.options, case_sensitive=True),
)
yield Static("", id="task_display", classes="task_display")
else:
yield Static("", id="task_display", classes="task_display")
# Footer container for token usage
with Container(id="footer_container"):
yield TokenDisplay(id="token_display")
yield Footer()
def on_mount(self) -> None:
"""Called when the app is mounted."""
self.title = "Trae Agent CLI"
self.execution_log = self.query_one("#execution_log", RichLog)
self.token_display = self.query_one("#token_display", TokenDisplay)
self.task_display = self.query_one("#task_display", Static)
if self.console_impl.mode == ConsoleMode.INTERACTIVE:
self.task_input = self.query_one("#task_input", Input)
_ = self.task_input.focus()
# Show initial task in RUN mode
if self.console_impl.mode == ConsoleMode.RUN and self.console_impl.initial_task:
self.task_display.update(
Panel(self.console_impl.initial_task, title="Task", border_style="blue")
)
@on(Input.Submitted, "#task_input")
def handle_task_input(self, event: Input.Submitted) -> None:
"""Handle task input submission in interactive mode."""
if self.is_running_task:
return
task = event.value.strip()
if not task:
return
handlers: dict = {
"exit": self._exit_handler,
"quit": self._exit_handler,
"help": self._help_handler,
"clear": self._clear_handler,
"status": self._status_handler,
}
handler = handlers.get(task.lower())
if handler:
handler(event) if task.lower() not in ["exit", "quit"] else handler()
return
# Execute the task
self.current_task = task
if self.task_display:
_ = self.task_display.update(Panel(task, title="Current Task", border_style="green"))
event.input.value = ""
self.is_running_task = True
# Start task execution
_ = asyncio.create_task(self._execute_task(task))
async def _execute_task(self, task: str):
"""Execute a task using the agent."""
try:
if not hasattr(self.console_impl, "agent") or not self.console_impl.agent:
if self.execution_log:
_ = self.execution_log.write("[red]Error: Agent not available[/red]")
return
# Get working directory
working_dir = os.getcwd()
if self.console_impl.mode == ConsoleMode.INTERACTIVE:
# For interactive mode, we might want to ask for working directory
# For now, use current directory
pass
task_args = {
"project_path": working_dir,
"issue": task,
"must_patch": "false",
}
if self.execution_log:
_ = self.execution_log.write(f"[blue]Executing task: {task}[/blue]")
# Execute the task
_ = await self.console_impl.agent.run(task, task_args)
if self.execution_log:
_ = self.execution_log.write("[green]Task completed successfully![/green]")
except Exception as e:
if self.execution_log:
_ = self.execution_log.write(f"[red]Error executing task: {e}[/red]")
finally:
self.is_running_task = False
if self.console_impl.mode == ConsoleMode.RUN:
# In run mode, exit after task completion
await asyncio.sleep(1) # Brief pause to show completion
_ = self.exit()
else:
# In interactive mode, clear task display and re-enable input
if self.task_display:
_ = self.task_display.update("")
if self.task_input:
_ = self.task_input.focus()
def log_agent_step(self, agent_step: AgentStep):
"""Log an agent step to the execution log."""
color, _ = AGENT_STATE_INFO.get(agent_step.state, ("white", "❓"))
# Create step display
step_content = generate_agent_step_table(agent_step)
if self.execution_log:
_ = self.execution_log.write(
Panel(step_content, title=f"Step {agent_step.step_number}", border_style=color)
)
def _help_handler(self, event: Input.Submitted):
if self.execution_log:
self.execution_log.write(
Panel(
"""[bold]Available Commands:[/bold]
• Type any task description to execute it
• 'status' - Show agent status
• 'clear' - Clear the execution log
• 'exit' or 'quit' - End the session""",
title="Help",
border_style="yellow",
)
)
event.input.value = ""
def _clear_handler(self, event: Input.Submitted):
if self.execution_log:
_ = self.execution_log.clear()
event.input.value = ""
def _status_handler(self, event: Input.Submitted):
if hasattr(self.console_impl, "agent") and self.console_impl.agent:
agent_info = getattr(self.console_impl.agent, "agent_config", None)
if agent_info and self.execution_log:
_ = self.execution_log.write(
Panel(
f"""[bold]Provider:[/bold] {agent_info.model.model_provider.provider}
[bold]Model:[/bold] {agent_info.model.model}
[bold]Working Directory:[/bold] {os.getcwd()}""",
title="Agent Status",
border_style="blue",
)
)
else:
if self.execution_log:
_ = self.execution_log.write("[yellow]Agent not initialized[/yellow]")
event.input.value = ""
def _exit_handler(self):
self.exit()
async def action_quit(self) -> None:
"""Quit the application."""
self.console_impl.should_exit = True
_ = self.exit()
class RichCLIConsole(CLIConsole):
"""Rich CLI console using Textual for TUI interface."""
def __init__(
self, mode: ConsoleMode = ConsoleMode.RUN, lakeview_config: LakeviewConfig | None = None
):
"""Initialize the rich CLI console."""
super().__init__(mode, lakeview_config)
self.app: RichConsoleApp | None = None
self.should_exit: bool = False
self.initial_task: str | None = None
self._is_running: bool = False
# Agent context for interactive mode
self.agent = None
self.trae_agent_config = None
self.config_file = None
self.trajectory_file = None
@override
async def start(self):
"""Start the rich console application."""
# Prevent multiple starts of the same app
if self._is_running:
return
self._is_running = True
try:
if self.app is None:
self.app = RichConsoleApp(self)
# Run the textual app
await self.app.run_async()
finally:
self._is_running = False
@override
def update_status(
self, agent_step: AgentStep | None = None, agent_execution: AgentExecution | None = None
):
"""Update the console with agent status."""
if agent_step and self.app:
if agent_step.step_number not in self.console_step_history:
# update step history
self.console_step_history[agent_step.step_number] = ConsoleStep(agent_step)
if (
agent_step.state in [AgentStepState.COMPLETED, AgentStepState.ERROR]
and not self.console_step_history[agent_step.step_number].agent_step_printed
):
self.app.log_agent_step(agent_step)
self.console_step_history[agent_step.step_number].agent_step_printed = True
if agent_execution:
self.agent_execution = agent_execution
if self.app and self.app.token_display:
self.app.token_display.update_tokens(agent_execution)
@override
def print_task_details(self, details: dict[str, str]):
"""Print initial task configuration details."""
if self.app and self.app.execution_log:
content = "\n".join([f"[bold]{key}:[/bold] {value}" for key, value in details.items()])
_ = self.app.execution_log.write(
Panel(content, title="Task Details", border_style="blue")
)
@override
def print(self, message: str, color: str = "blue", bold: bool = False):
"""Print a message to the console."""
if self.app and self.app.execution_log:
formatted_message = f"[bold]{message}[/bold]" if bold else message
formatted_message = f"[{color}]{formatted_message}[/{color}]"
_ = self.app.execution_log.write(formatted_message)
@override
def get_task_input(self) -> str | None:
"""Get task input from user (for interactive mode)."""
# This method is not used in rich console as input is handled by the TUI
return None
@override
def get_working_dir_input(self) -> str:
"""Get working directory input from user (for interactive mode)."""
# For now, return current directory. Could be enhanced with a dialog
return os.getcwd()
@override
def stop(self):
"""Stop the console and cleanup resources."""
self.should_exit = True
if self.app:
_ = self.app.exit()
def set_agent_context(self, agent, trae_agent_config, config_file, trajectory_file) -> None:
"""Set the agent context for task execution in interactive mode."""
self.agent = agent
self.trae_agent_config = trae_agent_config
self.config_file = config_file
self.trajectory_file = trajectory_file
def set_initial_task(self, task: str):
"""Set the initial task for RUN mode."""
self.initial_task = task