diff --git a/src/deep_code_agent/cli.py b/src/deep_code_agent/cli.py index 1b3ea3b..9a5c06c 100644 --- a/src/deep_code_agent/cli.py +++ b/src/deep_code_agent/cli.py @@ -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}") @@ -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 [], } diff --git a/src/deep_code_agent/tui/app.py b/src/deep_code_agent/tui/app.py index e625e3e..aa4f1b5 100644 --- a/src/deep_code_agent/tui/app.py +++ b/src/deep_code_agent/tui/app.py @@ -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 @@ -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"), ] @@ -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.""" @@ -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() @@ -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, @@ -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: diff --git a/src/deep_code_agent/tui/bridge/agent_bridge.py b/src/deep_code_agent/tui/bridge/agent_bridge.py index 7e85ee2..3e04bd2 100644 --- a/src/deep_code_agent/tui/bridge/agent_bridge.py +++ b/src/deep_code_agent/tui/bridge/agent_bridge.py @@ -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) @@ -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 @@ -301,7 +311,6 @@ 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) @@ -309,14 +318,13 @@ def handle_event() -> 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: @@ -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 @@ -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", ) @@ -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() diff --git a/src/deep_code_agent/tui/commands.py b/src/deep_code_agent/tui/commands.py new file mode 100644 index 0000000..595ff45 --- /dev/null +++ b/src/deep_code_agent/tui/commands.py @@ -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 diff --git a/src/deep_code_agent/tui/screens/approval_modal.py b/src/deep_code_agent/tui/screens/approval_modal.py index 1d2d97c..0f74c05 100644 --- a/src/deep_code_agent/tui/screens/approval_modal.py +++ b/src/deep_code_agent/tui/screens/approval_modal.py @@ -1,5 +1,6 @@ """Approval modal for HITL (Human-in-the-Loop) approval.""" +import json from typing import Callable from textual.app import ComposeResult @@ -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; @@ -71,7 +74,7 @@ class ApprovalModal(ModalScreen): ApprovalModal #tool-name { text-style: bold; - color: $primary; + color: #a7cdbd; margin-bottom: 1; } @@ -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 @@ -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"], @@ -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.""" diff --git a/src/deep_code_agent/tui/screens/main_screen.py b/src/deep_code_agent/tui/screens/main_screen.py index b7e4b90..47fe469 100644 --- a/src/deep_code_agent/tui/screens/main_screen.py +++ b/src/deep_code_agent/tui/screens/main_screen.py @@ -2,16 +2,17 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING, cast from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical +from textual.binding import Binding +from textual.containers import Vertical from textual.screen import Screen -from textual.widgets import Footer, Header +from deep_code_agent.tui.commands import SLASH_COMMANDS, canonical_command_name 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 if TYPE_CHECKING: @@ -21,8 +22,8 @@ class MainScreen(Screen): """Main chat interface screen. - The primary screen displaying the chat log, input box, status bar, - and side panel with session information. + The primary screen displaying a Codex-style transcript, status row, + and bottom input composer. Example: screen = MainScreen() @@ -30,9 +31,10 @@ class MainScreen(Screen): """ BINDINGS = [ - ("ctrl+d", "toggle_dark", "Toggle Dark Mode"), - ("f1", "help", "Help"), - ("ctrl+q", "quit", "Quit"), + Binding("ctrl+l", "clear_chat", "Clear", key_display="^L"), + Binding("ctrl+d", "toggle_dark", "Theme", key_display="^D"), + Binding("f1", "help", "Help"), + Binding("ctrl+q", "quit", "Quit", key_display="^Q"), ] def __init__(self, session_info: dict | None = None, **kwargs): @@ -41,19 +43,10 @@ def __init__(self, session_info: dict | None = None, **kwargs): def compose(self) -> ComposeResult: """Compose the main screen layout.""" - yield Header(show_clock=True) - - with Horizontal(id="main-container"): - # Side panel on the left - yield SidePanel(session_info=self.session_info, id="sidebar") - - # Main content area on the right - with Vertical(id="content"): - yield ChatLog(id="chat_log") - yield StatusBar(id="status_bar") - yield InputBox(id="input_box") - - yield Footer() + with Vertical(id="codex-shell"): + yield ChatLog(id="chat_log") + yield StatusBar(id="status_bar") + yield InputBox(id="input_box") def on_mount(self) -> None: """Called when screen is mounted.""" @@ -64,11 +57,13 @@ def on_mount(self) -> None: except Exception: pass chat_log = self.query_one("#chat_log", ChatLog) + chat_log.add_session_header(self.session_info) is_agent_ready = bool(getattr(self.app, "is_agent_ready", True)) if is_agent_ready: - chat_log.add_system_message("Deep Code Agent ready! Type your request below.") + chat_log.add_system_message("Tip: Use /skills to list available skills.") else: chat_log.add_system_message("Initializing agent...") + self.update_session_info(self.session_info) def action_toggle_dark(self) -> None: """Toggle dark mode.""" @@ -77,8 +72,20 @@ def action_toggle_dark(self) -> None: def action_help(self) -> None: """Show help screen.""" - # TODO: Push help screen - self.notify("Help screen coming soon!", severity="information") + commands = "\n".join(f"{command.name} - {command.description}" for command in SLASH_COMMANDS) + self.notify( + f"Enter send\nCtrl+L clear chat\nCtrl+D theme\n\n{commands}", + title="Shortcuts", + severity="information", + timeout=8, + ) + + def action_clear_chat(self) -> None: + """Clear the conversation stream.""" + chat_log = self.get_chat_log() + chat_log.clear_messages() + chat_log.add_session_header(self.session_info) + chat_log.add_system_message("Conversation cleared.") def action_quit(self) -> None: """Quit the application.""" @@ -96,15 +103,21 @@ def get_status_bar(self) -> StatusBar: """Get the status bar widget.""" return self.query_one("#status_bar", StatusBar) - def get_side_panel(self) -> SidePanel: - """Get the side panel widget.""" - return self.query_one("#sidebar", SidePanel) - def update_session_info(self, session_info: dict) -> None: """Update session information.""" self.session_info = session_info - side_panel = self.get_side_panel() - side_panel.session_info = session_info + try: + self.get_chat_log().update_session_header(session_info) + except Exception: + pass + try: + self.get_status_bar().session_info = session_info + except Exception: + pass + try: + self.get_input_box().session_info = session_info + except Exception: + pass from textual import work @@ -124,9 +137,60 @@ async def process_agent_request(self, content: str) -> None: def on_input_box_user_input(self, event: InputBox.UserInput) -> None: """Handle user input from InputBox and forward to app.""" + if self._handle_local_command(event.content): + return + # Add user message to chat log immediately chat_log = self.get_chat_log() chat_log.add_user_message(event.content) # Start the worker self.process_agent_request(event.content) + + def _handle_local_command(self, content: str) -> bool: + """Handle local slash commands before they reach the agent.""" + command = canonical_command_name(content) + if command is None: + return False + if command == "/exit": + self.app.exit() + return True + if command == "/clear": + self.action_clear_chat() + return True + if command == "/help": + self.action_help() + return True + if command == "/skills": + self.get_chat_log().add_system_message(self._format_skills_message()) + return True + if command == "/model": + self.get_chat_log().add_system_message(self._format_model_message()) + return True + return False + + def _format_skills_message(self) -> str: + skills = self.session_info.get("skills") or [] + names: list[str] = [] + for skill_dir in skills: + try: + for child in Path(str(skill_dir)).iterdir(): + if child.is_dir() and (child / "SKILL.md").exists(): + names.append(child.name) + except OSError: + continue + + if names: + unique_names = sorted(set(names)) + return "Available skills:\n" + "\n".join(f"- {name}" for name in unique_names) + if skills: + return "No skills found under configured skill directories." + return "No local skills directory is configured for this session." + + def _format_model_message(self) -> str: + model = str(self.session_info.get("model") or self.session_info.get("model_name") or "default") + provider = str(self.session_info.get("model_provider") or "openai") + return ( + f"Current model: {model} ({provider}).\n" + "Change it by restarting with --model-name or updating MODEL_NAME in .env." + ) diff --git a/src/deep_code_agent/tui/styles/main.tcss b/src/deep_code_agent/tui/styles/main.tcss index 13b2e0f..963d650 100644 --- a/src/deep_code_agent/tui/styles/main.tcss +++ b/src/deep_code_agent/tui/styles/main.tcss @@ -1,83 +1,53 @@ /* Deep Code Agent TUI - Main Stylesheet */ -/* Global settings */ * { - transition: all 200ms; + scrollbar-background: #171717; + scrollbar-color: #555555; + scrollbar-color-hover: #777777; + scrollbar-color-active: #999999; } -/* Main layout */ Screen { - background: #1a1a2e; - color: #eee; + background: #171717; + color: #e8e8e8; } -#main-container { - height: 100%; - width: 100%; -} - -#sidebar { - width: 25%; - min-width: 20; - max-width: 40; - border-right: solid #4a5568; - background: #16213e; +Screen:light { + background: #f3f3f3; + color: #222222; } -#content { - width: 75%; - height: 100%; +#codex-shell { + height: 1fr; + width: 100%; + background: #171717; } -/* Chat log */ #chat_log { height: 1fr; - border: solid #2d3748; - padding: 0 1; - background: #1a1a2e; + border: none; + padding: 0; + background: #171717; } -/* Input box */ #input_box { height: auto; - margin-top: 1; - background: #16213e; } #user-input { - width: 1fr; -} - -#send-button { - width: auto; - margin-left: 1; -} - -/* Status bar */ -#status_bar { - height: 1; - background: #0f3460; - color: #eee; - content-align: center middle; -} - -/* Message bubbles */ -MessageBubble { - margin: 1 0; - padding: 1; + width: 100%; } -MessageBubble.user { - background: #2d5a87; - border-right: thick #4a90d9; +Screen:light #codex-shell, +Screen:light #chat_log { + background: #f3f3f3; } -MessageBubble.agent { - background: #1e3a5f; - border-left: thick #4ecdc4; +Screen:light InputBox, +Screen:light StatusBar { + background: #f3f3f3; } -MessageBubble.system { - background: #3d3d3d; - text-style: italic; +Screen:light InputBox #prompt-row { + background: #e0e0e0; } diff --git a/src/deep_code_agent/tui/widgets/__init__.py b/src/deep_code_agent/tui/widgets/__init__.py index 020cd94..7e97d94 100644 --- a/src/deep_code_agent/tui/widgets/__init__.py +++ b/src/deep_code_agent/tui/widgets/__init__.py @@ -4,6 +4,7 @@ from deep_code_agent.tui.widgets.input_box import InputBox from deep_code_agent.tui.widgets.message_bubble import MessageBubble from deep_code_agent.tui.widgets.selectable_option import SelectableOption +from deep_code_agent.tui.widgets.session_header import SessionHeader from deep_code_agent.tui.widgets.side_panel import SidePanel from deep_code_agent.tui.widgets.status_bar import StatusBar from deep_code_agent.tui.widgets.todos_progress_card import TodosProgressCard @@ -14,6 +15,7 @@ "InputBox", "MessageBubble", "SelectableOption", + "SessionHeader", "SidePanel", "StatusBar", "TodosProgressCard", diff --git a/src/deep_code_agent/tui/widgets/chat_log.py b/src/deep_code_agent/tui/widgets/chat_log.py index 4f4e9d7..c7e3e05 100644 --- a/src/deep_code_agent/tui/widgets/chat_log.py +++ b/src/deep_code_agent/tui/widgets/chat_log.py @@ -4,6 +4,7 @@ from textual.reactive import reactive from deep_code_agent.tui.widgets.message_bubble import MessageBubble +from deep_code_agent.tui.widgets.session_header import SessionHeader from deep_code_agent.tui.widgets.todos_progress_card import TodosProgressCard @@ -22,14 +23,14 @@ class ChatLog(VerticalScroll): DEFAULT_CSS = """ ChatLog { width: 100%; - height: 100%; - border: solid $primary-darken-2; - padding: 0 1; - background: $surface-darken-2; + height: 1fr; + border: none; + padding: 0 2; + background: #171717; } ChatLog:focus { - border: solid $primary; + border: none; } """ @@ -39,6 +40,7 @@ class ChatLog(VerticalScroll): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._todos_card: TodosProgressCard | None = None + self._session_header: SessionHeader | None = None def compose(self): """Compose the chat log (empty initially).""" @@ -52,6 +54,28 @@ def _mount_above_todos_card(self, widget) -> None: return self.mount(widget) + def add_session_header(self, session_info: dict | None = None) -> SessionHeader: + """Add or replace the Codex-style session header.""" + if self._session_header is not None: + try: + self._session_header.remove() + except Exception: + pass + + header = SessionHeader(session_info or {}) + self._session_header = header + if self.children: + self.mount(header, before=self.children[0]) + else: + self.mount(header) + self._scroll_to_bottom() + return header + + def update_session_header(self, session_info: dict) -> None: + """Update the session header if it is currently mounted.""" + if self._session_header is not None: + self._session_header.session_info = session_info + def add_user_message(self, content: str) -> MessageBubble: """Add a user message to the chat log. @@ -102,7 +126,7 @@ def add_tool_call(self, tool_name: str, args: dict) -> None: args: The tool arguments """ # Format the tool call nicely - content = f"๐Ÿ”ง Tool call: {tool_name}" + content = f"Ran {tool_name}" bubble = MessageBubble(content, role="system") self._mount_above_todos_card(bubble) self._scroll_to_bottom() @@ -176,6 +200,7 @@ def clear_messages(self) -> None: for child in list(self.children): child.remove() self._todos_card = None + self._session_header = None def _scroll_to_bottom(self) -> None: """Scroll to the bottom of the chat log.""" diff --git a/src/deep_code_agent/tui/widgets/input_box.py b/src/deep_code_agent/tui/widgets/input_box.py index 14c2b07..0fd697f 100644 --- a/src/deep_code_agent/tui/widgets/input_box.py +++ b/src/deep_code_agent/tui/widgets/input_box.py @@ -1,16 +1,64 @@ -"""Input box widget for user message entry.""" +"""Input composer widget for user message entry.""" -from textual.containers import Horizontal +from pathlib import Path + +from rich.markup import escape +from textual.binding import Binding +from textual.containers import Horizontal, Vertical from textual.message import Message from textual.reactive import reactive -from textual.widgets import Button, Input +from textual.widgets import Input, Static + +from deep_code_agent.tui.commands import SlashCommand, command_token, filter_slash_commands + + +class ComposerInput(Input): + """Prompt input with slash-command navigation keys.""" + + BINDINGS = [ + Binding("up", "slash_previous", "Previous command", show=False, priority=True), + Binding("down", "slash_next", "Next command", show=False, priority=True), + Binding("tab", "slash_complete", "Complete command", show=False, priority=True), + ] + + def action_slash_previous(self) -> None: + handler = getattr(self.parent, "select_previous_slash_command", None) + if callable(handler): + handler() + + def action_slash_next(self) -> None: + handler = getattr(self.parent, "select_next_slash_command", None) + if callable(handler): + handler() + + def action_slash_complete(self) -> None: + handler = getattr(self.parent, "complete_selected_slash_command", None) + if callable(handler) and handler(): + return + self.screen.focus_next() + + def on_key(self, event) -> None: + """Handle navigation keys before Input consumes them.""" + if event.key == "up": + event.stop() + event.prevent_default() + self.action_slash_previous() + return + if event.key == "down": + event.stop() + event.prevent_default() + self.action_slash_next() + return + if event.key == "tab": + event.stop() + event.prevent_default() + self.action_slash_complete() -class InputBox(Horizontal): - """Input box with text entry and send button. +class InputBox(Vertical): + """Codex-style input composer. - Provides a multi-line capable input area for users to type messages, - with a send button to submit. Emits a UserInput message when submitted. + Emits a UserInput message when submitted. Example: input_box = InputBox() @@ -19,40 +67,79 @@ class InputBox(Horizontal): # print(f"User said: {event.content}") """ + BINDINGS = [ + Binding("enter", "submit", "Send", show=True, key_display="Enter"), + Binding("up", "slash_previous", "Previous command", show=False, priority=True), + Binding("down", "slash_next", "Next command", show=False, priority=True), + Binding("tab", "slash_complete", "Complete command", show=False, priority=True), + ] + DEFAULT_CSS = """ InputBox { height: auto; - margin: 1 0; - padding: 0 1; + padding: 0 2 1 2; + background: #171717; + border-top: none; + } + + InputBox #prompt-row { + width: 100%; + height: 4; + padding: 1 1; + background: #3a3a3a; + } + + InputBox #prompt-marker { + width: 2; + height: 100%; + color: #f0f0f0; + content-align: left top; + text-style: bold; } InputBox Input { - width: 1fr; - height: 3; - border: solid $primary; + width: 100%; + height: 100%; + border: none; + background: transparent; + color: #f4f4f4; } InputBox Input:focus { - border: solid $primary-lighten-2; + border: none; } - InputBox Button { - width: auto; - margin-left: 1; - min-width: 10; + InputBox #slash-command-menu { + width: 100%; + height: auto; + max-height: 10; + margin: 0 0 0 0; + padding: 1 2; + background: #242424; + color: #dedede; + border-top: solid #333333; } - InputBox Button:hover { - background: $primary-darken-1; + InputBox #slash-command-menu.hidden { + display: none; } - InputBox .placeholder { - color: $text-muted; + InputBox #bottom-status { + height: 1; + margin-top: 0; + color: #969696; + content-align: left middle; } """ # Reactive state disabled = reactive(False) + session_info = reactive({}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._slash_commands: list[SlashCommand] = [] + self._slash_index = 0 class UserInput(Message): """Message sent when user submits input. @@ -67,23 +154,54 @@ def __init__(self, content: str) -> None: def compose(self): """Compose the input box.""" - yield Input(placeholder="Type your message here... (Shift+Enter for new line)", id="user-input") - yield Button("Send", variant="primary", id="send-button") + with Horizontal(id="prompt-row"): + yield Static("โ€บ", id="prompt-marker", markup=False) + yield ComposerInput( + value="", + placeholder="Use /skills to list available skills", + id="user-input", + ) + yield Static("", id="slash-command-menu", classes="hidden") + yield Static(self._status_text(), id="bottom-status") def on_mount(self) -> None: """Called when widget is mounted.""" - # Focus the input by default self.query_one("#user-input", Input).focus() - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle send button press.""" - if event.button.id == "send-button": - self._submit_input() + def watch_session_info(self, session_info: dict) -> None: + """Keep the bottom status line in sync with session metadata.""" + try: + self.query_one("#bottom-status", Static).update(self._status_text()) + except Exception: + pass - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle input submission (Enter key).""" + def action_submit(self) -> None: + """Submit the prompt from the keyboard binding.""" self._submit_input() + def action_slash_previous(self) -> None: + """Select the previous visible slash command.""" + self.select_previous_slash_command() + + def action_slash_next(self) -> None: + """Select the next visible slash command.""" + self.select_next_slash_command() + + def action_slash_complete(self) -> None: + """Complete the selected slash command, or fall back to focus navigation.""" + if not self.complete_selected_slash_command(): + self.screen.focus_next() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Submit when Enter is pressed in the prompt input.""" + if event.input.id == "user-input": + self._submit_input() + + def on_input_changed(self, event: Input.Changed) -> None: + """Refresh slash command suggestions as the user types.""" + if event.input.id == "user-input": + self._refresh_slash_command_menu(event.value) + def _submit_input(self) -> None: """Submit the current input value.""" if self.disabled: @@ -93,12 +211,10 @@ def _submit_input(self) -> None: content = input_widget.value.strip() if content: - # Emit user input message self.post_message(self.UserInput(content)) - # Clear the input input_widget.value = "" + self._hide_slash_command_menu() else: - # Visual feedback for empty input input_widget.focus() def set_disabled(self, disabled: bool) -> None: @@ -109,17 +225,111 @@ def set_disabled(self, disabled: bool) -> None: """ self.disabled = disabled input_widget = self.query_one("#user-input", Input) - button_widget = self.query_one("#send-button", Button) input_widget.disabled = disabled - button_widget.disabled = disabled if disabled: - input_widget.placeholder = "Waiting for agent..." + input_widget.placeholder = "Agent is working..." + self.add_class("disabled") + self._hide_slash_command_menu() else: - input_widget.placeholder = "Type your message here..." + input_widget.placeholder = "Use /skills to list available skills" + self.remove_class("disabled") input_widget.focus() def focus_input(self) -> None: """Focus the input field.""" self.query_one("#user-input", Input).focus() + + def select_previous_slash_command(self) -> bool: + """Move the slash-command selection up.""" + if not self._slash_commands: + return False + self._slash_index = (self._slash_index - 1) % len(self._slash_commands) + self._render_slash_command_menu() + return True + + def select_next_slash_command(self) -> bool: + """Move the slash-command selection down.""" + if not self._slash_commands: + return False + self._slash_index = (self._slash_index + 1) % len(self._slash_commands) + self._render_slash_command_menu() + return True + + def complete_selected_slash_command(self) -> bool: + """Complete the currently selected slash command into the prompt.""" + if not self._slash_commands: + return False + input_widget = self.query_one("#user-input", Input) + selected = self._slash_commands[self._slash_index] + input_widget.value = selected.name + input_widget.cursor_position = len(selected.name) + self._hide_slash_command_menu() + return True + + def _refresh_slash_command_menu(self, value: str) -> None: + menu = self.query_one("#slash-command-menu", Static) + if command_token(value) is None: + self._hide_slash_command_menu() + return + + commands = filter_slash_commands(value) + self._slash_commands = commands + self._slash_index = 0 + menu.remove_class("hidden") + if not commands: + self._slash_commands = [] + menu.update("[dim]No slash commands match[/dim]") + return + + self._render_slash_command_menu() + + def _render_slash_command_menu(self) -> None: + menu = self.query_one("#slash-command-menu", Static) + if not self._slash_commands: + menu.update("[dim]No slash commands match[/dim]") + return + + lines = ["[dim]Slash commands[/dim]"] + for index, command in enumerate(self._slash_commands[:7]): + selected = index == self._slash_index + marker = "โ€บ" if selected else " " + name_style = "#f6df9c" if selected else "#55c7ff" + desc_style = "#dedede" if selected else "dim" + lines.append( + f"{marker} [{name_style}]{escape(command.name):<8}[/] " + f"[{desc_style}]{escape(command.description)}[/]" + ) + menu.update("\n".join(lines)) + + def _hide_slash_command_menu(self) -> None: + try: + menu = self.query_one("#slash-command-menu", Static) + except Exception: + return + self._slash_commands = [] + self._slash_index = 0 + menu.add_class("hidden") + menu.update("") + + def _status_text(self) -> str: + model = str(self.session_info.get("model") or self.session_info.get("model_name") or "deep-code-agent") + reasoning = str(self.session_info.get("reasoning") or self.session_info.get("effort") or "").strip() + model_label = f"{model} {reasoning}".strip() + directory = str(self.session_info.get("directory") or self.session_info.get("codebase_dir") or Path.cwd()) + return ( + f"[#f6df9c]{escape(model_label)}[/]" + f" [dim]ยท[/dim] [#91d18b]{escape(self._short_path(directory))}[/]" + ) + + def _short_path(self, directory: str) -> str: + try: + path = Path(directory).expanduser() + home = Path.home() + display = "~/" + str(path.relative_to(home)) if path.is_relative_to(home) else str(path) + except Exception: + display = directory + if len(display) <= 58: + return display + return display[:24] + "โ€ฆ" + display[-29:] diff --git a/src/deep_code_agent/tui/widgets/message_bubble.py b/src/deep_code_agent/tui/widgets/message_bubble.py index d2d610b..59ce7c8 100644 --- a/src/deep_code_agent/tui/widgets/message_bubble.py +++ b/src/deep_code_agent/tui/widgets/message_bubble.py @@ -5,10 +5,7 @@ class MessageBubble(Vertical): - """A bubble displaying a chat message. - - Shows user messages on the right (aligned) and agent messages on the left. - System messages are centered and styled differently. + """A transcript row displaying a chat message. Args: content: The message content to display @@ -18,45 +15,35 @@ class MessageBubble(Vertical): DEFAULT_CSS = """ MessageBubble { width: 100%; - margin: 1 0; + margin: 0 0 1 0; height: auto; + padding: 0; } MessageBubble.user { - align: right middle; + background: #343434; + padding: 1 1; + margin: 1 0 1 0; } MessageBubble.agent { - align: left middle; + background: transparent; } MessageBubble.system { - align: center middle; - } - - MessageBubble .bubble-content { - max-width: 80%; - padding: 1 2; - height: auto; - } - - MessageBubble .role-label { - text-style: bold; - margin-bottom: 1; - } - - MessageBubble.user .role-label { - color: $primary; - } - - MessageBubble.agent .role-label { - color: $success; + background: transparent; + color: #d0d0d0; } MessageBubble .message-text { width: 100%; height: auto; text-wrap: wrap; + color: #e8e8e8; + } + + MessageBubble.system .message-text { + color: #d2d2d2; } """ @@ -67,11 +54,8 @@ def __init__(self, content: str, role: str = "agent", **kwargs): self.add_class(role) def compose(self): - """Compose the message bubble.""" - with Vertical(classes="bubble-content"): - if self.role != "system": - yield Static(f"{self.role.upper()}", classes="role-label") - yield Static(self.content, classes="message-text") + """Compose the transcript row.""" + yield Static(self._display_text(), classes="message-text", markup=False) def update_content(self, new_content: str) -> None: """Update the bubble content (useful for streaming).""" @@ -83,4 +67,11 @@ def update_content(self, new_content: str) -> None: # streamed chunks complete in the same event-loop turn. Storing # content is enough; compose() will render the latest value. return - content_widget.update(new_content) + content_widget.update(self._display_text()) + + def _display_text(self) -> str: + marker = "โ€บ" if self.role == "user" else "โ€ข" + lines = self.content.splitlines() or [""] + out = [f"{marker} {lines[0]}"] + out.extend(f" {line}" if line else "" for line in lines[1:]) + return "\n".join(out) diff --git a/src/deep_code_agent/tui/widgets/selectable_option.py b/src/deep_code_agent/tui/widgets/selectable_option.py index 8759347..563ae6d 100644 --- a/src/deep_code_agent/tui/widgets/selectable_option.py +++ b/src/deep_code_agent/tui/widgets/selectable_option.py @@ -19,25 +19,31 @@ class SelectableOption(Horizontal): SelectableOption { width: 100%; height: auto; - padding: 0 1; + padding: 1; + margin-bottom: 1; + border: solid #30362f; + background: #151817; } - SelectableOption:focus { - background: $primary-darken-1; + SelectableOption.selected { + border: tall #7dc4a4; + background: #18231d; } SelectableOption #option-marker { - width: 2; + width: 3; text-style: bold; + color: #7dc4a4; } SelectableOption #option-label { width: 1fr; + text-style: bold; } SelectableOption #option-description { width: auto; - color: $text-muted; + color: #90998f; } """ @@ -59,21 +65,24 @@ def __init__( self.label = label self.description = description self.selected = selected + if selected: + self.add_class("selected") def compose(self): """Compose the option widget.""" - marker = "โ–ถ" if self.selected else " " - yield Static(marker, id="option-marker") - yield Static(f"{self.key}. {self.label}", id="option-label") - yield Static(self.description, id="option-description") + marker = ">" if self.selected else " " + yield Static(marker, id="option-marker", markup=False) + yield Static(f"{self.key}. {self.label}", id="option-label", markup=False) + yield Static(self.description, id="option-description", markup=False) def watch_selected(self, selected: bool) -> None: """Update marker when selection changes.""" try: marker_widget = self.query_one("#option-marker", Static) - marker_widget.update("โ–ถ" if selected else " ") + marker_widget.update(">" if selected else " ") except Exception: pass + self.set_class(selected, "selected") def set_selected(self, selected: bool) -> None: """Set selection state.""" diff --git a/src/deep_code_agent/tui/widgets/session_header.py b/src/deep_code_agent/tui/widgets/session_header.py new file mode 100644 index 0000000..7d86e57 --- /dev/null +++ b/src/deep_code_agent/tui/widgets/session_header.py @@ -0,0 +1,81 @@ +"""Codex-style session header for the TUI transcript.""" + +from __future__ import annotations + +from pathlib import Path + +from deep_code_agent import __version__ +from textual.reactive import reactive +from textual.widgets import Static + + +class SessionHeader(Static): + """Small bordered session summary inspired by Codex CLI.""" + + DEFAULT_CSS = """ + SessionHeader { + width: auto; + height: auto; + margin: 1 0 2 0; + color: #e8e8e8; + background: transparent; + } + """ + + session_info = reactive({}) + + def __init__(self, session_info: dict | None = None, **kwargs) -> None: + super().__init__("", markup=False, **kwargs) + self.session_info = session_info or {} + + def on_mount(self) -> None: + """Render the initial header once the widget is mounted.""" + self._refresh() + + def watch_session_info(self, session_info: dict) -> None: + """Update the rendered header when session metadata changes.""" + self._refresh() + + def _refresh(self) -> None: + self.update(self._render_header()) + + def _render_header(self) -> str: + model = str(self.session_info.get("model") or self.session_info.get("model_name") or "deep-code-agent") + reasoning = str(self.session_info.get("reasoning") or self.session_info.get("effort") or "").strip() + model_label = f"{model} {reasoning}".strip() + directory = self._format_directory( + str(self.session_info.get("directory") or self.session_info.get("codebase_dir") or Path.cwd()) + ) + version = str(self.session_info.get("version") or __version__) + + rows = [ + f">_ Deep Code Agent (v{version})", + "", + f"model: {model_label} /model to change", + f"directory: {directory}", + ] + + width = min(72, max(46, *(self._plain_len(row) for row in rows))) + top = "โ•ญ" + "โ”€" * (width + 2) + "โ•ฎ" + bottom = "โ•ฐ" + "โ”€" * (width + 2) + "โ•ฏ" + body = [self._border_row(row, width) for row in rows] + return "\n".join([top, *body, bottom]) + + def _border_row(self, row: str, width: int) -> str: + padding = max(0, width - self._plain_len(row)) + return f"โ”‚ {row}{' ' * padding} โ”‚" + + def _format_directory(self, directory: str) -> str: + try: + path = Path(directory).expanduser() + home = Path.home() + display = "~/" + str(path.relative_to(home)) if path.is_relative_to(home) else str(path) + except Exception: + display = directory + + if len(display) <= 56: + return display + return display[:24] + "โ€ฆ" + display[-29:] + + def _plain_len(self, value: str) -> int: + return len(value) diff --git a/src/deep_code_agent/tui/widgets/side_panel.py b/src/deep_code_agent/tui/widgets/side_panel.py index 92547b5..c4fe75a 100644 --- a/src/deep_code_agent/tui/widgets/side_panel.py +++ b/src/deep_code_agent/tui/widgets/side_panel.py @@ -1,17 +1,18 @@ -"""Side panel widget for session information and file browser.""" +"""Side panel widget for session context and recent activity.""" + +from pathlib import Path -from textual.widgets import Static, Tree from textual.containers import Vertical from textual.reactive import reactive +from textual.widgets import Static class SidePanel(Vertical): - """A sidebar panel showing session info and file tree. + """A sidebar panel showing session context and recent tool activity. Displays: - Session ID and model info - Current codebase directory - - File tree (simplified) - Recent tool calls history Args: @@ -22,40 +23,52 @@ class SidePanel(Vertical): SidePanel { width: 100%; height: 100%; - background: $surface-darken-1; - border-right: solid $primary; + background: #111411; + color: #dce5da; + border-right: solid #343832; + padding: 1; + } + + SidePanel.collapsed { + display: none; } SidePanel #sidebar-title { - text-align: center; - text-style: bold; - background: $primary; - color: $text; - padding: 1; height: 3; + content-align: left middle; + text-style: bold; + color: #f0f4ef; + border-bottom: solid #343832; + margin-bottom: 1; } - SidePanel #session-info { - padding: 1; - background: $surface-darken-2; + SidePanel .panel-section { height: auto; + margin-bottom: 1; + padding: 1; + background: #171b17; + border: solid #2e342e; } - SidePanel #session-info Static { - margin: 0 0 1 0; + SidePanel .section-title { + height: 1; + color: #a7cdbd; + text-style: bold; + margin-bottom: 1; } - SidePanel #file-tree { - height: 1fr; - border-top: solid $primary-darken-2; - padding: 1; + SidePanel .kv-line { + height: auto; + color: #dce5da; + text-wrap: wrap; + margin-bottom: 1; } - SidePanel #tool-history { + SidePanel #tool-history-list { height: auto; - max-height: 10; - border-top: solid $primary-darken-2; - padding: 1; + max-height: 12; + color: #c8d2c6; + text-wrap: wrap; } """ @@ -69,28 +82,66 @@ def __init__(self, session_info: dict | None = None, **kwargs): def compose(self): """Compose the side panel.""" - yield Static("๐Ÿ“ Session Info", id="sidebar-title") - - with Vertical(id="session-info"): - yield Static(f"Model: {self.session_info.get('model', 'Unknown')}", id="model-name") - yield Static(f"Session: {self.session_info.get('session_id', 'N/A')[:8]}...", id="session-id") - yield Static(f"Dir: {self.session_info.get('codebase_dir', 'N/A')}", id="codebase-dir") - - yield Static("Recent Tool Calls:", id="tool-history") + yield Static("DEEP CODE", id="sidebar-title", markup=False) + + with Vertical(id="session-info", classes="panel-section"): + yield Static("SESSION", classes="section-title", markup=False) + yield Static(self._model_text(), id="model-name", classes="kv-line", markup=False) + yield Static(self._session_text(), id="session-id", classes="kv-line", markup=False) + yield Static(self._dir_text(), id="codebase-dir", classes="kv-line", markup=False) + + with Vertical(id="tool-history", classes="panel-section"): + yield Static("RECENT TOOLS", classes="section-title", markup=False) + yield Static("No tool calls yet.", id="tool-history-list", markup=False) + + def _short_path(self, value: str | None) -> str: + if not value: + return "N/A" + text = str(value) + try: + path = Path(text).expanduser() + home = Path.home() + if path == home or home in path.parents: + rel = path.relative_to(home) + text = f"~/{rel}" if str(rel) != "." else "~" + except Exception: + pass + max_width = 26 + if len(text) <= max_width: + return text + parts = [part for part in text.split("/") if part] + if len(parts) >= 2: + candidate = f".../{parts[-2]}/{parts[-1]}" + if len(candidate) <= max_width: + return candidate + if parts: + candidate = f".../{parts[-1]}" + if len(candidate) <= max_width: + return candidate + return f".../{text[-(max_width - 4):]}" + + def _model_text(self) -> str: + return f"Model: {self.session_info.get('model', 'Unknown')}" + + def _session_text(self) -> str: + session_id = str(self.session_info.get("session_id", "N/A")) + suffix = "..." if len(session_id) > 8 else "" + return f"Session: {session_id[:8]}{suffix}" + + def _dir_text(self) -> str: + return f"Codebase: {self._short_path(self.session_info.get('codebase_dir'))}" def watch_session_info(self, session_info: dict) -> None: """React to session info changes.""" - # Update static widgets with new info try: model_widget = self.query_one("#model-name", Static) - model_widget.update(f"Model: {session_info.get('model', 'Unknown')}") + model_widget.update(self._model_text()) session_widget = self.query_one("#session-id", Static) - session_id = session_info.get('session_id', 'N/A') - session_widget.update(f"Session: {session_id[:8]}...") + session_widget.update(self._session_text()) dir_widget = self.query_one("#codebase-dir", Static) - dir_widget.update(f"Dir: {session_info.get('codebase_dir', 'N/A')}") + dir_widget.update(self._dir_text()) except Exception: pass # Widgets might not be mounted yet @@ -105,12 +156,15 @@ def add_tool_call(self, tool_name: str, args: dict) -> None: # Keep only recent calls self.tool_calls = self.tool_calls[-5:] - # Update display try: - tool_widget = self.query_one("#tool-history", Static) - tool_text = "Recent Tool Calls:\n" + tool_widget = self.query_one("#tool-history-list", Static) + lines = [] for tc in reversed(self.tool_calls): - tool_text += f" โ€ข {tc['name']}\n" + args = tc.get("args", {}) + arg_count = len(args) if isinstance(args, dict) else 1 + suffix = f" ({arg_count} args)" if arg_count else "" + lines.append(f"- {tc['name']}{suffix}") + tool_text = "\n".join(lines) if lines else "No tool calls yet." tool_widget.update(tool_text) except Exception: pass @@ -119,7 +173,7 @@ def clear_tool_calls(self) -> None: """Clear the tool call history.""" self.tool_calls = [] try: - tool_widget = self.query_one("#tool-history", Static) - tool_widget.update("Recent Tool Calls:") + tool_widget = self.query_one("#tool-history-list", Static) + tool_widget.update("No tool calls yet.") except Exception: pass diff --git a/src/deep_code_agent/tui/widgets/status_bar.py b/src/deep_code_agent/tui/widgets/status_bar.py index cb1a843..be279c2 100644 --- a/src/deep_code_agent/tui/widgets/status_bar.py +++ b/src/deep_code_agent/tui/widgets/status_bar.py @@ -1,7 +1,8 @@ """Status bar widget for displaying application state.""" -from textual.widgets import Static from textual.reactive import reactive +from textual.widgets import Static +from rich.markup import escape class StatusBar(Static): @@ -24,33 +25,17 @@ class StatusBar(Static): DEFAULT_CSS = """ StatusBar { - height: 1; - dock: bottom; - background: $surface-darken-1; - color: $text; - content-align: center middle; + height: 2; + background: #171717; + color: #dcdcdc; + padding: 0 2 0 2; + content-align: left middle; text-style: none; } - StatusBar .status-icon { - text-style: bold; - } - - StatusBar .status-ready { - color: $success; - } - - StatusBar .status-thinking { - color: $warning; - } - - StatusBar .status-waiting { - color: $error; - } - - StatusBar .status-error { - color: $error; - text-style: bold; + StatusBar.error, + StatusBar.disconnected { + color: #ff9a9a; } """ @@ -61,14 +46,15 @@ class StatusBar(Static): def __init__(self, **kwargs): super().__init__(**kwargs) - self._status_icons = { - "ready": "๐ŸŸข", - "thinking": "๐Ÿค”", - "waiting_approval": "โณ", - "streaming": "๐Ÿ“", - "error": "โŒ", - "disconnected": "๐Ÿ”ด", + self._status_labels = { + "ready": "Ready", + "thinking": "Working", + "waiting_approval": "Waiting for approval", + "streaming": "Streaming", + "error": "Error", + "disconnected": "Offline", } + self._status_classes = set(self._status_labels) def watch_status(self, status: str) -> None: """React to status changes.""" @@ -78,23 +64,35 @@ def watch_message(self, message: str) -> None: """React to message changes.""" self._update_display() + def watch_session_info(self, session_info: dict) -> None: + """React to session metadata changes.""" + self._update_display() + def _update_display(self) -> None: """Update the status bar display.""" - icon = self._status_icons.get(self.status, "โšช") - message = self.message or self.status.replace("_", " ").title() - - # Build session info string if available - session_str = "" - if self.session_info: - parts = [] - if "model" in self.session_info: - parts.append(f"Model: {self.session_info['model']}") - if "session_id" in self.session_info: - parts.append(f"Session: {self.session_info['session_id'][:8]}...") - if parts: - session_str = " | " + " | ".join(parts) - - self.update(f"{icon} {message}{session_str}") + for status_class in self._status_classes: + self.remove_class(status_class) + if self.status in self._status_classes: + self.add_class(self.status) + + label = self._status_labels.get(self.status, self.status.replace("_", " ").upper()) + message = self.message.strip() + if message and message.lower() != label.lower(): + text = f"{label} [dim]({escape(message)})[/dim]" + elif self.status == "thinking": + text = "Working [dim](esc to interrupt)[/dim]" + else: + text = label + + bullet_color = { + "ready": "#21c45d", + "thinking": "#d6d6d6", + "streaming": "#55c7ff", + "waiting_approval": "#ffb86b", + "error": "#ff7b7b", + "disconnected": "#ff7b7b", + }.get(self.status, "#d6d6d6") + self.update(f"[{bullet_color}]โ€ข[/] {text}") def update_status( self, diff --git a/src/deep_code_agent/tui/widgets/todos_progress_card.py b/src/deep_code_agent/tui/widgets/todos_progress_card.py index 94d3993..33dd710 100644 --- a/src/deep_code_agent/tui/widgets/todos_progress_card.py +++ b/src/deep_code_agent/tui/widgets/todos_progress_card.py @@ -22,17 +22,16 @@ class TodosProgressCard(Vertical): TodosProgressCard { width: 100%; height: auto; - margin: 1 0; - padding: 1; - background: #172033; - border: solid $secondary; + margin: 0 0 1 0; + padding: 0; + background: transparent; display: block; } TodosProgressCard .todos-header { text-style: bold; - color: #a6e3ff; - margin-bottom: 1; + color: #ededed; + margin-bottom: 0; } TodosProgressCard .todos-body { @@ -43,30 +42,31 @@ class TodosProgressCard(Vertical): width: 100%; height: auto; text-wrap: wrap; + color: #9a9a9a; } TodosProgressCard .todo-pending { - color: $warning; + color: #9a9a9a; } TodosProgressCard .todo-in_progress { - color: $accent; + color: #58c7ff; } TodosProgressCard .todo-completed { - color: $success; + color: #7ed98a; } TodosProgressCard .todo-failed { - color: $error; + color: #ff9a9a; } """ STATUS_ICONS = { - "pending": "โ—‹", - "in_progress": "โ—", - "completed": "โœ“", - "failed": "โœ—", + "pending": "[ ]", + "in_progress": "[~]", + "completed": "[x]", + "failed": "[!]", } STATUS_LABELS = { @@ -85,7 +85,7 @@ def __init__(self, todos: list[TodoItem] | None = None, *, expanded: bool = True def compose(self): """Compose the todos card.""" - header = Static(self._header_text(), classes="todos-header") + header = Static(self._header_text(), classes="todos-header", markup=False) self._header_static = header yield header @@ -106,7 +106,7 @@ def _coerce_todos(self, todos: list[TodoItem]) -> list[TodoItem]: return coerced def _header_text(self) -> str: - marker = "โ–ผ" if self.expanded else "โ–ถ" + marker = "Working" if self.expanded else "Plan" counts = {status: 0 for status in self.STATUS_ICONS} for todo in self.todos: counts[todo["status"]] += 1 @@ -122,15 +122,16 @@ def _header_text(self) -> str: summary_parts.append(f"{counts['pending']} pending") summary = ", ".join(summary_parts) if summary_parts else "no tasks" - return f"{marker} ๐Ÿ“‹ Todos ({summary})" + return f"โ€ข {marker} ({summary})" def _make_row(self, todo: TodoItem) -> Static: status = todo["status"] icon = self.STATUS_ICONS[status] label = self.STATUS_LABELS[status] return Static( - f"{icon} [{label}] {todo['content']}", + f"โ”” {icon} {label}: {todo['content']}", classes=f"todo-row todo-{status}", + markup=False, ) def _refresh_header(self) -> None: diff --git a/src/deep_code_agent/tui/widgets/tool_call_view.py b/src/deep_code_agent/tui/widgets/tool_call_view.py index 74fab12..47e336d 100644 --- a/src/deep_code_agent/tui/widgets/tool_call_view.py +++ b/src/deep_code_agent/tui/widgets/tool_call_view.py @@ -21,64 +21,63 @@ class ToolCallView(Vertical): ToolCallView { width: 100%; height: auto; - margin: 1 0; - padding: 1; - background: #1a1a2e; - border: solid $primary; + margin: 0 0 1 0; + padding: 0; + background: transparent; display: block; } ToolCallView .tool-header { text-style: bold; - color: #00d4ff; - margin-bottom: 1; + color: #ededed; + margin-bottom: 0; } ToolCallView .status-pending { - color: $warning; + color: #d6d6d6; } ToolCallView .status-running { - color: $accent; + color: #58c7ff; } ToolCallView .status-success { - color: $success; + color: #ededed; } ToolCallView .status-error { - color: $error; + color: #ff9a9a; } ToolCallView .tool-args { - background: #16213e; - padding: 1; - margin-top: 1; - border: solid #0f3460; + background: transparent; + padding: 0; + margin: 0; height: auto; } ToolCallView .arg-line { margin: 0; - color: #e94560; + color: #9a9a9a; + text-wrap: wrap; } ToolCallView .tool-result { - margin-top: 1; - background: #16213e; - padding: 1; - border: solid #0f3460; + margin-top: 0; + background: transparent; + padding: 0; height: auto; } ToolCallView .result-label { text-style: bold; - color: #eaeaea; - margin-bottom: 1; + color: #9a9a9a; + margin-bottom: 0; } ToolCallView .result-content { - color: #ffffff; + color: #9a9a9a; + text-wrap: wrap; } """ @@ -101,14 +100,11 @@ def __init__( def compose(self): """Compose the tool call view.""" - # Header with tool name and status status_class = f"status-{self.status}" - # Use a fallback name if tool_name is unknown or empty - display_name = self.tool_name if self.tool_name and self.tool_name not in ("unknown", "", "None") else "tool" - header_text = f"๐Ÿ”ง {display_name}" header = Static( - f"{header_text} [{self.status.upper()}]", - classes=f"tool-header {status_class}" + self._header_text(), + classes=f"tool-header {status_class}", + markup=False, ) self._header_static = header yield header @@ -118,21 +114,45 @@ def compose(self): args_container = Vertical(classes="tool-args") self._args_container = args_container with args_container: - yield Static("Arguments:", classes="result-label") for key, value in self.args.items(): - value_str = str(value) - if len(value_str) > 100: - value_str = value_str[:100] + "..." - arg_line = f" {key}: {value_str}" - yield Static(arg_line, classes="arg-line") + yield Static(self._format_arg(key, value), classes="arg-line", markup=False) # Result (shown if result exists and is not empty) if self.result and str(self.result).strip(): result_container = Vertical(classes="tool-result") self._result_container = result_container with result_container: - yield Static("Result:", classes="result-label") - yield Static(self.result, classes="result-content") + for line in self._format_result(self.result): + yield Static(line, classes="result-content", markup=False) + + def _format_arg(self, key: str, value) -> str: + value_str = str(value) + if len(value_str) > 160: + value_str = value_str[:157] + "..." + return f"โ”” {key}: {value_str}" + + def _format_result(self, result: str) -> list[str]: + lines = str(result).splitlines() or [str(result)] + if len(lines) > 5: + lines = [*lines[:4], "..."] + formatted: list[str] = [] + for line in lines: + if len(line) > 180: + line = line[:177] + "..." + formatted.append(f"โ”” {line}") + return formatted + + def _display_name(self) -> str: + return self.tool_name if self.tool_name and self.tool_name not in ("unknown", "", "None") else "tool" + + def _header_text(self) -> str: + verb = { + "pending": "Queued", + "running": "Running", + "success": "Ran", + "error": "Failed", + }.get(self.status, "Ran") + return f"โ€ข {verb} {self._display_name()}" def update_status(self, status: str) -> None: """Update the tool call status. @@ -144,10 +164,7 @@ def update_status(self, status: str) -> None: # Update the header display try: header = self.query_one(".tool-header", Static) - # Use a fallback name if tool_name is unknown or empty - display_name = self.tool_name if self.tool_name and self.tool_name not in ("unknown", "", "None") else "tool" - header_text = f"๐Ÿ”ง {display_name}" - header.update(f"{header_text} [{status.upper()}]") + header.update(self._header_text()) # Update status class for status_type in ["pending", "running", "success", "error"]: @@ -165,6 +182,9 @@ def update_args(self, args: dict) -> None: return self.args = args + if self._header_static is None: + return + if self._args_container: self._args_container.remove() @@ -176,13 +196,8 @@ def update_args(self, args: dict) -> None: except Exception: self.mount(args_container) - args_container.mount(Static("Arguments:", classes="result-label")) for key, value in args.items(): - value_str = str(value) - if len(value_str) > 100: - value_str = value_str[:100] + "..." - arg_line = f" {key}: {value_str}" - args_container.mount(Static(arg_line, classes="arg-line")) + args_container.mount(Static(self._format_arg(key, value), classes="arg-line", markup=False)) def update_result(self, result: str, status: str = "success") -> None: """Update the tool call result and status. @@ -194,6 +209,9 @@ def update_result(self, result: str, status: str = "success") -> None: self.status = status self.result = result + if self._header_static is None: + return + # Remove old result container if exists if self._result_container: self._result_container.remove() @@ -201,10 +219,14 @@ def update_result(self, result: str, status: str = "success") -> None: # Create new result container result_container = Vertical(classes="tool-result") self._result_container = result_container - self.mount(result_container) + anchor = self._args_container or self._header_static + try: + self.mount(result_container, after=anchor) + except Exception: + self.mount(result_container) - result_container.mount(Static("Result:", classes="result-label")) - result_container.mount(Static(result, classes="result-content")) + for line in self._format_result(result): + result_container.mount(Static(line, classes="result-content", markup=False)) # Update status self.update_status(status) diff --git a/tests/test_cli.py b/tests/test_cli.py index 24e1df3..e48a56d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -162,3 +162,26 @@ def test_tui_mode_defers_agent_initialization_until_after_app_starts(mock_initia mock_app_class.call_args.kwargs["agent_factory"]() assert mock_initialize_agent.call_args.args == (args, mock_app_class.call_args.kwargs["session_info"]["codebase_dir"]) app_instance.run.assert_called_once_with() + + +@patch("dotenv.load_dotenv") +@patch("deep_code_agent.tui.DeepCodeAgentApp") +def test_tui_session_info_uses_env_model_and_package_version(mock_app_class, mock_load_dotenv, monkeypatch): + """The TUI header should reflect dotenv-backed model metadata before agent init.""" + monkeypatch.setenv("MODEL_NAME", "gpt-from-env") + args = SimpleNamespace( + model_name=None, + model_provider="openai", + api_key=None, + base_url=None, + backend_type="state", + skills_dir=None, + thread_id="test-thread", + ) + + _run_tui_mode(args) + + session_info = mock_app_class.call_args.kwargs["session_info"] + assert session_info["model"] == "gpt-from-env" + assert session_info["version"] == __version__ + mock_load_dotenv.assert_called_once() diff --git a/tests/tui/test_agent_bridge_helpers.py b/tests/tui/test_agent_bridge_helpers.py index 7e74b4a..5eb68b1 100644 --- a/tests/tui/test_agent_bridge_helpers.py +++ b/tests/tui/test_agent_bridge_helpers.py @@ -20,6 +20,22 @@ def test_agent_bridge_extracts_tool_name_from_action_requests(): ] +def test_agent_bridge_extracts_nested_action_request_wrapper(): + from deep_code_agent.tui.bridge.agent_bridge import AgentBridge + + bridge = AgentBridge(agent=object()) + interrupt_data = { + "action_requests": [ + {"action": {"name": "read_file", "args": {"path": "x.txt"}}}, + ] + } + + assert bridge._extract_tool_name_from_interrupt(interrupt_data) == "read_file" + assert bridge._extract_action_requests_from_interrupt(interrupt_data) == [ + {"name": "read_file", "args": {"path": "x.txt"}}, + ] + + def test_agent_bridge_extracts_tool_name_from_tool_calls(): from deep_code_agent.tui.bridge.agent_bridge import AgentBridge diff --git a/tests/tui/test_approval_modal.py b/tests/tui/test_approval_modal.py index 2dd3041..5e85158 100644 --- a/tests/tui/test_approval_modal.py +++ b/tests/tui/test_approval_modal.py @@ -122,3 +122,19 @@ def callback(decision): } asyncio.run(run_test()) + + +def test_approval_modal_extracts_nested_action_request(): + """DeepAgents action request wrappers should render the real tool name.""" + from deep_code_agent.tui.screens.approval_modal import ApprovalModal + + interrupt_data = { + "action_requests": [ + {"action": {"name": "read_file", "args": {"path": "test.txt"}}} + ] + } + + modal = ApprovalModal(interrupt_data, callback=lambda d: None) + + assert modal.tool_name == "read_file" + assert modal.tool_args == {"path": "test.txt"} diff --git a/tests/tui/test_main_screen_commands.py b/tests/tui/test_main_screen_commands.py new file mode 100644 index 0000000..2acf6f6 --- /dev/null +++ b/tests/tui/test_main_screen_commands.py @@ -0,0 +1,65 @@ +"""Tests for local TUI command handling.""" + +import asyncio + + +def test_tui_exit_command_exits_without_contacting_agent(): + """Slash exit should be handled locally rather than sent to the agent.""" + from deep_code_agent.tui.app import DeepCodeAgentApp + from deep_code_agent.tui.widgets.input_box import InputBox + + class ExplodingBridge: + async def process_request(self, content: str) -> None: + raise AssertionError(f"agent should not receive local command: {content}") + + async def run_test(): + app = DeepCodeAgentApp(agent=object()) + async with app.run_test() as pilot: + await pilot.pause() + screen = app._main_screen + assert screen is not None + app.bridge = ExplodingBridge() + + exited = [] + original_exit = app.exit + + def capture_exit(*args, **kwargs): + exited.append(True) + return original_exit(*args, **kwargs) + + app.exit = capture_exit + screen.on_input_box_user_input(InputBox.UserInput("/exit")) + await pilot.pause() + + assert exited == [True] + + asyncio.run(run_test()) + + +def test_tui_model_command_is_handled_locally(): + """Slash model should render local metadata instead of contacting the agent.""" + from deep_code_agent.tui.app import DeepCodeAgentApp + from deep_code_agent.tui.widgets.input_box import InputBox + + class ExplodingBridge: + async def process_request(self, content: str) -> None: + raise AssertionError(f"agent should not receive local command: {content}") + + async def run_test(): + app = DeepCodeAgentApp( + agent=object(), + session_info={"model": "gpt-test", "model_provider": "openai"}, + ) + async with app.run_test() as pilot: + await pilot.pause() + screen = app._main_screen + assert screen is not None + app.bridge = ExplodingBridge() + + screen.on_input_box_user_input(InputBox.UserInput("/model")) + await pilot.pause() + + chat_text = "\n".join(str(getattr(child, "content", "")) for child in app._chat_log.children) + assert "gpt-test" in chat_text + + asyncio.run(run_test()) diff --git a/tests/tui/test_slash_commands.py b/tests/tui/test_slash_commands.py new file mode 100644 index 0000000..aa6c3b7 --- /dev/null +++ b/tests/tui/test_slash_commands.py @@ -0,0 +1,33 @@ +"""Tests for slash command filtering.""" + + +def test_slash_commands_show_all_for_empty_slash_query(): + from deep_code_agent.tui.commands import filter_slash_commands + + names = [command.name for command in filter_slash_commands("/")] + + assert names[:3] == ["/help", "/clear", "/skills"] + assert "/exit" in names + + +def test_slash_commands_prioritize_prefix_matches(): + from deep_code_agent.tui.commands import filter_slash_commands + + names = [command.name for command in filter_slash_commands("/cl")] + + assert names[0] == "/clear" + + +def test_slash_commands_match_substrings(): + from deep_code_agent.tui.commands import filter_slash_commands + + names = [command.name for command in filter_slash_commands("/odel")] + + assert names == ["/model"] + + +def test_slash_commands_ignore_non_command_text(): + from deep_code_agent.tui.commands import filter_slash_commands + + assert filter_slash_commands("please /clear") == [] + assert filter_slash_commands("/clear now") == [] diff --git a/tests/tui/test_widgets.py b/tests/tui/test_widgets.py index d2fbdb7..643beac 100644 --- a/tests/tui/test_widgets.py +++ b/tests/tui/test_widgets.py @@ -1,7 +1,7 @@ """Tests for TUI widgets.""" -import pytest -from textual.containers import Vertical +from textual.app import App +from textual.widgets import Input, Static def test_selectable_option_creation(): @@ -29,3 +29,206 @@ def test_message_bubble_update_before_compose_is_safe(): bubble.update_content("latest") assert bubble.content == "latest" + + +def test_input_box_submits_text(): + """The composer should submit and clear the current prompt.""" + import asyncio + + from deep_code_agent.tui.widgets.input_box import InputBox + + async def run_test(): + async with App().run_test() as pilot: + widget = InputBox() + await pilot.app.mount(widget) + await pilot.pause() + + input_widget = widget.query_one("#user-input", Input) + input_widget.value = "send me" + + messages = [] + original_post_message = widget.post_message + + def capture(message): + if isinstance(message, InputBox.UserInput): + messages.append(message.content) + return original_post_message(message) + + widget.post_message = capture + widget._submit_input() + await pilot.pause() + + assert messages == ["send me"] + assert input_widget.value == "" + + asyncio.run(run_test()) + + +def test_input_box_enter_submits_when_text_area_has_focus(): + """Pressing Enter in the composer should submit instead of inserting a newline.""" + import asyncio + + from deep_code_agent.tui.widgets.input_box import InputBox + + async def run_test(): + async with App().run_test() as pilot: + widget = InputBox() + await pilot.app.mount(widget) + await pilot.pause() + + input_widget = widget.query_one("#user-input", Input) + input_widget.value = "send me" + input_widget.focus() + + messages = [] + original_post_message = widget.post_message + + def capture(message): + if isinstance(message, InputBox.UserInput): + messages.append(message.content) + return original_post_message(message) + + widget.post_message = capture + await pilot.press("enter") + await pilot.pause() + + assert messages == ["send me"] + assert input_widget.value == "" + + asyncio.run(run_test()) + + +def test_input_box_shows_slash_command_menu(): + """Typing / should show available slash commands below the prompt.""" + import asyncio + + from deep_code_agent.tui.widgets.input_box import InputBox + + async def run_test(): + async with App().run_test() as pilot: + widget = InputBox() + await pilot.app.mount(widget) + await pilot.pause() + + input_widget = widget.query_one("#user-input", Input) + input_widget.value = "/" + await pilot.pause() + + menu = widget.query_one("#slash-command-menu", Static) + assert not menu.has_class("hidden") + assert "/clear" in menu.content + assert "/exit" in menu.content + + asyncio.run(run_test()) + + +def test_input_box_filters_slash_command_menu(): + """Slash command suggestions should narrow as the user types.""" + import asyncio + + from deep_code_agent.tui.widgets.input_box import InputBox + + async def run_test(): + async with App().run_test() as pilot: + widget = InputBox() + await pilot.app.mount(widget) + await pilot.pause() + + input_widget = widget.query_one("#user-input", Input) + input_widget.value = "/cl" + await pilot.pause() + + menu = widget.query_one("#slash-command-menu", Static) + rendered = menu.content + assert "/clear" in rendered + assert "/exit" not in rendered + + asyncio.run(run_test()) + + +def test_input_box_tab_completes_first_slash_command_by_default(): + """Tab should complete the first suggestion when no navigation happened.""" + import asyncio + + from deep_code_agent.tui.widgets.input_box import InputBox + + async def run_test(): + async with App().run_test() as pilot: + widget = InputBox() + await pilot.app.mount(widget) + await pilot.pause() + + input_widget = widget.query_one("#user-input", Input) + input_widget.value = "/" + input_widget.focus() + await pilot.pause() + + await pilot.press("tab") + await pilot.pause() + + assert input_widget.value == "/help" + + asyncio.run(run_test()) + + +def test_input_box_arrow_navigation_controls_tab_completion(): + """After arrow navigation, Tab should complete the selected suggestion.""" + import asyncio + + from deep_code_agent.tui.widgets.input_box import InputBox + + async def run_test(): + async with App().run_test() as pilot: + widget = InputBox() + await pilot.app.mount(widget) + await pilot.pause() + + input_widget = widget.query_one("#user-input", Input) + input_widget.value = "/" + input_widget.focus() + await pilot.pause() + + await pilot.press("down") + await pilot.press("tab") + await pilot.pause() + + assert input_widget.value == "/clear" + + asyncio.run(run_test()) + + +def test_input_box_arrow_navigation_wraps_slash_commands(): + """Arrow navigation should wrap around the slash command list.""" + import asyncio + + from deep_code_agent.tui.widgets.input_box import InputBox + + async def run_test(): + async with App().run_test() as pilot: + widget = InputBox() + await pilot.app.mount(widget) + await pilot.pause() + + input_widget = widget.query_one("#user-input", Input) + input_widget.value = "/" + input_widget.focus() + await pilot.pause() + + await pilot.press("up") + await pilot.press("tab") + await pilot.pause() + + assert input_widget.value == "/exit" + + asyncio.run(run_test()) + + +def test_side_panel_compacts_long_codebase_path(): + """Long paths should not overwhelm the sidebar.""" + from deep_code_agent.tui.widgets.side_panel import SidePanel + + panel = SidePanel(session_info={"codebase_dir": "/very/" + "long/" * 12 + "project"}) + compact = panel._short_path(panel.session_info["codebase_dir"]) + + assert compact.startswith("...") + assert len(compact) <= 26