Skip to content
Merged
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
8 changes: 7 additions & 1 deletion src/deep_code_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,10 +288,13 @@ def _run_tui_mode(args) -> None:
args: Parsed command line arguments
"""
import os
from dotenv import load_dotenv
from deep_code_agent.tui import DeepCodeAgentApp

# Get codebase directory
codebase_dir = os.getcwd()
dotenv_path = Path(codebase_dir) / ".env"
load_dotenv(dotenv_path=dotenv_path if dotenv_path.exists() else None)

print(f"🚀 Starting Deep Code Agent TUI...")
print(f"📁 Codebase: {codebase_dir}")
Expand All @@ -301,8 +304,11 @@ def agent_factory():

try:
session_info = {
"model": args.model_name or "default",
"model": args.model_name or os.getenv("MODEL_NAME") or "default",
"model_provider": args.model_provider,
"version": __version__,
"session_id": args.thread_id,
"directory": codebase_dir,
"codebase_dir": codebase_dir,
"skills": _resolve_skills(args, codebase_dir) or [],
}
Expand Down
12 changes: 7 additions & 5 deletions src/deep_code_agent/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
if TYPE_CHECKING:
from deep_code_agent.tui.widgets.chat_log import ChatLog
from deep_code_agent.tui.widgets.input_box import InputBox
from deep_code_agent.tui.widgets.side_panel import SidePanel
from deep_code_agent.tui.widgets.status_bar import StatusBar


Expand All @@ -35,7 +34,7 @@ class DeepCodeAgentApp(App):
CSS_PATH = ["styles/main.tcss"]
BINDINGS = [
Binding("ctrl+c", "quit", "Quit", show=True),
Binding("ctrl+d", "toggle_dark", "Toggle Dark Mode"),
Binding("ctrl+d", "toggle_dark", "Theme"),
Binding("f1", "help", "Help"),
]

Expand Down Expand Up @@ -79,7 +78,7 @@ def __init__(
self._chat_log: ChatLog | None = None
self._status_bar: StatusBar | None = None
self._input_box: InputBox | None = None
self._side_panel: SidePanel | None = None
self._side_panel = None

def compose(self) -> ComposeResult:
"""Compose the application."""
Expand All @@ -98,7 +97,8 @@ def register_main_screen(self, screen: MainScreen) -> None:
self._chat_log = screen.get_chat_log()
self._status_bar = screen.get_status_bar()
self._input_box = screen.get_input_box()
self._side_panel = screen.get_side_panel()
self._status_bar.session_info = self.session_info
self._input_box.session_info = self.session_info
if self.bridge is None and self.agent_factory is not None:
self.start_agent_initialization()

Expand Down Expand Up @@ -159,7 +159,7 @@ def action_toggle_dark(self) -> None:
def action_help(self) -> None:
"""Show help."""
self.notify(
"Shortcuts:\n" " Ctrl+C: Quit\n" " Ctrl+D: Toggle dark mode\n" " F1: Help\n" " Tab: Navigate widgets",
"Enter send prompt\nCtrl+L clear chat\nCtrl+D theme\nTab navigate",
title="Help",
severity="information",
timeout=10,
Expand All @@ -170,6 +170,8 @@ def update_session_info(self, session_info: dict) -> None:
self.session_info = session_info
if self._main_screen is not None:
self._main_screen.update_session_info(session_info)
if self._status_bar is not None:
self._status_bar.session_info = session_info
self.sub_title = session_info.get("model", "AI Assistant")

def get_bridge(self) -> AgentBridge:
Expand Down
21 changes: 15 additions & 6 deletions src/deep_code_agent/tui/bridge/agent_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ def _extract_tool_name_from_interrupt(self, interrupt_data: dict) -> str:
if action_requests:
action = action_requests[0]
action_data = action.action if hasattr(action, "action") else action
if isinstance(action_data, dict) and isinstance(action_data.get("action"), dict):
action_data = action_data["action"]
if hasattr(action_data, "model_dump"):
action_data = action_data.model_dump()
if not isinstance(action_data, dict):
return "unknown"
return action_data.get("name", "unknown")

# Path 2: tool_calls (common in LangGraph)
Expand Down Expand Up @@ -162,6 +168,10 @@ def _extract_action_requests_from_interrupt(self, interrupt_data: Any) -> list[d
out: list[dict] = []
for ar in action_requests:
ar_data = ar.action if hasattr(ar, "action") else ar
if isinstance(ar_data, dict) and isinstance(ar_data.get("action"), dict):
ar_data = ar_data["action"]
if hasattr(ar_data, "model_dump"):
ar_data = ar_data.model_dump()
if isinstance(ar_data, dict):
out.append(ar_data)
continue
Expand Down Expand Up @@ -301,22 +311,20 @@ def handle_event() -> None:
try:
from deep_code_agent.tui.widgets.chat_log import ChatLog
from deep_code_agent.tui.widgets.input_box import InputBox
from deep_code_agent.tui.widgets.side_panel import SidePanel
from deep_code_agent.tui.widgets.status_bar import StatusBar

chat_log = getattr(app, "_chat_log", None)
status_bar = getattr(app, "_status_bar", None)
input_box = getattr(app, "_input_box", None)
side_panel = getattr(app, "_side_panel", None)

if chat_log is None or status_bar is None or input_box is None or side_panel is None:
if chat_log is None or status_bar is None or input_box is None:
screen = getattr(app, "screen", None)
if screen is None:
raise RuntimeError("No active screen")
chat_log = screen.query_one("#chat_log", ChatLog)
status_bar = screen.query_one("#status_bar", StatusBar)
input_box = screen.query_one("#input_box", InputBox)
side_panel = screen.query_one("#sidebar", SidePanel)
except Exception as e:
message = f"[ERROR] Failed to get UI widgets: {e}"
if self._last_ui_error != message:
Expand Down Expand Up @@ -412,7 +420,8 @@ def handle_event() -> None:
# Fallback to old method
chat_log.add_tool_call(tool_name=tool_name, args=tool_args)

side_panel.add_tool_call(tool_name=tool_name, args=tool_args)
if side_panel is not None and hasattr(side_panel, "add_tool_call"):
side_panel.add_tool_call(tool_name=tool_name, args=tool_args)

elif event.type == EventType.TOOL_START:
# Update tool status to running
Expand Down Expand Up @@ -539,7 +548,7 @@ def on_decision(decision: dict) -> None:
# We're already in the main thread, so we can set directly
setattr(app, "auto_approve_tools", auto_approve_tools + [tool_to_add])
app.notify(
f"Auto-approve enabled for: {tool_to_add}",
f"Auto-approve enabled for: {tool_to_add}",
title="Auto-Approve",
severity="information",
)
Expand All @@ -565,7 +574,7 @@ def on_decision(decision: dict) -> None:
self._reset_streaming_state()
status_bar.set_error(event.data or "Unknown error")
input_box.set_disabled(False)
chat_log.add_system_message(f"Error: {event.data}")
chat_log.add_system_message(f"Error: {event.data}")

elif event.type == EventType.DONE:
status_bar.set_ready()
Expand Down
70 changes: 70 additions & 0 deletions src/deep_code_agent/tui/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Local slash command definitions for the TUI."""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class SlashCommand:
"""A command that can be shown in the composer and handled locally."""

name: str
description: str
aliases: tuple[str, ...] = ()

def candidates(self) -> tuple[str, ...]:
"""Return searchable command names without leading slashes."""
return tuple(value.lstrip("/").lower() for value in (self.name, *self.aliases))


SLASH_COMMANDS: tuple[SlashCommand, ...] = (
SlashCommand("/help", "Show local TUI shortcuts and commands", aliases=("help", "/?")),
SlashCommand("/clear", "Clear the transcript", aliases=("clear",)),
SlashCommand("/skills", "List configured local skills"),
SlashCommand("/model", "Show current model configuration"),
SlashCommand("/exit", "Exit the TUI", aliases=("/quit", "/bye", "exit", "quit", "bye")),
)


def command_token(value: str) -> str | None:
"""Return the command-like token if the input is currently a slash command."""
stripped = value.strip()
if not stripped.startswith("/"):
return None
if any(char.isspace() for char in stripped):
return None
return stripped


def filter_slash_commands(value: str) -> list[SlashCommand]:
"""Filter slash commands for a prompt value.

Prefix matches are returned before substring matches.
"""
token = command_token(value)
if token is None:
return []

query = token.lstrip("/").lower()
prefix_matches: list[SlashCommand] = []
substring_matches: list[SlashCommand] = []

for command in SLASH_COMMANDS:
candidates = command.candidates()
haystack = " ".join((*candidates, command.description.lower()))
if not query or any(candidate.startswith(query) for candidate in candidates):
prefix_matches.append(command)
elif query in haystack:
substring_matches.append(command)

return [*prefix_matches, *substring_matches]


def canonical_command_name(value: str) -> str | None:
"""Return the canonical slash command name for an exact local command."""
normalized = value.strip().lower()
for command in SLASH_COMMANDS:
if normalized == command.name or normalized in command.aliases:
return command.name
return None
58 changes: 33 additions & 25 deletions src/deep_code_agent/tui/screens/approval_modal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Approval modal for HITL (Human-in-the-Loop) approval."""

import json
from typing import Callable

from textual.app import ComposeResult
Expand Down Expand Up @@ -43,23 +44,25 @@ class ApprovalModal(ModalScreen):
width: 80;
height: auto;
max-height: 90%;
background: $surface;
border: thick $primary;
padding: 1 2;
background: #111411;
border: thick #7dc4a4;
padding: 2;
}

ApprovalModal #dialog-title {
text-align: center;
content-align: center middle;
text-style: bold;
color: $warning;
height: auto;
color: #f0f4ef;
height: 3;
margin-bottom: 1;
border-bottom: solid #343832;
}

ApprovalModal #tool-info {
margin: 1 0;
padding: 1;
background: $surface-darken-1;
background: #171b17;
border: solid #2e342e;
height: auto;
max-height: 20;
overflow: auto;
Expand All @@ -71,7 +74,7 @@ class ApprovalModal(ModalScreen):

ApprovalModal #tool-name {
text-style: bold;
color: $primary;
color: #a7cdbd;
margin-bottom: 1;
}

Expand Down Expand Up @@ -121,6 +124,12 @@ def _extract_tool_call(self) -> None:
self.action_requests = action_requests
action = action_requests[0]
action_data = action.action if hasattr(action, "action") else action
if isinstance(action_data, dict) and isinstance(action_data.get("action"), dict):
action_data = action_data["action"]
if hasattr(action_data, "model_dump"):
action_data = action_data.model_dump()
if not isinstance(action_data, dict):
action_data = {}
self.tool_name = action_data.get("name", "unknown")
self.tool_args = action_data.get("args", {})
return
Expand Down Expand Up @@ -181,33 +190,32 @@ def _extract_tool_call(self) -> None:
def _setup_options(self) -> None:
"""Setup options for the modal."""
self.options = [
{"key": "1", "label": "Approve", "description": "Allow this once", "action": "approve"},
{"key": "1", "label": "Approve", "description": "Allow this once", "action": "approve"},
{
"key": "2",
"label": "Approve All for Tool",
"description": "✓ Always approve this tool",
"label": "Always Approve",
"description": "Trust this tool in this session",
"action": "approve_all",
},
{"key": "3", "label": "Reject", "description": "Block execution", "action": "reject"},
{"key": "4", "label": "Cancel", "description": "🚫 Dismiss dialog", "action": "cancel"},
{"key": "3", "label": "Reject", "description": "Block execution", "action": "reject"},
{"key": "4", "label": "Cancel", "description": "Dismiss dialog", "action": "cancel"},
]

def compose(self) -> ComposeResult:
"""Compose the approval modal."""
with Vertical():
yield Static("⚠️ Action Requires Approval", id="dialog-title")
yield Static("Action Requires Approval", id="dialog-title", markup=False)

with Static(id="tool-info"):
tool_display = f"Tool: {self.tool_name}"
if self.tool_name == "unknown":
debug_data = getattr(self, "_debug_data", "N/A")
debug_data = debug_data.replace("[", "\\[").replace("]", "\\]")
tool_display += f"\nDebug: {debug_data}"
yield Static(tool_display, id="tool-name")
yield Static(self._format_args(self.tool_args))
yield Static(tool_display, id="tool-name", markup=False)
yield Static(self._format_args(self.tool_args), markup=False)

with Vertical(id="options-list"):
yield Static("Choose an action:")
yield Static("Choose an action:", markup=False)
for i, option in enumerate(self.options):
yield SelectableOption(
key=option["key"],
Expand All @@ -221,13 +229,13 @@ def _format_args(self, args: dict) -> str:
if not args:
return "No arguments"

lines = ["Arguments:"]
for key, value in args.items():
value_str = str(value)
if len(value_str) > 200:
value_str = value_str[:200] + "..."
lines.append(f" {key}: {value_str}")
return "\n".join(lines)
try:
formatted = json.dumps(args, indent=2, ensure_ascii=False, default=str)
except TypeError:
formatted = str(args)
if len(formatted) > 900:
formatted = formatted[:897] + "..."
return f"Arguments:\n{formatted}"

def _update_selection(self) -> None:
"""Update visual selection state."""
Expand Down
Loading
Loading