diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 2b2fbdb40be..ff3dd190ea5 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1597,7 +1597,7 @@ async def output_task(self, preproc): self.io.output_task = asyncio.create_task(self.generate(user_message, preproc)) # Start spinner for output task - self.io.start_spinner("Processing...") + self.io.start_spinner("Processing...", coder_uuid=getattr(self, "uuid", None)) await self.io.recreate_input() # Monitor output task @@ -2365,7 +2365,7 @@ async def format_in_executor(): if not self.tui: spinner_text += f" • ${self.format_cost(self.total_cost)} session" - self.io.start_spinner(spinner_text) + self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None)) if self.stream: self.mdstream = True else: @@ -2452,9 +2452,7 @@ async def format_in_executor(): self.mdstream = None # Ensure any waiting spinner is stopped - self.io.start_spinner("Processing Answer...") - - self.partial_response_content = self.get_multi_response_content_in_progress(True) + self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None)) self.remove_reasoning_content() self.multi_response_content = "" diff --git a/cecli/io.py b/cecli/io.py index 923c795466d..59ebdeeec36 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -553,7 +553,7 @@ def _spinner_supports_unicode(self) -> bool: except Exception: return False - def start_spinner(self, text, update_last_text=True): + def start_spinner(self, text, update_last_text=True, **kwargs): """Start the spinner.""" self.stop_spinner() diff --git a/cecli/tui/app.py b/cecli/tui/app.py index d3cd0eb736b..9d151bff074 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -502,9 +502,10 @@ def check_output_queue(self): pass def handle_output_message(self, msg): - """Route output messages to appropriate handlers.""" msg_type = msg["type"] + # Resolve agent_name from coder_uuid for agent-specific status messages + agent_name = self._resolve_agent_name(msg.get("coder_uuid")) if msg_type == "output": container = self._get_output_container(msg) container.add_output(msg["text"], msg.get("task_id")) @@ -532,15 +533,15 @@ def handle_output_message(self, msg): container = self._get_output_container(msg) container.start_task(msg["task_id"], msg["title"], msg.get("task_type")) elif msg_type == "confirmation": - self.show_confirmation(msg) + self.show_confirmation(msg, agent_name=agent_name) elif msg_type == "spinner": - self.update_spinner(msg) + self.update_spinner(msg, agent_name=agent_name) elif msg_type == "ready_for_input": self.enable_input(msg) footer = self.query_one(MainFooter) footer.stop_spinner() elif msg_type == "error": - self.show_error(msg["message"]) + self.show_error(msg["message"], agent_name=agent_name) elif msg_type == "cost_update": footer = self.query_one(MainFooter) footer.update_cost(msg.get("cost", 0)) @@ -563,6 +564,53 @@ def handle_output_message(self, msg): else: self._switch_to_container(target_uuid) + def _resolve_agent_name(self, coder_uuid: str | None) -> str | None: + """Resolve an agent display name from a coder_uuid. + + Returns the sub-agent's name if the coder_uuid belongs to a known + sub-agent. For the primary agent, returns "primary" if sub-agents + exist, otherwise None. + + If multiple sub-agents share the same name, disambiguates by + appending the first 3 characters of the UUID in parentheses. + """ + if not coder_uuid: + return None + try: + if not self.worker or not self.worker.coder: + return None # Cannot resolve without a coder + from cecli.helpers.agents.service import AgentService + + agent_service = AgentService.get_instance(self.worker.coder) + if not agent_service: + return None + primary_uuid = str(agent_service.coder.uuid) + if coder_uuid == primary_uuid: + if agent_service.sub_agents: + return "primary" + return None # Primary agent gets no prefix + if not agent_service.sub_agents: + return None + for info in agent_service.sub_agents.values(): + if not info or not info.coder: + continue + if str(info.coder.uuid) == coder_uuid: + # Check for duplicate names among sub-agents + name_count = sum( + 1 + for i in agent_service.sub_agents.values() + if i and hasattr(i, "name") and i.name == info.name + ) + if name_count > 1: + # Disambiguate with first 3 UUID characters + short_uuid = str(info.coder.uuid)[:3] + return f"{info.name} ({short_uuid})" + return info.name + except (AttributeError, ImportError, KeyError): + # Agent service not available or coder not yet initialized + pass + return None + def add_output(self, text, task_id=None): """Add output to the output container.""" output_container = self.query_one("#output", OutputContainer) @@ -601,7 +649,7 @@ def start_task(self, task_id, title, task_type="general"): output_container = self.query_one("#output", OutputContainer) output_container.start_task(task_id, title, task_type) - def show_confirmation(self, msg): + def show_confirmation(self, msg, agent_name: str | None = None): """Show inline confirmation bar.""" # Disable input while confirm bar is active input_area = self.query_one("#input", InputArea) @@ -623,6 +671,7 @@ def show_confirmation(self, msg): allow_never=allow_never, default=options.get("default", "y"), explicit_yes_required=options.get("explicit_yes_required", False), + agent_name=agent_name, ) def enable_input(self, msg, coder=None): @@ -657,13 +706,13 @@ def enable_input(self, msg, coder=None): input_area.focus() - def update_spinner(self, msg): + def update_spinner(self, msg, agent_name: str | None = None): """Update spinner in footer.""" footer = self.query_one(MainFooter) action = msg.get("action", "start") if action == "start": - footer.start_spinner(msg.get("text", "")) + footer.start_spinner(msg.get("text", ""), agent_name=agent_name or "") elif action == "update": footer.spinner_text = msg.get("text", "") elif action == "update_suffix": @@ -671,10 +720,11 @@ def update_spinner(self, msg): elif action == "stop": footer.stop_spinner() - def show_error(self, message): - """Show error notification.""" - status_bar = self.query_one("#status-bar", StatusBar) - status_bar.show_notification(f"Error: {message}", severity="error", timeout=10) + def show_error(self, message, agent_name: str | None = None): + """Show an error message in the status bar.""" + self.status_bar.show_notification( + message, severity="error", timeout=5, agent_name=agent_name + ) def on_resize(self) -> None: file_list = self.query_one("#file-list", FileList) @@ -781,15 +831,21 @@ def on_input_area_submit(self, message: InputArea.Submit): # Update footer to show processing footer = self.query_one(MainFooter) - footer.start_spinner("Processing...") coder = self.worker.coder - - if coder: - coder.io.start_spinner("Processing...") - # Determine which coder is in the foreground for input routing foreground_coder = AgentService.get_instance(coder).foreground_coder + coder_uuid = ( + str(foreground_coder.uuid) + if foreground_coder and hasattr(foreground_coder, "uuid") + else None + ) + agent_name = self._resolve_agent_name(coder_uuid) + + footer.start_spinner("Processing...", agent_name=agent_name or "") + + if coder: + coder.io.start_spinner("Processing...", coder_uuid=coder_uuid) if coder and is_active(getattr(coder.io, "output_task", None)): from cecli.helpers.conversation import ConversationService, MessageTag diff --git a/cecli/tui/io.py b/cecli/tui/io.py index f204bf2c44c..36dcb6a543f 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -328,13 +328,15 @@ def _reroute_output(self, text, msg_type, **kwargs): return False - def start_spinner(self, text, update_last_text=True): + def start_spinner(self, text, update_last_text=True, **kwargs): """Override start_spinner to send spinner state to TUI. Args: text: Spinner text update_last_text: Whether to update last_spinner_text + coder_uuid: Optional uuid string to include in the message """ + coder_uuid = kwargs.get("coder_uuid", None) # Call parent to maintain state super().start_spinner(text, update_last_text) @@ -344,23 +346,27 @@ def start_spinner(self, text, update_last_text=True): "type": "spinner", "action": "start", "text": text, + "coder_uuid": coder_uuid, } ) self.output_queue.put( { "type": "spinner", + "coder_uuid": coder_uuid, "action": "update_suffix", "text": "", } ) - def update_spinner(self, text): + def update_spinner(self, text, **kwargs): """Override update_spinner to send updates to TUI. Args: text: New spinner text + coder_uuid: Optional uuid string to include in the message """ + coder_uuid = kwargs.get("coder_uuid", None) # Call parent super().update_spinner(text) @@ -370,15 +376,18 @@ def update_spinner(self, text): "type": "spinner", "action": "update", "text": text, + "coder_uuid": coder_uuid, } ) - def update_spinner_suffix(self, text=None): + def update_spinner_suffix(self, text=None, **kwargs): """Override update_spinner_suffix to send updates to TUI. Args: text: New spinner suffix text + coder_uuid: Optional uuid string to include in the message """ + coder_uuid = kwargs.get("coder_uuid", None) # Call parent super().update_spinner_suffix(text) @@ -388,21 +397,18 @@ def update_spinner_suffix(self, text=None): "type": "spinner", "action": "update_suffix", "text": text, + "coder_uuid": coder_uuid, } ) - def stop_spinner(self): + def stop_spinner(self, **kwargs): """Override stop_spinner to send stop state to TUI.""" + coder_uuid = kwargs.get("coder_uuid", None) # Call parent super().stop_spinner() # Send to TUI - self.output_queue.put( - { - "type": "spinner", - "action": "stop", - } - ) + self.output_queue.put({"type": "spinner", "action": "stop", "coder_uuid": coder_uuid}) def interrupt_input(self): self.interrupted = True @@ -518,6 +524,7 @@ async def confirm_ask( allow_never=False, allow_tweak=False, acknowledge=False, + coder_uuid=None, ): """Override confirm_ask to show modal instead of inline prompt. @@ -594,6 +601,7 @@ async def confirm_ask( "acknowledge": acknowledge, "valid_responses": valid_responses, }, + "coder_uuid": coder_uuid, } ) diff --git a/cecli/tui/widgets/footer.py b/cecli/tui/widgets/footer.py index b85f4eccd8f..5f77cdae230 100644 --- a/cecli/tui/widgets/footer.py +++ b/cecli/tui/widgets/footer.py @@ -10,6 +10,7 @@ class MainFooter(Static): # Left side info coder_mode = reactive("code") + agent_name = reactive("") model_name = reactive("") # Right side info @@ -46,6 +47,7 @@ def __init__( self.project_name = project_name self.git_branch = git_branch self.coder_mode = coder_mode + self.agent_name = "" self._spinner_interval = None def on_mount(self): @@ -77,7 +79,7 @@ def _get_display_model(self) -> str: else: name = coder.get_active_model().name except Exception: - name = self.app.worker.coder.get_active_model().name + name = self.model_name # Strip common prefixes like "openrouter/x-ai/" if len(name) > 40: @@ -98,9 +100,10 @@ def render(self) -> Text: if self.spinner_visible: spinner_char = self._spinner_chars[self._spinner_frame] left.append(f"{spinner_char} ") + if self.agent_name: + left.append(f"({self.agent_name}) ") if self.spinner_text: left.append(self.spinner_text) - # When a sub-agent is generating, show its model alongside the spinner # if self._has_running_sub_agent(): # model_display = self._get_display_model() @@ -176,9 +179,10 @@ def update_mode(self, mode: str): self.coder_mode = mode self.refresh() - def start_spinner(self, text: str = ""): + def start_spinner(self, text: str = "", agent_name: str = ""): """Show spinner with optional text.""" self.spinner_text = text + self.agent_name = agent_name self.spinner_visible = True self.refresh() @@ -204,6 +208,7 @@ def stop_spinner(self): self.spinner_visible = False self.spinner_text = "" + self.agent_name = "" self.refresh() def _has_running_sub_agent(self) -> bool: diff --git a/cecli/tui/widgets/status_bar.py b/cecli/tui/widgets/status_bar.py index 5197d04f064..b198a30cb27 100644 --- a/cecli/tui/widgets/status_bar.py +++ b/cecli/tui/widgets/status_bar.py @@ -126,6 +126,7 @@ def __init__(self, **kwargs): """Initialize status bar.""" super().__init__(**kwargs) self._text = "" + self._agent_name: str | None = None self._severity = "info" self._show_all = False self._allow_tweak = False @@ -153,9 +154,11 @@ def _rebuild_content(self) -> None: container.remove_children() if self.mode == "notification": - container.mount(Static(self._text, classes=f"notification-text {self._severity}")) + display_text = f"({self._agent_name}) {self._text}" if self._agent_name else self._text + container.mount(Static(display_text, classes=f"notification-text {self._severity}")) elif self.mode == "confirm": - container.mount(Static(self._text, classes="confirm-question")) + display_text = f"({self._agent_name}) {self._text}" if self._agent_name else self._text + container.mount(Static(display_text, classes="confirm-question")) hints = Horizontal(classes="confirm-hints") container.mount(hints) hints.mount(Static("\\[y]es", classes="hint hint-yes")) @@ -169,7 +172,11 @@ def _rebuild_content(self) -> None: hints.mount(Static("\\[d]on't ask again", classes="hint hint-never")) def show_notification( - self, text: str, severity: str = "info", timeout: float | None = 3.0 + self, + text: str, + severity: str = "info", + timeout: float | None = 3.0, + agent_name: str | None = None, ) -> None: """Show a transient notification message. @@ -177,6 +184,7 @@ def show_notification( text: Message to display severity: One of "info", "warning", "error", "success" timeout: Auto-dismiss after this many seconds (None = no auto-dismiss) + agent_name: Optional agent name to prefix the message with """ # Cancel any existing timer if self._timer: @@ -184,6 +192,7 @@ def show_notification( self._timer = None self._text = text + self._agent_name = agent_name self._severity = severity self.mode = "notification" self._rebuild_content() @@ -199,6 +208,7 @@ def show_confirm( allow_never: bool = False, default: str = "y", explicit_yes_required: bool = False, + agent_name: str | None = None, ) -> None: """Show a confirmation prompt. @@ -209,6 +219,7 @@ def show_confirm( allow_never: Whether to show "don't ask again" option default: Default response ("y" or "n") explicit_yes_required: Whether explicit yes is required + agent_name: Optional agent name to prefix the question with """ # Cancel any existing timer if self._timer: @@ -216,6 +227,7 @@ def show_confirm( self._timer = None self._text = question + self._agent_name = agent_name self._show_all = show_all self._allow_tweak = allow_tweak self._allow_never = allow_never diff --git a/cecli/utils.py b/cecli/utils.py index aac9b20b597..b8a009c07eb 100644 --- a/cecli/utils.py +++ b/cecli/utils.py @@ -2,14 +2,13 @@ import json import os import platform +import shlex import shutil import subprocess import sys import tempfile from pathlib import Path -import oslex - from cecli.dump import dump # noqa: F401 from cecli.waiting import Spinner @@ -437,7 +436,7 @@ def printable_shell_command(cmd_list): Returns: str: Shell-escaped command string. """ - return oslex.join(cmd_list) + return shlex.join(cmd_list) def split_concatenated_json(s: str) -> list[str]: diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py index e6244d87cf2..5d008b93ad3 100644 --- a/tests/tui/test_app.py +++ b/tests/tui/test_app.py @@ -31,7 +31,211 @@ def test_on_mouse_move_linux(tui_instance): """ Test that on_mouse_move does not stop the event on Linux. """ - with patch("platform.system", return_value="Linux"): + with patch("cecli.tui.app.IS_WINDOWS", False): mock_event = MagicMock(spec=events.MouseMove) tui_instance.on_mouse_move(mock_event) mock_event.stop.assert_not_called() + + +def test_handle_output_message_spinner_with_agent_name(tui_instance, monkeypatch): + """ + Test that spinner status messages display the agent name prefix + when a sub-agent is active. + """ + # Mock query_one to return mock widgets for all lookup types + mock_footer = MagicMock() + mock_footer.spinner_suffix = "" + mock_status_bar = MagicMock() + mock_input_area = MagicMock() + mock_input_container = MagicMock() + mock_output_container = MagicMock() + + def mock_query_one(selector, *args): + # query_one may be called with class or string selector + if isinstance(selector, type): + name = selector.__name__ + else: + # String selector - could be CSS like "#input, InputArea" + if "," in selector or "#" in selector: + return mock_input_area + name = "MainFooter" # Default fallback for footer lookup + + mapping = { + "MainFooter": mock_footer, + "StatusBar": mock_status_bar, + "InputContainer": mock_input_container, + "InputArea": mock_input_area, + "OutputContainer": mock_output_container, + } + return mapping.get(name, mock_footer) + + tui_instance.query_one = mock_query_one + + # Mock coder worker for agent service lookups + mock_coder = MagicMock() + mock_coder.uuid = "primary_uuid" + tui_instance.worker = MagicMock() + tui_instance.worker.coder = mock_coder + + # Mock AgentService so _resolve_agent_name works + mock_agent_service = MagicMock() + mock_agent_info = MagicMock() + mock_agent_info.name = "researcher" + mock_agent_info.coder = MagicMock() + mock_agent_info.coder.uuid = "some_uuid" + mock_agent_service.sub_agents = {"some_uuid": mock_agent_info} + mock_agent_service.coder = mock_coder + + monkeypatch.setattr( + "cecli.helpers.agents.service.AgentService.get_instance", + lambda *args: mock_agent_service, + ) + + # Test: sub-agent spinner should include agent_name="researcher" + msg = { + "type": "spinner", + "action": "start", + "text": "Thinking...", + "coder_uuid": "some_uuid", + } + tui_instance.handle_output_message(msg) + mock_footer.start_spinner.assert_called_once_with("Thinking...", agent_name="researcher") + + # Test: primary agent spinner should have agent_name=None + mock_footer.reset_mock() + msg["coder_uuid"] = "primary_uuid" + tui_instance.handle_output_message(msg) + mock_footer.start_spinner.assert_called_once_with("Thinking...", agent_name=None) + + +def test_handle_output_message_confirmation_with_agent_name(tui_instance, monkeypatch): + """ + Test that confirmation status messages display the agent name prefix. + """ + mock_footer = MagicMock() + mock_footer.spinner_suffix = "" + mock_status_bar = MagicMock() + mock_input_area = MagicMock() + mock_input_container = MagicMock() + mock_output_container = MagicMock() + + def mock_query_one(selector, *args): + if isinstance(selector, type): + name = selector.__name__ + else: + if selector == "#input" or selector == "#input, InputArea": + return mock_input_area + elif selector == "#status-bar" or selector == "#status-bar, StatusBar": + return mock_status_bar + name = "MainFooter" # Default fallback + + mapping = { + "MainFooter": mock_footer, + "StatusBar": mock_status_bar, + "InputContainer": mock_input_container, + "InputArea": mock_input_area, + "OutputContainer": mock_output_container, + } + return mapping.get(name, mock_footer) + + tui_instance.query_one = mock_query_one + + # Mock coder worker for agent service lookups + mock_coder = MagicMock() + mock_coder.uuid = "primary_uuid" + tui_instance.worker = MagicMock() + tui_instance.worker.coder = mock_coder + + # Stub status_bar reference + tui_instance.status_bar = mock_status_bar + + # Mock AgentService + mock_agent_service = MagicMock() + mock_agent_info = MagicMock() + mock_agent_info.name = "researcher" + mock_agent_info.coder = MagicMock() + mock_agent_info.coder.uuid = "some_uuid" + mock_agent_service.sub_agents = {"some_uuid": mock_agent_info} + mock_agent_service.coder = mock_coder + + monkeypatch.setattr( + "cecli.helpers.agents.service.AgentService.get_instance", + lambda *args: mock_agent_service, + ) + + # Test: sub-agent confirmation should include agent_name="researcher" + msg = { + "type": "confirmation", + "question": "Are you sure?", + "options": {}, + "coder_uuid": "some_uuid", + } + tui_instance.handle_output_message(msg) + mock_status_bar.show_confirm.assert_called_once_with( + "Are you sure?", + show_all=False, + allow_tweak=False, + allow_never=False, + default="y", + explicit_yes_required=False, + agent_name="researcher", + ) + + +def test_handle_output_message_error_with_agent_name(tui_instance, monkeypatch): + """ + Test that error status messages display the agent name prefix. + """ + mock_footer = MagicMock() + mock_footer.spinner_suffix = "" + mock_status_bar = MagicMock() + mock_input_area = MagicMock() + mock_input_container = MagicMock() + mock_output_container = MagicMock() + + def mock_query_one(selector, *args): + if isinstance(selector, type): + name = selector.__name__ + else: + if "," in selector or "#" in selector: + return mock_input_area + return mock_footer + mapping = { + "MainFooter": mock_footer, + "StatusBar": mock_status_bar, + "InputContainer": mock_input_container, + "InputArea": mock_input_area, + "OutputContainer": mock_output_container, + } + return mapping.get(name, mock_footer) + + tui_instance.query_one = mock_query_one + + # Mock coder worker for agent service lookups + mock_coder = MagicMock() + mock_coder.uuid = "primary_uuid" + tui_instance.worker = MagicMock() + tui_instance.worker.coder = mock_coder + + # Stub status_bar reference + tui_instance.status_bar = mock_status_bar + + # Mock AgentService - unknown UUID should return None (no prefix) + monkeypatch.setattr( + "cecli.helpers.agents.service.AgentService.get_instance", + lambda *args: MagicMock(sub_agents={}, coder=mock_coder), + ) + + # Test: error message for unknown agent should have agent_name=None + msg = { + "type": "error", + "message": "Something went wrong!", + "coder_uuid": "unknown_uuid", + } + tui_instance.handle_output_message(msg) + mock_status_bar.show_notification.assert_called_once_with( + "Something went wrong!", + severity="error", + timeout=5, + agent_name=None, + )