diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 91ab4d7cdc6..1c80a691827 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -771,6 +771,12 @@ async def gather_and_await(): all_results_content.append(f"Error in tool execution: {res}") else: all_results_content.append(str(res)) + if not getattr(tool_module, "SKIP_EXECUTE_RESULT_UI", False): + from cecli.tools.utils.output import ( + emit_execute_result_to_ui, + ) + + emit_execute_result_to_ui(self, res) if not await HookIntegration.call_post_tool_hooks( self, tool_name, args_string, "\n\n".join(all_results_content) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index f09d32f2cdf..bb7eab11749 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -14,6 +14,7 @@ class Tool(BaseTool): NORM_NAME = "command" TRACK_INVOCATIONS = False + SKIP_EXECUTE_RESULT_UI = True SCHEMA = { "type": "function", "function": { diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index c519cdd8d97..b99381ba0f1 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -203,28 +203,6 @@ def execute( final_message = "\n\n".join(all_results) - if coder.tui and coder.tui(): - # For the UI, show a summary to avoid cluttering the terminal - ui_summaries = [] - for search_op, result in zip(searches, all_results): - pattern = search_op.get("pattern") - if "No matches found" in result: - ui_summaries.append(f"✗ No matches found for '{pattern}'.") - elif "Error" in result: - ui_summaries.append(f"✗ Error searching for '{pattern}'.") - else: - # Count lines in the output to give a sense of scale - # The result string contains the matches in a code block - match_count = ( - result.count("\n") - 2 - ) # Subtracting for the markdown block markers - if match_count < 0: - match_count = 0 - ui_summaries.append(f"✓ Matches found for '{pattern}'.") - - ui_message = "\n".join(ui_summaries) - coder.io.tool_output(ui_message, type="tool-result") - return final_message @classmethod diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 89c5b791b5f..ea1752ae85a 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -9,6 +9,7 @@ class Tool(BaseTool): NORM_NAME = "ls" TRACK_INVOCATIONS = False + SKIP_EXECUTE_RESULT_UI = True SCHEMA = { "type": "function", "function": { diff --git a/cecli/tools/utils/base_tool.py b/cecli/tools/utils/base_tool.py index a9fb39c709a..7e661efcde2 100644 --- a/cecli/tools/utils/base_tool.py +++ b/cecli/tools/utils/base_tool.py @@ -1,7 +1,8 @@ +import asyncio from abc import ABC, abstractmethod from cecli.tools.utils.helpers import handle_tool_error -from cecli.tools.utils.output import print_tool_response +from cecli.tools.utils.output import emit_execute_result_to_ui, print_tool_response from cecli.tools.validations import ToolValidations @@ -16,6 +17,9 @@ class BaseTool(ABC): # Declarative validations (maps param paths to lists of validation method names) VALIDATIONS = {} + # When True, execute() already streamed full output via tool_output (skip UI mirror). + SKIP_EXECUTE_RESULT_UI = False + # Invocation tracking for detecting repeated tool calls _invocations = {} # Dict to store last 3 invocations per tool _invocation_summary = set() # Set to track distinct tool names @@ -133,7 +137,10 @@ def process_response(cls, coder, params): params = ToolValidations.validate_params(params, cls.VALIDATIONS, cls.SCHEMA) try: - return cls.execute(coder, **params) + result = cls.execute(coder, **params) + if not asyncio.iscoroutine(result) and not cls.SKIP_EXECUTE_RESULT_UI: + emit_execute_result_to_ui(coder, result) + return result except Exception as e: return handle_tool_error(coder, cls.SCHEMA.get("function").get("name"), e) diff --git a/cecli/tools/utils/output.py b/cecli/tools/utils/output.py index 67d51011466..4fe03042624 100644 --- a/cecli/tools/utils/output.py +++ b/cecli/tools/utils/output.py @@ -2,6 +2,27 @@ import re +def emit_execute_result_to_ui(coder, result, *, max_lines: int = 200) -> None: + """ + Mirror tool ``execute()`` return text to ``tool_output`` for headless UI consumers + (e.g. BrightVision chat tool cards). Skips Textual TUI sessions. + """ + if coder.tui and coder.tui(): + return + text = ("" if result is None else str(result)).strip() + if not text: + return + if text.startswith("Error:") and "\n" not in text and len(text) < 400: + return + lines = text.splitlines() + if len(lines) > max_lines: + body = "\n".join(lines[:max_lines]) + body += f"\n… ({len(lines) - max_lines} more lines)" + else: + body = text + coder.io.tool_output(body) + + def print_tool_response(coder, mcp_server, tool_response, params=None): """ Format the output for display. diff --git a/tests/tools/test_tool_result_ui.py b/tests/tools/test_tool_result_ui.py new file mode 100644 index 00000000000..601ef2c06f4 --- /dev/null +++ b/tests/tools/test_tool_result_ui.py @@ -0,0 +1,67 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from cecli.tools.utils.output import emit_execute_result_to_ui + + +def test_emit_execute_result_to_ui_skips_tui(): + io = Mock() + coder = SimpleNamespace(tui=lambda: object(), io=io) + emit_execute_result_to_ui(coder, "hello") + io.tool_output.assert_not_called() + + +def test_emit_execute_result_to_ui_emits_body_for_headless(): + io = Mock() + coder = SimpleNamespace(tui=lambda: None, io=io) + emit_execute_result_to_ui(coder, "line one\nline two") + io.tool_output.assert_called_once_with("line one\nline two") + + +def test_emit_execute_result_to_ui_truncates_long_output(): + io = Mock() + coder = SimpleNamespace(tui=lambda: None, io=io) + long_text = "\n".join(f"line {i}" for i in range(250)) + emit_execute_result_to_ui(coder, long_text, max_lines=200) + emitted = io.tool_output.call_args[0][0] + assert emitted.startswith("line 0") + assert "… (50 more lines)" in emitted + + +def test_grep_process_response_emits_matches_to_ui(monkeypatch, tmp_path): + from cecli.tools import grep + + sample = tmp_path / "example.txt" + sample.write_text("hello ollama world\n") + + io = Mock() + coder = SimpleNamespace( + repo=SimpleNamespace(root=str(tmp_path)), + io=io, + verbose=False, + root=str(tmp_path), + tui=lambda: None, + ) + + monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("grep", "/usr/bin/grep")) + + result = grep.Tool.process_response( + coder, + { + "searches": [ + { + "pattern": "ollama", + "file_glob": "*.txt", + "directory": ".", + "use_regex": False, + "case_insensitive": False, + "context_before": 0, + "context_after": 0, + } + ] + }, + ) + + assert "Matches for" in result + emitted = [str(call.args[0]) for call in io.tool_output.call_args_list] + assert any("ollama" in line for line in emitted)