bone-agent 1.3.3 → 2.0.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.
Files changed (121) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -184
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -141
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -36
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/targeted_searching.md +0 -10
  25. package/prompts/main/task_lists_pattern.md +0 -8
  26. package/prompts/main/temp_folder.md +0 -9
  27. package/prompts/main/think_before_acting.md +0 -10
  28. package/prompts/main/tone_and_style.md +0 -4
  29. package/prompts/main/tool_preferences.md +0 -24
  30. package/prompts/main/trust_subagent_context.md +0 -21
  31. package/prompts/main/when_to_use_sub_agent.md +0 -7
  32. package/prompts/micro/ask_questions.md +0 -1
  33. package/prompts/micro/batch_independent_calls.md +0 -1
  34. package/prompts/micro/casual_interactions.md +0 -1
  35. package/prompts/micro/code_references.md +0 -1
  36. package/prompts/micro/communication_style.md +0 -1
  37. package/prompts/micro/context_reliability.md +0 -1
  38. package/prompts/micro/conversational_tool_calling.md +0 -1
  39. package/prompts/micro/editing_pattern.md +0 -1
  40. package/prompts/micro/error_handling.md +0 -1
  41. package/prompts/micro/exploration_pattern.md +0 -1
  42. package/prompts/micro/intro.md +0 -1
  43. package/prompts/micro/obsidian.md +0 -4
  44. package/prompts/micro/obsidian_project.md +0 -5
  45. package/prompts/micro/professional_objectivity.md +0 -1
  46. package/prompts/micro/targeted_searching.md +0 -1
  47. package/prompts/micro/task_lists_pattern.md +0 -1
  48. package/prompts/micro/temp_folder.md +0 -1
  49. package/prompts/micro/think_before_acting.md +0 -5
  50. package/prompts/micro/tone_and_style.md +0 -1
  51. package/prompts/micro/tool_preferences.md +0 -1
  52. package/prompts/micro/trust_subagent_context.md +0 -1
  53. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  54. package/requirements.txt +0 -9
  55. package/src/__init__.py +0 -11
  56. package/src/core/__init__.py +0 -1
  57. package/src/core/agentic.py +0 -985
  58. package/src/core/chat_manager.py +0 -1564
  59. package/src/core/config_manager.py +0 -253
  60. package/src/core/cron.py +0 -582
  61. package/src/core/cron_allowlist.py +0 -118
  62. package/src/core/memory.py +0 -145
  63. package/src/core/retry.py +0 -71
  64. package/src/core/sub_agent.py +0 -326
  65. package/src/core/tool_approval.py +0 -220
  66. package/src/core/tool_feedback.py +0 -778
  67. package/src/exceptions.py +0 -79
  68. package/src/llm/__init__.py +0 -1
  69. package/src/llm/client.py +0 -171
  70. package/src/llm/config.py +0 -492
  71. package/src/llm/prompts.py +0 -489
  72. package/src/llm/providers.py +0 -436
  73. package/src/llm/streaming.py +0 -163
  74. package/src/llm/token_tracker.py +0 -384
  75. package/src/tools/__init__.py +0 -212
  76. package/src/tools/constants.py +0 -59
  77. package/src/tools/create_file.py +0 -136
  78. package/src/tools/directory.py +0 -389
  79. package/src/tools/edit.py +0 -545
  80. package/src/tools/file_reader.py +0 -322
  81. package/src/tools/helpers/__init__.py +0 -105
  82. package/src/tools/helpers/base.py +0 -550
  83. package/src/tools/helpers/converters.py +0 -44
  84. package/src/tools/helpers/file_helpers.py +0 -189
  85. package/src/tools/helpers/formatters.py +0 -411
  86. package/src/tools/helpers/loader.py +0 -231
  87. package/src/tools/helpers/parallel_executor.py +0 -231
  88. package/src/tools/helpers/path_resolver.py +0 -232
  89. package/src/tools/helpers/plugin_manifest.py +0 -156
  90. package/src/tools/obsidian.py +0 -96
  91. package/src/tools/review_sub_agent.py +0 -189
  92. package/src/tools/rg_search.py +0 -460
  93. package/src/tools/search_plugins.py +0 -109
  94. package/src/tools/select_option.py +0 -600
  95. package/src/tools/shell.py +0 -302
  96. package/src/tools/sub_agent.py +0 -139
  97. package/src/tools/task_list.py +0 -269
  98. package/src/tools/web_search.py +0 -61
  99. package/src/ui/__init__.py +0 -1
  100. package/src/ui/banner.py +0 -87
  101. package/src/ui/commands.py +0 -2809
  102. package/src/ui/displays.py +0 -214
  103. package/src/ui/loader.py +0 -284
  104. package/src/ui/main.py +0 -647
  105. package/src/ui/prompt_utils.py +0 -113
  106. package/src/ui/setting_selector.py +0 -590
  107. package/src/ui/setup_wizard.py +0 -294
  108. package/src/ui/sub_agent_panel.py +0 -234
  109. package/src/ui/tool_confirmation.py +0 -215
  110. package/src/utils/__init__.py +0 -1
  111. package/src/utils/citation_parser.py +0 -199
  112. package/src/utils/editor.py +0 -158
  113. package/src/utils/gitignore_filter.py +0 -149
  114. package/src/utils/logger.py +0 -254
  115. package/src/utils/paths.py +0 -30
  116. package/src/utils/result_parsers.py +0 -108
  117. package/src/utils/safe_commands.py +0 -243
  118. package/src/utils/settings.py +0 -191
  119. package/src/utils/user_message_logger.py +0 -120
  120. package/src/utils/validation.py +0 -191
  121. package/src/utils/web_search.py +0 -173
@@ -1,778 +0,0 @@
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
-