Skip to content

Commit 2fe2d46

Browse files
feat(tools): mirror execute results to tool_output for headless UI
Emit tool return text via emit_execute_result_to_ui so BrightVision and other headless consumers show Grep/ExploreCode output in tool cards. Sync and async local tools hook through BaseTool and AgentCoder; ls/Command opt out when they already stream output during execute. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fdc90fc commit 2fe2d46

7 files changed

Lines changed: 104 additions & 24 deletions

File tree

cecli/coders/agent_coder.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,10 @@ async def gather_and_await():
771771
all_results_content.append(f"Error in tool execution: {res}")
772772
else:
773773
all_results_content.append(str(res))
774+
if not getattr(tool_module, "SKIP_EXECUTE_RESULT_UI", False):
775+
from cecli.tools.utils.output import emit_execute_result_to_ui
776+
777+
emit_execute_result_to_ui(self, res)
774778

775779
if not await HookIntegration.call_post_tool_hooks(
776780
self, tool_name, args_string, "\n\n".join(all_results_content)

cecli/tools/command.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
class Tool(BaseTool):
1515
NORM_NAME = "command"
1616
TRACK_INVOCATIONS = False
17+
SKIP_EXECUTE_RESULT_UI = True
1718
SCHEMA = {
1819
"type": "function",
1920
"function": {

cecli/tools/grep.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -203,28 +203,6 @@ def execute(
203203

204204
final_message = "\n\n".join(all_results)
205205

206-
if coder.tui and coder.tui():
207-
# For the UI, show a summary to avoid cluttering the terminal
208-
ui_summaries = []
209-
for search_op, result in zip(searches, all_results):
210-
pattern = search_op.get("pattern")
211-
if "No matches found" in result:
212-
ui_summaries.append(f"✗ No matches found for '{pattern}'.")
213-
elif "Error" in result:
214-
ui_summaries.append(f"✗ Error searching for '{pattern}'.")
215-
else:
216-
# Count lines in the output to give a sense of scale
217-
# The result string contains the matches in a code block
218-
match_count = (
219-
result.count("\n") - 2
220-
) # Subtracting for the markdown block markers
221-
if match_count < 0:
222-
match_count = 0
223-
ui_summaries.append(f"✓ Matches found for '{pattern}'.")
224-
225-
ui_message = "\n".join(ui_summaries)
226-
coder.io.tool_output(ui_message, type="tool-result")
227-
228206
return final_message
229207

230208
@classmethod

cecli/tools/ls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
class Tool(BaseTool):
1010
NORM_NAME = "ls"
1111
TRACK_INVOCATIONS = False
12+
SKIP_EXECUTE_RESULT_UI = True
1213
SCHEMA = {
1314
"type": "function",
1415
"function": {

cecli/tools/utils/base_tool.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from abc import ABC, abstractmethod
22

3+
import asyncio
4+
35
from cecli.tools.utils.helpers import handle_tool_error
4-
from cecli.tools.utils.output import print_tool_response
6+
from cecli.tools.utils.output import emit_execute_result_to_ui, print_tool_response
57
from cecli.tools.validations import ToolValidations
68

79

@@ -16,6 +18,9 @@ class BaseTool(ABC):
1618
# Declarative validations (maps param paths to lists of validation method names)
1719
VALIDATIONS = {}
1820

21+
# When True, execute() already streamed full output via tool_output (skip UI mirror).
22+
SKIP_EXECUTE_RESULT_UI = False
23+
1924
# Invocation tracking for detecting repeated tool calls
2025
_invocations = {} # Dict to store last 3 invocations per tool
2126
_invocation_summary = set() # Set to track distinct tool names
@@ -133,7 +138,10 @@ def process_response(cls, coder, params):
133138
params = ToolValidations.validate_params(params, cls.VALIDATIONS, cls.SCHEMA)
134139

135140
try:
136-
return cls.execute(coder, **params)
141+
result = cls.execute(coder, **params)
142+
if not asyncio.iscoroutine(result) and not cls.SKIP_EXECUTE_RESULT_UI:
143+
emit_execute_result_to_ui(coder, result)
144+
return result
137145
except Exception as e:
138146
return handle_tool_error(coder, cls.SCHEMA.get("function").get("name"), e)
139147

cecli/tools/utils/output.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22
import re
33

44

5+
def emit_execute_result_to_ui(coder, result, *, max_lines: int = 200) -> None:
6+
"""
7+
Mirror tool ``execute()`` return text to ``tool_output`` for headless UI consumers
8+
(e.g. BrightVision chat tool cards). Skips Textual TUI sessions.
9+
"""
10+
if coder.tui and coder.tui():
11+
return
12+
text = ("" if result is None else str(result)).strip()
13+
if not text:
14+
return
15+
if text.startswith("Error:") and "\n" not in text and len(text) < 400:
16+
return
17+
lines = text.splitlines()
18+
if len(lines) > max_lines:
19+
body = "\n".join(lines[:max_lines])
20+
body += f"\n… ({len(lines) - max_lines} more lines)"
21+
else:
22+
body = text
23+
coder.io.tool_output(body)
24+
25+
526
def print_tool_response(coder, mcp_server, tool_response, params=None):
627
"""
728
Format the output for display.

tests/tools/test_tool_result_ui.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import Mock
3+
4+
from cecli.tools.utils.output import emit_execute_result_to_ui
5+
6+
7+
def test_emit_execute_result_to_ui_skips_tui():
8+
io = Mock()
9+
coder = SimpleNamespace(tui=lambda: object(), io=io)
10+
emit_execute_result_to_ui(coder, "hello")
11+
io.tool_output.assert_not_called()
12+
13+
14+
def test_emit_execute_result_to_ui_emits_body_for_headless():
15+
io = Mock()
16+
coder = SimpleNamespace(tui=lambda: None, io=io)
17+
emit_execute_result_to_ui(coder, "line one\nline two")
18+
io.tool_output.assert_called_once_with("line one\nline two")
19+
20+
21+
def test_emit_execute_result_to_ui_truncates_long_output():
22+
io = Mock()
23+
coder = SimpleNamespace(tui=lambda: None, io=io)
24+
long_text = "\n".join(f"line {i}" for i in range(250))
25+
emit_execute_result_to_ui(coder, long_text, max_lines=200)
26+
emitted = io.tool_output.call_args[0][0]
27+
assert emitted.startswith("line 0")
28+
assert "… (50 more lines)" in emitted
29+
30+
31+
def test_grep_process_response_emits_matches_to_ui(monkeypatch, tmp_path):
32+
from cecli.tools import grep
33+
34+
sample = tmp_path / "example.txt"
35+
sample.write_text("hello ollama world\n")
36+
37+
io = Mock()
38+
coder = SimpleNamespace(
39+
repo=SimpleNamespace(root=str(tmp_path)),
40+
io=io,
41+
verbose=False,
42+
root=str(tmp_path),
43+
tui=lambda: None,
44+
)
45+
46+
monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("grep", "/usr/bin/grep"))
47+
48+
result = grep.Tool.process_response(
49+
coder,
50+
{
51+
"searches": [
52+
{
53+
"pattern": "ollama",
54+
"file_glob": "*.txt",
55+
"directory": ".",
56+
"use_regex": False,
57+
"case_insensitive": False,
58+
"context_before": 0,
59+
"context_after": 0,
60+
}
61+
]
62+
},
63+
)
64+
65+
assert "Matches for" in result
66+
emitted = [str(call.args[0]) for call in io.tool_output.call_args_list]
67+
assert any("ollama" in line for line in emitted)

0 commit comments

Comments
 (0)