bone-agent 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/npm-wrapper.js +235 -0
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +133 -0
- package/package.json +53 -0
- package/requirements.txt +9 -0
- package/src/__init__.py +11 -0
- package/src/core/__init__.py +1 -0
- package/src/core/agentic.py +1054 -0
- package/src/core/chat_manager.py +1552 -0
- package/src/core/config_manager.py +247 -0
- package/src/core/cron.py +527 -0
- package/src/core/cron_allowlist.py +118 -0
- package/src/core/memory.py +232 -0
- package/src/core/retry.py +71 -0
- package/src/core/sub_agent.py +326 -0
- package/src/core/tool_approval.py +220 -0
- package/src/core/tool_feedback.py +778 -0
- package/src/exceptions.py +79 -0
- package/src/llm/__init__.py +1 -0
- package/src/llm/client.py +171 -0
- package/src/llm/config.py +466 -0
- package/src/llm/prompts.py +735 -0
- package/src/llm/providers.py +417 -0
- package/src/llm/streaming.py +163 -0
- package/src/llm/token_tracker.py +368 -0
- package/src/tools/__init__.py +212 -0
- package/src/tools/constants.py +59 -0
- package/src/tools/create_file.py +136 -0
- package/src/tools/directory.py +389 -0
- package/src/tools/edit.py +543 -0
- package/src/tools/file_reader.py +322 -0
- package/src/tools/helpers/__init__.py +105 -0
- package/src/tools/helpers/base.py +550 -0
- package/src/tools/helpers/converters.py +44 -0
- package/src/tools/helpers/file_helpers.py +189 -0
- package/src/tools/helpers/formatters.py +411 -0
- package/src/tools/helpers/loader.py +231 -0
- package/src/tools/helpers/parallel_executor.py +231 -0
- package/src/tools/helpers/path_resolver.py +226 -0
- package/src/tools/helpers/plugin_manifest.py +156 -0
- package/src/tools/obsidian.py +96 -0
- package/src/tools/review_sub_agent.py +189 -0
- package/src/tools/rg_search.py +393 -0
- package/src/tools/search_plugins.py +109 -0
- package/src/tools/select_option.py +593 -0
- package/src/tools/shell.py +302 -0
- package/src/tools/sub_agent.py +139 -0
- package/src/tools/task_list.py +269 -0
- package/src/tools/web_search.py +61 -0
- package/src/ui/__init__.py +1 -0
- package/src/ui/banner.py +87 -0
- package/src/ui/commands.py +2694 -0
- package/src/ui/displays.py +213 -0
- package/src/ui/loader.py +284 -0
- package/src/ui/main.py +646 -0
- package/src/ui/prompt_utils.py +113 -0
- package/src/ui/setting_selector.py +590 -0
- package/src/ui/setup_wizard.py +294 -0
- package/src/ui/sub_agent_panel.py +234 -0
- package/src/ui/tool_confirmation.py +215 -0
- package/src/utils/__init__.py +1 -0
- package/src/utils/citation_parser.py +199 -0
- package/src/utils/editor.py +158 -0
- package/src/utils/gitignore_filter.py +149 -0
- package/src/utils/logger.py +254 -0
- package/src/utils/paths.py +30 -0
- package/src/utils/result_parsers.py +108 -0
- package/src/utils/safe_commands.py +243 -0
- package/src/utils/settings.py +174 -0
- package/src/utils/validation.py +191 -0
- package/src/utils/web_search.py +173 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
"""Tool result display functions for the agentic loop."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from rich.syntax import Syntax
|
|
8
|
+
|
|
9
|
+
from utils.settings import MAX_COMMAND_OUTPUT_LINES, MonokaiDarkBGStyle
|
|
10
|
+
from utils.result_parsers import extract_exit_code, extract_all_metadata, extract_multiple_metadata
|
|
11
|
+
from tools.task_list import _format_task_list, _strip_rich_markup
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Helpers
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def vault_root_str() -> Optional[str]:
|
|
19
|
+
"""Return vault_root from the active VaultSession, or None."""
|
|
20
|
+
try:
|
|
21
|
+
from tools.obsidian import get_vault_session
|
|
22
|
+
session = get_vault_session()
|
|
23
|
+
return str(session.vault_root) if session else None
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _print_or_append(text, console, panel_updater, markup=True):
|
|
29
|
+
"""Print text to console or append to panel_updater.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
text: Text to display
|
|
33
|
+
console: Rich console
|
|
34
|
+
panel_updater: Optional SubAgentPanel for live updates
|
|
35
|
+
markup: If True, parse Rich markup (only used for console)
|
|
36
|
+
"""
|
|
37
|
+
if panel_updater:
|
|
38
|
+
panel_updater.append(text)
|
|
39
|
+
else:
|
|
40
|
+
console.print(text, markup=markup)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Constants
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
# File extension to Pygments lexer name mapping for syntax highlighting
|
|
48
|
+
LEXER_MAP = {
|
|
49
|
+
'py': 'python',
|
|
50
|
+
'js': 'javascript',
|
|
51
|
+
'ts': 'typescript',
|
|
52
|
+
'tsx': 'typescript',
|
|
53
|
+
'jsx': 'javascript',
|
|
54
|
+
'go': 'go',
|
|
55
|
+
'rs': 'rust',
|
|
56
|
+
'java': 'java',
|
|
57
|
+
'c': 'c',
|
|
58
|
+
'cpp': 'cpp',
|
|
59
|
+
'h': 'c',
|
|
60
|
+
'hpp': 'cpp',
|
|
61
|
+
'sh': 'bash',
|
|
62
|
+
'bash': 'bash',
|
|
63
|
+
'zsh': 'bash',
|
|
64
|
+
'yaml': 'yaml',
|
|
65
|
+
'yml': 'yaml',
|
|
66
|
+
'json': 'json',
|
|
67
|
+
'toml': 'toml',
|
|
68
|
+
'md': 'markdown',
|
|
69
|
+
'html': 'html',
|
|
70
|
+
'css': 'css',
|
|
71
|
+
'sql': 'sql',
|
|
72
|
+
'php': 'php',
|
|
73
|
+
'rb': 'ruby',
|
|
74
|
+
'swift': 'swift',
|
|
75
|
+
'kt': 'kotlin',
|
|
76
|
+
'scala': 'scala',
|
|
77
|
+
'lua': 'lua',
|
|
78
|
+
'r': 'r',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Label builders
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def strip_leading_task_list_echo(content, task_list, title=None):
|
|
87
|
+
"""Remove a leading echoed task list from assistant content.
|
|
88
|
+
|
|
89
|
+
Some models copy the task list tool output into the final response, which
|
|
90
|
+
causes duplicate task list rendering in the CLI.
|
|
91
|
+
"""
|
|
92
|
+
if not content or not isinstance(content, str) or not task_list:
|
|
93
|
+
return content or ""
|
|
94
|
+
|
|
95
|
+
expected = _strip_rich_markup(_format_task_list(task_list, title)).strip()
|
|
96
|
+
if not expected:
|
|
97
|
+
return content
|
|
98
|
+
|
|
99
|
+
trimmed = content.lstrip()
|
|
100
|
+
if trimmed.startswith(expected):
|
|
101
|
+
remainder = trimmed[len(expected):]
|
|
102
|
+
return remainder.lstrip("\n").lstrip()
|
|
103
|
+
|
|
104
|
+
return content
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_read_file_label(path, start_line=None, max_lines=None, with_colon=False):
|
|
108
|
+
"""Build uniform read_file label.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
path: File path
|
|
112
|
+
start_line: Optional starting line number (unused in display)
|
|
113
|
+
max_lines: Optional max lines to read (unused in display)
|
|
114
|
+
with_colon: If True, use 'read_file: path' format (for batch mode labels)
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
str: Formatted label
|
|
118
|
+
"""
|
|
119
|
+
separator = ': ' if with_colon else ' '
|
|
120
|
+
label = f"read_file{separator}{path}"
|
|
121
|
+
return label
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_tool_label(function_name, arguments):
|
|
125
|
+
"""Build tool label with arguments for display.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
function_name: Name of the tool function
|
|
129
|
+
arguments: Dictionary of tool arguments
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
str: Formatted label with arguments (e.g., "read_file: path/to/file", "rg: search_pattern")
|
|
133
|
+
"""
|
|
134
|
+
if function_name == "rg":
|
|
135
|
+
pattern = arguments.get('pattern', '')
|
|
136
|
+
# Truncate long patterns for display
|
|
137
|
+
return f"rg: {pattern[:40]}" if pattern else "rg"
|
|
138
|
+
elif function_name == "read_file":
|
|
139
|
+
path = arguments.get('path_str', '')
|
|
140
|
+
return build_read_file_label(path, with_colon=True)
|
|
141
|
+
elif function_name == "list_directory":
|
|
142
|
+
path = arguments.get('path_str', '')
|
|
143
|
+
return f"list_directory: {path}"
|
|
144
|
+
elif function_name == "create_file":
|
|
145
|
+
path = arguments.get('path_str', '')
|
|
146
|
+
return f"create_file: {path}"
|
|
147
|
+
elif function_name == "edit_file":
|
|
148
|
+
path = arguments.get('path', '')
|
|
149
|
+
return f"edit_file: {path}"
|
|
150
|
+
elif function_name == "web_search":
|
|
151
|
+
query = arguments.get('query', '')
|
|
152
|
+
return f"web search | {query}"
|
|
153
|
+
elif function_name == "execute_command":
|
|
154
|
+
command = arguments.get('command', '')
|
|
155
|
+
# Truncate long commands for display
|
|
156
|
+
return f"execute_command: {command[:80]}" if command else "execute_command"
|
|
157
|
+
else:
|
|
158
|
+
return function_name
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Tool-specific feedback handlers
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def handle_create_file_feedback(tool_result, console, panel_updater):
|
|
166
|
+
"""Handle feedback for create_file tool.
|
|
167
|
+
|
|
168
|
+
Display syntax-highlighted file preview.
|
|
169
|
+
"""
|
|
170
|
+
lines = tool_result.split('\n')
|
|
171
|
+
# Extract path from metadata
|
|
172
|
+
path_match = re.search(r'path=([^\s]+)', tool_result)
|
|
173
|
+
path_str = path_match.group(1) if path_match else "file"
|
|
174
|
+
|
|
175
|
+
# Find file content section
|
|
176
|
+
content_start = None
|
|
177
|
+
content_end = None
|
|
178
|
+
for i, line in enumerate(lines):
|
|
179
|
+
if line.startswith("=== FILE_CONTENT ==="):
|
|
180
|
+
content_start = i + 1
|
|
181
|
+
elif line.startswith("=== END_FILE_CONTENT ===") and content_start is not None:
|
|
182
|
+
content_end = i
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
# Display summary and syntax-highlighted content
|
|
186
|
+
if content_start is not None and content_end is not None:
|
|
187
|
+
content_lines = lines[content_start:content_end]
|
|
188
|
+
content = "\n".join(content_lines)
|
|
189
|
+
|
|
190
|
+
# Get file extension for syntax highlighting
|
|
191
|
+
file_ext = Path(path_str).suffix[1:] if Path(path_str).suffix else "text"
|
|
192
|
+
lexer_name = LEXER_MAP.get(file_ext.lower(), 'text')
|
|
193
|
+
|
|
194
|
+
# Create syntax object
|
|
195
|
+
syntax = Syntax(
|
|
196
|
+
content,
|
|
197
|
+
lexer_name,
|
|
198
|
+
theme=MonokaiDarkBGStyle,
|
|
199
|
+
line_numbers=True,
|
|
200
|
+
word_wrap=False
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Show with prefix for console
|
|
204
|
+
if panel_updater:
|
|
205
|
+
panel_updater.append(f"Created: {path_str}")
|
|
206
|
+
panel_updater.append(str(syntax))
|
|
207
|
+
else:
|
|
208
|
+
console.print(f"Created: {path_str}", markup=False)
|
|
209
|
+
console.print(syntax)
|
|
210
|
+
else:
|
|
211
|
+
# Fallback: just show path
|
|
212
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
213
|
+
message = f"{prefix}Created: {path_str}"
|
|
214
|
+
_print_or_append(message, console, panel_updater)
|
|
215
|
+
|
|
216
|
+
if not panel_updater:
|
|
217
|
+
console.print()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def handle_list_directory_feedback(tool_result, console, panel_updater):
|
|
221
|
+
"""Handle feedback for list_directory tool.
|
|
222
|
+
|
|
223
|
+
Display formatted directory tree with files and directories.
|
|
224
|
+
"""
|
|
225
|
+
lines = tool_result.split('\n')
|
|
226
|
+
# Extract items_count from metadata
|
|
227
|
+
items_count = 0
|
|
228
|
+
for line in lines:
|
|
229
|
+
match = re.search(r'items_count=(\d+)', line)
|
|
230
|
+
if match:
|
|
231
|
+
items_count = int(match.group(1))
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
# Parse content lines (skip metadata lines)
|
|
235
|
+
content_start = None
|
|
236
|
+
for i, line in enumerate(lines):
|
|
237
|
+
if line.startswith("FILE") or line.startswith("DIR"):
|
|
238
|
+
content_start = i
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
if content_start is not None and items_count > 0:
|
|
242
|
+
content_lines = lines[content_start:]
|
|
243
|
+
|
|
244
|
+
# Parse entries: kind, path, line_count, size
|
|
245
|
+
# Format: FILE path/to/file.py 123 lines 12345 bytes
|
|
246
|
+
# DIR path/to/dir/ 0 lines
|
|
247
|
+
entries = []
|
|
248
|
+
for line in content_lines:
|
|
249
|
+
# Use regex to extract parts - handles paths with spaces
|
|
250
|
+
# Pattern: <KIND> <path> <line_count> lines [<size> bytes]
|
|
251
|
+
file_match = re.match(r'^(FILE|DIR)\s+(.+?)\s+(\d+)\s+lines(?:\s+(\d+)\s+bytes)?$', line)
|
|
252
|
+
if file_match:
|
|
253
|
+
kind = file_match.group(1)
|
|
254
|
+
path = file_match.group(2).strip()
|
|
255
|
+
line_count = file_match.group(3)
|
|
256
|
+
size = file_match.group(4) if file_match.group(4) else None
|
|
257
|
+
|
|
258
|
+
if kind == "FILE":
|
|
259
|
+
entries.append(("FILE", path, size if size else "?"))
|
|
260
|
+
else: # DIR
|
|
261
|
+
entries.append(("DIR", path))
|
|
262
|
+
|
|
263
|
+
# Sort: directories first, then alphabetically
|
|
264
|
+
entries.sort(key=lambda x: (0 if x[0] == "DIR" else 1, x[1]))
|
|
265
|
+
|
|
266
|
+
# Build tree with truncation (max 10 items)
|
|
267
|
+
max_display = 10
|
|
268
|
+
display_entries = entries[:max_display]
|
|
269
|
+
remaining = max(0, items_count - max_display)
|
|
270
|
+
|
|
271
|
+
# Format tree lines
|
|
272
|
+
tree_lines = []
|
|
273
|
+
for i, entry in enumerate(display_entries):
|
|
274
|
+
is_last = (i == len(display_entries) - 1) and (remaining == 0)
|
|
275
|
+
# Use closing pipe (└─) for last item, otherwise middle pipe (├─)
|
|
276
|
+
connector = "└─" if is_last else "├─"
|
|
277
|
+
if entry[0] == "DIR":
|
|
278
|
+
tree_lines.append(f" {connector} {entry[1]}")
|
|
279
|
+
else: # FILE
|
|
280
|
+
size_str = f"{int(entry[2]):,}" if entry[2].isdigit() else entry[2]
|
|
281
|
+
tree_lines.append(f" {connector} {entry[1]} ({size_str} bytes)")
|
|
282
|
+
|
|
283
|
+
# Add overflow indicator if needed (always use closing pipe)
|
|
284
|
+
if remaining > 0:
|
|
285
|
+
tree_lines.append(f" └─ ... and {remaining} more")
|
|
286
|
+
|
|
287
|
+
# Build output with header
|
|
288
|
+
path_match = re.search(r'path=([^\s]+)', tool_result)
|
|
289
|
+
path_str = path_match.group(1) if path_match else "directory"
|
|
290
|
+
header = f"{path_str}/ ({items_count} item{'s' if items_count != 1 else ''})"
|
|
291
|
+
|
|
292
|
+
# Display with prefix
|
|
293
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
294
|
+
output = f"{prefix}{header}\n"
|
|
295
|
+
output += "\n".join(tree_lines)
|
|
296
|
+
|
|
297
|
+
_print_or_append(output, console, panel_updater)
|
|
298
|
+
|
|
299
|
+
if not panel_updater:
|
|
300
|
+
console.print()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def handle_search_plugins_feedback(tool_result, console, panel_updater):
|
|
304
|
+
"""Handle feedback for search_plugins tool.
|
|
305
|
+
|
|
306
|
+
Display a tree of found plugins with query and category info,
|
|
307
|
+
similar to list_directory feedback style.
|
|
308
|
+
"""
|
|
309
|
+
lines = tool_result.split('\n')
|
|
310
|
+
|
|
311
|
+
# Extract query from "Found N plugin(s) matching 'query':" line
|
|
312
|
+
query = ""
|
|
313
|
+
query_match = re.search(r"matching '([^']+)':", tool_result)
|
|
314
|
+
if query_match:
|
|
315
|
+
query = query_match.group(1)
|
|
316
|
+
|
|
317
|
+
# Parse plugin entries: - **name** [category] (status): description
|
|
318
|
+
# Tags: tag1, tag2
|
|
319
|
+
plugins = []
|
|
320
|
+
i = 0
|
|
321
|
+
while i < len(lines):
|
|
322
|
+
plugin_match = re.match(r'^- \*\*(.+?)\*\*(?:\s+\[(.+?)\])?\s+\((.+?)\):\s+(.+)$', lines[i])
|
|
323
|
+
if plugin_match:
|
|
324
|
+
name = plugin_match.group(1)
|
|
325
|
+
category = plugin_match.group(2) or ""
|
|
326
|
+
status = plugin_match.group(3)
|
|
327
|
+
description = plugin_match.group(4)
|
|
328
|
+
|
|
329
|
+
# Check next line for tags
|
|
330
|
+
tags = ""
|
|
331
|
+
if i + 1 < len(lines) and lines[i + 1].strip().startswith("Tags:"):
|
|
332
|
+
tags = lines[i + 1].strip().replace("Tags: ", "")
|
|
333
|
+
i += 1
|
|
334
|
+
|
|
335
|
+
plugins.append({
|
|
336
|
+
"name": name,
|
|
337
|
+
"category": category,
|
|
338
|
+
"status": status,
|
|
339
|
+
"description": description,
|
|
340
|
+
"tags": tags,
|
|
341
|
+
})
|
|
342
|
+
i += 1
|
|
343
|
+
|
|
344
|
+
if not plugins:
|
|
345
|
+
# No plugins found — just show the message
|
|
346
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
347
|
+
# Extract the informational message (skip exit_code line)
|
|
348
|
+
msg_lines = [l for l in lines if l.strip() and not l.startswith("exit_code=")]
|
|
349
|
+
output = prefix + "\n".join(msg_lines) if msg_lines else f"{prefix}No plugins found"
|
|
350
|
+
_print_or_append(output, console, panel_updater)
|
|
351
|
+
if not panel_updater:
|
|
352
|
+
console.print()
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
# Build tree display
|
|
356
|
+
max_display = 10
|
|
357
|
+
display_plugins = plugins[:max_display]
|
|
358
|
+
remaining = max(0, len(plugins) - max_display)
|
|
359
|
+
|
|
360
|
+
# Count activated vs already active
|
|
361
|
+
activated_count = sum(1 for p in plugins if p["status"] == "activated")
|
|
362
|
+
already_active_count = sum(1 for p in plugins if p["status"] == "already active")
|
|
363
|
+
|
|
364
|
+
tree_lines = []
|
|
365
|
+
for i, plugin in enumerate(display_plugins):
|
|
366
|
+
is_last = (i == len(display_plugins) - 1) and (remaining == 0)
|
|
367
|
+
connector = "└─" if is_last else "├─"
|
|
368
|
+
|
|
369
|
+
# Build the plugin line: name [category] (status)
|
|
370
|
+
cat_part = f" [{plugin['category']}]" if plugin['category'] else ""
|
|
371
|
+
status_part = plugin['status']
|
|
372
|
+
|
|
373
|
+
if status_part == "activated":
|
|
374
|
+
status_display = f"[green]{status_part}[/green]"
|
|
375
|
+
else:
|
|
376
|
+
status_display = f"[dim]{status_part}[/dim]"
|
|
377
|
+
|
|
378
|
+
line = f" {connector} **{plugin['name']}**{cat_part} ({status_display}): {plugin['description']}"
|
|
379
|
+
tree_lines.append(line)
|
|
380
|
+
|
|
381
|
+
# Tags on sub-line
|
|
382
|
+
if plugin["tags"]:
|
|
383
|
+
tag_connector = "│ " if not is_last else " "
|
|
384
|
+
tree_lines.append(f"{tag_connector} Tags: [dim]{plugin['tags']}[/dim]")
|
|
385
|
+
|
|
386
|
+
# Add overflow indicator
|
|
387
|
+
if remaining > 0:
|
|
388
|
+
tree_lines.append(f" └─ ... and {remaining} more")
|
|
389
|
+
|
|
390
|
+
# Build header
|
|
391
|
+
if query:
|
|
392
|
+
header = f"plugins matching '{query}' ({len(plugins)} found)"
|
|
393
|
+
else:
|
|
394
|
+
header = f"plugins ({len(plugins)} found)"
|
|
395
|
+
|
|
396
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
397
|
+
output = f"{prefix}{header}\n"
|
|
398
|
+
output += "\n".join(tree_lines)
|
|
399
|
+
|
|
400
|
+
# Activation summary
|
|
401
|
+
if activated_count > 0 or already_active_count > 0:
|
|
402
|
+
summary_parts = []
|
|
403
|
+
if activated_count > 0:
|
|
404
|
+
summary_parts.append(f"{activated_count} activated")
|
|
405
|
+
if already_active_count > 0:
|
|
406
|
+
summary_parts.append(f"{already_active_count} already active")
|
|
407
|
+
summary = ", ".join(summary_parts)
|
|
408
|
+
output += f"\n{prefix}[dim]{summary}. Schemas available next turn. Auto-evict after 10 turns of non-use.[/dim]"
|
|
409
|
+
|
|
410
|
+
_print_or_append(output, console, panel_updater)
|
|
411
|
+
|
|
412
|
+
if not panel_updater:
|
|
413
|
+
console.print()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def handle_execute_command_feedback(tool_result, console, panel_updater):
|
|
417
|
+
"""Handle feedback for execute_command tool.
|
|
418
|
+
|
|
419
|
+
Display command output with line truncation and exit code.
|
|
420
|
+
"""
|
|
421
|
+
lines = tool_result.split('\n')
|
|
422
|
+
if lines:
|
|
423
|
+
# Extract exit code from first line
|
|
424
|
+
exit_code = extract_exit_code(tool_result)
|
|
425
|
+
|
|
426
|
+
# Get output (all lines after the exit_code line)
|
|
427
|
+
output_lines = lines[1:] if exit_code is not None else lines
|
|
428
|
+
output_lines = [line for line in output_lines if line.strip()]
|
|
429
|
+
|
|
430
|
+
# Truncate if too many lines
|
|
431
|
+
truncation_message = None
|
|
432
|
+
if len(output_lines) > MAX_COMMAND_OUTPUT_LINES:
|
|
433
|
+
displayed_lines = output_lines[:MAX_COMMAND_OUTPUT_LINES]
|
|
434
|
+
omitted = len(output_lines) - MAX_COMMAND_OUTPUT_LINES
|
|
435
|
+
output = '\n'.join(displayed_lines)
|
|
436
|
+
truncation_message = f"[dim]... ({omitted} more lines truncated)[/dim]"
|
|
437
|
+
else:
|
|
438
|
+
output = '\n'.join(output_lines)
|
|
439
|
+
|
|
440
|
+
# Build prefix
|
|
441
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
442
|
+
|
|
443
|
+
# Show output
|
|
444
|
+
if output:
|
|
445
|
+
display_text = f"{prefix}{output}"
|
|
446
|
+
_print_or_append(display_text, console, panel_updater, markup=False)
|
|
447
|
+
|
|
448
|
+
# Show truncation message separately to preserve markup
|
|
449
|
+
if truncation_message:
|
|
450
|
+
_print_or_append(truncation_message, console, panel_updater)
|
|
451
|
+
|
|
452
|
+
# Show exit code if non-zero
|
|
453
|
+
if exit_code is not None and exit_code != 0:
|
|
454
|
+
exit_text = f"[dim](exit code: {exit_code})[/dim]"
|
|
455
|
+
_print_or_append(exit_text, console, panel_updater)
|
|
456
|
+
|
|
457
|
+
if not panel_updater:
|
|
458
|
+
console.print()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
# Main display dispatcher
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
def display_tool_feedback(command, tool_result, console, indent=False, panel_updater=None):
|
|
466
|
+
"""Display user summary for read_file, rg, and list_directory.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
command: Tool command string
|
|
470
|
+
tool_result: Tool result string
|
|
471
|
+
console: Rich console
|
|
472
|
+
indent: If True, prefix with '│ ' (for sub-agent mode)
|
|
473
|
+
panel_updater: Optional SubAgentPanel for live updates
|
|
474
|
+
"""
|
|
475
|
+
if not tool_result:
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
# For sub-agent panel: add tool call with formatted message
|
|
479
|
+
if panel_updater:
|
|
480
|
+
# Extract tool name from command
|
|
481
|
+
if command.startswith("read_file"):
|
|
482
|
+
tool_name = "read_file"
|
|
483
|
+
elif command.startswith("rg"):
|
|
484
|
+
tool_name = "rg"
|
|
485
|
+
elif command.startswith("list_directory"):
|
|
486
|
+
tool_name = "list_directory"
|
|
487
|
+
elif command.startswith("search_plugins"):
|
|
488
|
+
tool_name = "search_plugins"
|
|
489
|
+
elif command.startswith(("create_task_list", "complete_task", "show_task_list")):
|
|
490
|
+
tool_name = command.split()[0]
|
|
491
|
+
elif command.startswith("web search"):
|
|
492
|
+
tool_name = "web_search"
|
|
493
|
+
elif command.startswith("execute_command"):
|
|
494
|
+
tool_name = "execute_command"
|
|
495
|
+
else:
|
|
496
|
+
tool_name = command.split()[0]
|
|
497
|
+
|
|
498
|
+
# Pass to panel updater which will handle formatting
|
|
499
|
+
panel_updater.add_tool_call(tool_name, tool_result, command)
|
|
500
|
+
|
|
501
|
+
# For task list tools: show the list (bounded by MAX_TASKS / MAX_TASK_LEN)
|
|
502
|
+
if command.startswith(("create_task_list", "complete_task", "show_task_list")):
|
|
503
|
+
exit_code = extract_exit_code(tool_result)
|
|
504
|
+
if exit_code == 0 or exit_code is None:
|
|
505
|
+
# Successful task list - display without exit_code line, with Rich markup parsing.
|
|
506
|
+
rendered = tool_result
|
|
507
|
+
if rendered.startswith("exit_code="):
|
|
508
|
+
rendered = "\n".join(rendered.splitlines()[1:])
|
|
509
|
+
_print_or_append(rendered.strip(), console, panel_updater, markup=True)
|
|
510
|
+
else:
|
|
511
|
+
# Show single-line error if present
|
|
512
|
+
first_two = "\n".join(tool_result.splitlines()[:2]).strip()
|
|
513
|
+
_print_or_append(first_two or tool_result.strip(), console, panel_updater, markup=False)
|
|
514
|
+
if not panel_updater:
|
|
515
|
+
console.print()
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
# For read_file: parse lines_read and start_line from first line
|
|
519
|
+
if command.startswith("read_file"):
|
|
520
|
+
metadata = extract_multiple_metadata(tool_result, 'lines_read', 'start_line')
|
|
521
|
+
count = metadata.get('lines_read')
|
|
522
|
+
if count is not None:
|
|
523
|
+
# Only add prefix for console, not for panel_updater
|
|
524
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
525
|
+
|
|
526
|
+
# Build message with line range if start_line is present
|
|
527
|
+
start_line = metadata.get('start_line')
|
|
528
|
+
if start_line:
|
|
529
|
+
if start_line > 1:
|
|
530
|
+
end_line = start_line + count - 1
|
|
531
|
+
message = f"{prefix}[dim]Read lines {start_line}-{end_line} ({count} line{'s' if count != 1 else ''})[/dim]"
|
|
532
|
+
else:
|
|
533
|
+
message = f"{prefix}[dim]Read {count} line{'s' if count != 1 else ''}[/dim]"
|
|
534
|
+
else:
|
|
535
|
+
message = f"{prefix}[dim]Read {count} line{'s' if count != 1 else ''}[/dim]"
|
|
536
|
+
|
|
537
|
+
_print_or_append(message, console, panel_updater)
|
|
538
|
+
if not panel_updater:
|
|
539
|
+
console.print()
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
# For rg: parse matches/files from result
|
|
543
|
+
if command.startswith("rg"):
|
|
544
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
545
|
+
message = None
|
|
546
|
+
|
|
547
|
+
# Check for "No matches found" message (0 results)
|
|
548
|
+
lines = tool_result.split('\n')
|
|
549
|
+
if any("No matches found" in line for line in lines):
|
|
550
|
+
message = f"{prefix}[dim]No matches found[/dim]"
|
|
551
|
+
# Check for matches=N or files=N pattern
|
|
552
|
+
elif len(lines) > 1:
|
|
553
|
+
metadata = extract_all_metadata(tool_result, line_index=1)
|
|
554
|
+
count = metadata.get('matches') or metadata.get('files')
|
|
555
|
+
if count is not None:
|
|
556
|
+
label = 'matches' if 'matches' in metadata else 'files'
|
|
557
|
+
if count == 0:
|
|
558
|
+
message = f"{prefix}[dim]No {label} found[/dim]"
|
|
559
|
+
else:
|
|
560
|
+
message = f"{prefix}[dim]Found {count} {label}[/dim]"
|
|
561
|
+
# Fallback: if exit_code=1 but no other info, show no matches
|
|
562
|
+
elif any("exit_code=1" in line for line in lines):
|
|
563
|
+
message = f"{prefix}[dim]No matches found[/dim]"
|
|
564
|
+
|
|
565
|
+
if message:
|
|
566
|
+
_print_or_append(message, console, panel_updater)
|
|
567
|
+
if not panel_updater:
|
|
568
|
+
console.print()
|
|
569
|
+
return
|
|
570
|
+
|
|
571
|
+
# For list_directory: parse and display directory tree
|
|
572
|
+
if command.startswith("list_directory"):
|
|
573
|
+
handle_list_directory_feedback(tool_result, console, panel_updater)
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
# For search_plugins: display tree of found plugins
|
|
577
|
+
if command.startswith("search_plugins"):
|
|
578
|
+
handle_search_plugins_feedback(tool_result, console, panel_updater)
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
# For create_file: display preview of created file
|
|
582
|
+
if command.startswith("create_file"):
|
|
583
|
+
handle_create_file_feedback(tool_result, console, panel_updater)
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
# For execute_command: display command output with line truncation
|
|
587
|
+
if command.startswith("execute_command"):
|
|
588
|
+
handle_execute_command_feedback(tool_result, console, panel_updater)
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
# For web_search: display results count and content fetch status
|
|
592
|
+
if command.startswith("web search"):
|
|
593
|
+
lines = tool_result.split('\n')
|
|
594
|
+
if lines:
|
|
595
|
+
summary = _parse_web_search_metadata(lines[0])
|
|
596
|
+
if summary:
|
|
597
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
598
|
+
message = f"{prefix}[dim]{summary}[/dim]"
|
|
599
|
+
_print_or_append(message, console, panel_updater)
|
|
600
|
+
if not panel_updater:
|
|
601
|
+
console.print()
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _parse_web_search_metadata(first_line):
|
|
606
|
+
"""Parse web search metadata line into a human-readable summary.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
first_line: The first line of web_search tool result containing metadata.
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
str: Human-readable summary like "Found 5 results, 3 pages fetched"
|
|
613
|
+
"""
|
|
614
|
+
match = re.search(r'results_found=(\d+)', first_line)
|
|
615
|
+
if not match:
|
|
616
|
+
return ""
|
|
617
|
+
|
|
618
|
+
count = int(match.group(1))
|
|
619
|
+
if count == 0:
|
|
620
|
+
return "No results found"
|
|
621
|
+
|
|
622
|
+
parts = [f"Found {count} result{'s' if count != 1 else ''}"]
|
|
623
|
+
|
|
624
|
+
fetched = re.search(r'pages_fetched=(\d+)', first_line)
|
|
625
|
+
if fetched:
|
|
626
|
+
fc = int(fetched.group(1))
|
|
627
|
+
if fc > 0:
|
|
628
|
+
parts.append(f"{fc} page{'s' if fc != 1 else ''} fetched")
|
|
629
|
+
|
|
630
|
+
failed = re.search(r'pages_failed=(\d+)', first_line)
|
|
631
|
+
if failed:
|
|
632
|
+
f = int(failed.group(1))
|
|
633
|
+
if f > 0:
|
|
634
|
+
parts.append(f"{f} failed")
|
|
635
|
+
|
|
636
|
+
return ", ".join(parts)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# ---------------------------------------------------------------------------
|
|
640
|
+
# Panel message builder (used by SubAgentPanel.add_tool_call)
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
def build_panel_tool_message(tool_name, tool_result, command):
|
|
644
|
+
"""Build a formatted Rich markup message for a tool call in the sub-agent panel.
|
|
645
|
+
|
|
646
|
+
This consolidates the formatting logic that was previously duplicated
|
|
647
|
+
between SubAgentPanel.add_tool_call and display_tool_feedback.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
tool_name: Name of the tool (e.g., "read_file", "rg")
|
|
651
|
+
tool_result: Tool result string
|
|
652
|
+
command: Optional command string for context (e.g., "read_file: path/to/file")
|
|
653
|
+
|
|
654
|
+
Returns:
|
|
655
|
+
str: Rich markup string for the panel message
|
|
656
|
+
"""
|
|
657
|
+
if tool_result is None:
|
|
658
|
+
return f"[grey]{tool_name}[/grey]"
|
|
659
|
+
|
|
660
|
+
if tool_name == "read_file":
|
|
661
|
+
path = ""
|
|
662
|
+
if command:
|
|
663
|
+
match = re.search(r'read_file:?\s+(.+)', command)
|
|
664
|
+
if match:
|
|
665
|
+
path = match.group(1).strip()
|
|
666
|
+
|
|
667
|
+
metadata = extract_multiple_metadata(tool_result, 'lines_read', 'start_line')
|
|
668
|
+
count = metadata.get('lines_read')
|
|
669
|
+
|
|
670
|
+
if count is not None:
|
|
671
|
+
start_line = metadata.get('start_line')
|
|
672
|
+
suffix = f" ({count} line{'s' if count != 1 else ''})"
|
|
673
|
+
if start_line and start_line > 1:
|
|
674
|
+
end_line = start_line + count - 1
|
|
675
|
+
suffix = f" lines {start_line}-{end_line}{suffix}"
|
|
676
|
+
else:
|
|
677
|
+
suffix = f" Read{suffix}"
|
|
678
|
+
return f"[grey]read_file {path}[/grey]\n[dim]╰─{suffix}[/dim]"
|
|
679
|
+
return f"[grey]read_file {path}[/grey]"
|
|
680
|
+
|
|
681
|
+
if tool_name == "rg":
|
|
682
|
+
pattern = ""
|
|
683
|
+
if command:
|
|
684
|
+
match = re.search(r'rg:?\s+(.+)', command)
|
|
685
|
+
if match:
|
|
686
|
+
pattern = match.group(1).strip()
|
|
687
|
+
|
|
688
|
+
lines = tool_result.split('\n')
|
|
689
|
+
if len(lines) > 1:
|
|
690
|
+
metadata = extract_all_metadata(tool_result, line_index=1)
|
|
691
|
+
count = metadata.get('matches') or metadata.get('files')
|
|
692
|
+
if count is not None:
|
|
693
|
+
label = 'matches' if 'matches' in metadata else 'files'
|
|
694
|
+
if count == 0:
|
|
695
|
+
return f"[grey]rg {pattern}[/grey]\n[dim]╰─ No {label} found[/dim]"
|
|
696
|
+
return f"[grey]rg {pattern}[/grey]\n[dim]╰─ Found {count} {label}[/dim]"
|
|
697
|
+
elif any("exit_code=1" in line for line in lines):
|
|
698
|
+
return f"[grey]rg {pattern}[/grey]\n[dim]╰─ No matches found[/dim]"
|
|
699
|
+
|
|
700
|
+
# Fallback: if exit_code=1 but no footer
|
|
701
|
+
if tool_result and "exit_code=1" in tool_result:
|
|
702
|
+
return f"[grey]rg {pattern}[/grey]\n[dim]╰─ No matches found[/dim]"
|
|
703
|
+
return f"[grey]rg {pattern}[/grey]"
|
|
704
|
+
|
|
705
|
+
if tool_name == "list_directory":
|
|
706
|
+
path = "."
|
|
707
|
+
if command:
|
|
708
|
+
match = re.search(r'list_directory:?\s+(.+)', command)
|
|
709
|
+
if match:
|
|
710
|
+
path = match.group(1).strip()
|
|
711
|
+
|
|
712
|
+
lines = tool_result.split('\n')
|
|
713
|
+
items_count = 0
|
|
714
|
+
for line in lines:
|
|
715
|
+
match = re.search(r'items_count=(\d+)', line)
|
|
716
|
+
if match:
|
|
717
|
+
items_count = int(match.group(1))
|
|
718
|
+
break
|
|
719
|
+
|
|
720
|
+
if items_count > 0:
|
|
721
|
+
return f"[grey]list_directory {path}[/grey]\n[dim]╰─ {items_count} item{'s' if items_count != 1 else ''}[/dim]"
|
|
722
|
+
return f"[grey]list_directory {path}[/grey]\n[dim]╰─ No items[/dim]"
|
|
723
|
+
|
|
724
|
+
if tool_name == "search_plugins":
|
|
725
|
+
query = ""
|
|
726
|
+
query_match = re.search(r"matching '([^']+)':", tool_result or "")
|
|
727
|
+
if query_match:
|
|
728
|
+
query = query_match.group(1)
|
|
729
|
+
|
|
730
|
+
# Count plugin entries
|
|
731
|
+
plugin_count = len(re.findall(r'^- \*\*.*?\*\*', tool_result or "", re.MULTILINE))
|
|
732
|
+
if plugin_count > 0:
|
|
733
|
+
label = f"plugins matching '{query}'" if query else "plugins"
|
|
734
|
+
return f"[grey]search_plugins {query}[/grey]\n[dim]╰─ {plugin_count} {label}[/dim]"
|
|
735
|
+
return f"[grey]search_plugins {query}[/grey]\n[dim]╰─ No plugins found[/dim]"
|
|
736
|
+
|
|
737
|
+
if tool_name == "web_search":
|
|
738
|
+
query = ""
|
|
739
|
+
if command:
|
|
740
|
+
if "|" in command:
|
|
741
|
+
parts = command.split(" | ", 1)
|
|
742
|
+
if len(parts) > 1:
|
|
743
|
+
query = parts[1]
|
|
744
|
+
|
|
745
|
+
lines = tool_result.split('\n')
|
|
746
|
+
summary = _parse_web_search_metadata(lines[0]) if lines else ""
|
|
747
|
+
|
|
748
|
+
if query:
|
|
749
|
+
if summary:
|
|
750
|
+
return f"[bold #5F9EA0]web search | {query}[/bold #5F9EA0]\n[dim]╰─ {summary}[/dim]"
|
|
751
|
+
return f"[bold #5F9EA0]web search | {query}[/bold #5F9EA0]\n[dim]╰─ Search completed[/dim]"
|
|
752
|
+
return f"[bold #5F9EA0]web_search[/bold #5F9EA0]\n[dim]╰─ Search completed[/dim]"
|
|
753
|
+
|
|
754
|
+
if tool_name == "execute_command":
|
|
755
|
+
cmd_display = ""
|
|
756
|
+
if command:
|
|
757
|
+
if command.startswith("execute_command"):
|
|
758
|
+
parts = command.split(' ', 1)
|
|
759
|
+
if len(parts) > 1:
|
|
760
|
+
cmd_display = parts[1]
|
|
761
|
+
else:
|
|
762
|
+
cmd_display = command
|
|
763
|
+
if cmd_display:
|
|
764
|
+
return f"[grey]{cmd_display}[/grey]\n[dim]╰─ Command executed[/dim]"
|
|
765
|
+
return f"[grey]execute_command[/grey]\n[dim]╰─ Command executed[/dim]"
|
|
766
|
+
|
|
767
|
+
if tool_name in ("create_task_list", "complete_task", "show_task_list"):
|
|
768
|
+
exit_code = extract_exit_code(tool_result)
|
|
769
|
+
if exit_code == 0 or exit_code is None:
|
|
770
|
+
rendered = tool_result
|
|
771
|
+
if rendered.startswith("exit_code="):
|
|
772
|
+
rendered = "\n".join(rendered.splitlines()[1:])
|
|
773
|
+
return f"[grey]{tool_name}[/grey]\n[dim]╰─ {rendered.strip()}[/dim]"
|
|
774
|
+
first_two = "\n".join(tool_result.splitlines()[:2]).strip()
|
|
775
|
+
return f"[grey]{tool_name}[/grey]\n[dim]╰─ {first_two or tool_result.strip()}[/dim]"
|
|
776
|
+
|
|
777
|
+
return f"[grey]{tool_name}[/grey]"
|
|
778
|
+
|