Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ __pycache__
!.crew/artifacts/
!.crew/graphs/
!.crew/artifacts/.gitkeep
!.crew/graphs/.gitkeep
!.crew/graphs/.gitkeep.superpowers/
.superpowers/
2 changes: 1 addition & 1 deletion agentforge_harness/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
22 changes: 13 additions & 9 deletions agentforge_harness/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion agentforge_harness/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 4 additions & 0 deletions agentforge_harness/ui/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
51 changes: 51 additions & 0 deletions agentforge_harness/ui/adapter.py
Original file line number Diff line number Diff line change
@@ -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"))
66 changes: 66 additions & 0 deletions agentforge_harness/ui/autocomplete.py
Original file line number Diff line number Diff line change
@@ -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))
58 changes: 58 additions & 0 deletions agentforge_harness/ui/config.py
Original file line number Diff line number Diff line change
@@ -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"
Loading