From 79cfc06ebd19fa71fb424996ca25a6e741e52675 Mon Sep 17 00:00:00 2001 From: hzhaoy Date: Thu, 2 Jul 2026 19:36:53 +0800 Subject: [PATCH] feat(tui): make the TUI feel closer to Codex CLI Reshape the Textual interface around a transcript-first flow, fix TUI startup metadata from dotenv, and keep common slash commands local so exit, clear, help, and model commands do not reach the agent. The composer now sends on Enter, shows slash command suggestions, supports arrow navigation, and completes the selected command with Tab. Constraint: Keep the existing Textual dependency and avoid adding packages Rejected: Keep the multiline TextArea composer | Enter-to-send and slash completion need single-line prompt semantics Confidence: high Scope-risk: moderate Directive: Slash commands are local TUI commands; do not forward exact local command aliases to the agent Tested: uv run pytest Tested: git diff --check --- src/deep_code_agent/cli.py | 8 +- src/deep_code_agent/tui/app.py | 12 +- .../tui/bridge/agent_bridge.py | 21 +- src/deep_code_agent/tui/commands.py | 70 +++++ .../tui/screens/approval_modal.py | 58 ++-- .../tui/screens/main_screen.py | 124 ++++++-- src/deep_code_agent/tui/styles/main.tcss | 80 ++--- src/deep_code_agent/tui/widgets/__init__.py | 2 + src/deep_code_agent/tui/widgets/chat_log.py | 37 ++- src/deep_code_agent/tui/widgets/input_box.py | 284 +++++++++++++++--- .../tui/widgets/message_bubble.py | 57 ++-- .../tui/widgets/selectable_option.py | 29 +- .../tui/widgets/session_header.py | 81 +++++ src/deep_code_agent/tui/widgets/side_panel.py | 140 ++++++--- src/deep_code_agent/tui/widgets/status_bar.py | 92 +++--- .../tui/widgets/todos_progress_card.py | 37 +-- .../tui/widgets/tool_call_view.py | 120 +++++--- tests/test_cli.py | 23 ++ tests/tui/test_agent_bridge_helpers.py | 16 + tests/tui/test_approval_modal.py | 16 + tests/tui/test_main_screen_commands.py | 65 ++++ tests/tui/test_slash_commands.py | 33 ++ tests/tui/test_widgets.py | 207 ++++++++++++- 23 files changed, 1245 insertions(+), 367 deletions(-) create mode 100644 src/deep_code_agent/tui/commands.py create mode 100644 src/deep_code_agent/tui/widgets/session_header.py create mode 100644 tests/tui/test_main_screen_commands.py create mode 100644 tests/tui/test_slash_commands.py 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