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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,593 @@
1
+ """Interactive selection tool for presenting multiple-choice questions to the user."""
2
+
3
+ import asyncio
4
+ from html import escape as _html_escape
5
+ from threading import Timer
6
+ from typing import Optional, List, Dict, Any, Union
7
+
8
+ from prompt_toolkit import HTML
9
+ from prompt_toolkit.application import Application
10
+ from prompt_toolkit.key_binding import KeyBindings
11
+ from prompt_toolkit.keys import Keys
12
+ from prompt_toolkit.layout import Layout, HSplit, Window
13
+ from prompt_toolkit.layout.dimension import D
14
+ from prompt_toolkit.layout.controls import FormattedTextControl
15
+
16
+ from ui.prompt_utils import TOOLBAR_STYLE
17
+
18
+ from .helpers.base import tool
19
+
20
+ # Sentinel value used to detect when user selects the custom input option
21
+ CUSTOM_INPUT_SENTINEL = "__custom_input__"
22
+ CUSTOM_INPUT_OPTION = {
23
+ "value": CUSTOM_INPUT_SENTINEL,
24
+ "text": "Type your own input..."
25
+ }
26
+
27
+
28
+ class SelectionPanel:
29
+ """Inline selection panel with arrow key navigation and inline custom input."""
30
+
31
+ # Cursor indicator
32
+ _CURSOR = "> "
33
+
34
+ def __init__(self, questions: List[Dict[str, Any]]):
35
+ """Initialize the selection panel.
36
+
37
+ Args:
38
+ questions: List of question dicts with 'question', 'options' (each with 'value', 'text', optional 'description')
39
+ """
40
+ self.questions = questions
41
+ self._showing_summary = False
42
+
43
+ # Initialize for multi-question mode (handles both single and multiple questions)
44
+ self.current_question_idx = 0
45
+ self.selections = [None] * len(questions)
46
+ # Initialize selected_index for each question
47
+ self.selected_indices = [0] * len(questions)
48
+
49
+ # Inline custom input editing state
50
+ self._editing_custom_input = False
51
+ self._custom_input_texts: Dict[int, str] = {} # question_idx -> typed text
52
+
53
+ # Multi-select state: per-question set of checked option indices
54
+ self._checked_indices: Dict[int, set] = {
55
+ q_idx: set() for q_idx in range(len(questions))
56
+ }
57
+
58
+ def _is_multi_select(self, q_idx: int = None) -> bool:
59
+ """Check if a question is in multi-select mode.
60
+
61
+ Args:
62
+ q_idx: Question index, defaults to current_question_idx
63
+ """
64
+ if q_idx is None:
65
+ q_idx = self.current_question_idx
66
+ return bool(self.questions[q_idx].get("multi_select", False))
67
+
68
+ def _is_custom_input_selected(self) -> bool:
69
+ """Check if the custom input option is currently selected."""
70
+ q_idx = self.current_question_idx
71
+ options = self.questions[q_idx].get("options", [])
72
+ opt_idx = self.selected_indices[q_idx]
73
+ if opt_idx < len(options):
74
+ return options[opt_idx].get("value") == CUSTOM_INPUT_SENTINEL
75
+ return False
76
+
77
+ def _wrap_description(self, text: str, indent: str, width: int = None) -> List[str]:
78
+ """Wrap description text preserving indent on continuation lines.
79
+
80
+ Args:
81
+ text: The description text to wrap.
82
+ indent: The indentation string (e.g. ' ').
83
+ width: Maximum line width. Defaults to current terminal width.
84
+
85
+ Returns:
86
+ List of lines, first without extra indent, continuations with indent.
87
+ """
88
+ import os
89
+ if width is None:
90
+ width = os.get_terminal_size().columns
91
+ available = width - len(indent)
92
+ if available <= 0:
93
+ return [text]
94
+ words = text.split()
95
+ lines = []
96
+ current = ""
97
+ for word in words:
98
+ if not current:
99
+ current = word
100
+ elif len(current) + 1 + len(word) <= available:
101
+ current += " " + word
102
+ else:
103
+ lines.append(current)
104
+ current = word
105
+ if current:
106
+ lines.append(current)
107
+ return lines
108
+
109
+ def _render_option(self, opt, o_idx, q_idx, is_focused, lines):
110
+ """Render a single option into the lines list.
111
+
112
+ Args:
113
+ opt: Option dict with value, text, optional description
114
+ o_idx: Option index
115
+ q_idx: Question index
116
+ is_focused: Whether this option has the cursor
117
+ lines: List to append rendered lines to
118
+ """
119
+ text = _html_escape(opt.get("text", ""))
120
+ description = opt.get("description", "")
121
+ is_custom = opt.get("value") == CUSTOM_INPUT_SENTINEL
122
+ multi = self._is_multi_select(q_idx)
123
+ checked = o_idx in self._checked_indices.get(q_idx, set())
124
+
125
+ if is_focused:
126
+ if is_custom and self._editing_custom_input:
127
+ # Editing mode: show text field with user input
128
+ typed = _html_escape(self._custom_input_texts.get(q_idx, ""))
129
+ lines.append(f'<style fg="white" bold="true">{self._CURSOR}{typed}</style>')
130
+ lines.append(f'<style fg="gray"> Type your answer, Enter to confirm, Esc to go back</style>')
131
+ else:
132
+ # Navigation mode
133
+ if is_custom:
134
+ typed = _html_escape(self._custom_input_texts.get(q_idx, ""))
135
+ display = typed if typed else text
136
+ else:
137
+ display = text
138
+
139
+ if multi and not is_custom:
140
+ marker = "◉" if checked else "○"
141
+ lines.append(f'<style fg="white" bold="true">{self._CURSOR}{marker} {display}</style>')
142
+ else:
143
+ lines.append(f'<style fg="white" bold="true">{self._CURSOR}{display}</style>')
144
+
145
+ if description:
146
+ for wl in self._wrap_description(_html_escape(description), " "):
147
+ lines.append(f'<style fg="white"> {wl}</style>')
148
+ else:
149
+ # Unfocused option
150
+ if is_custom:
151
+ typed = _html_escape(self._custom_input_texts.get(q_idx, ""))
152
+ display = typed if typed else text
153
+ else:
154
+ display = text
155
+
156
+ if multi and not is_custom:
157
+ marker = "◉" if checked else "○"
158
+ if checked:
159
+ lines.append(f'<style fg="#5F9EA0"> {marker} {display}</style>')
160
+ else:
161
+ lines.append(f'<style fg="gray"> {marker} {display}</style>')
162
+ else:
163
+ lines.append(f'<style fg="gray"> {display}</style>')
164
+
165
+ if description:
166
+ color = "#5F9EA0" if (multi and checked) else "gray"
167
+ for wl in self._wrap_description(_html_escape(description), " "):
168
+ lines.append(f'<style fg="{color}"> {wl}</style>')
169
+
170
+ def _get_display_text(self) -> HTML:
171
+ """Get the formatted text to display.
172
+
173
+ Returns:
174
+ HTML formatted text with current selection state
175
+ """
176
+ lines = []
177
+ is_single = len(self.questions) == 1
178
+
179
+ # --- Summary view ---
180
+ if self._showing_summary:
181
+ if is_single:
182
+ lines.append("<b>Selection Summary</b>")
183
+ lines.append("")
184
+
185
+ question = _html_escape(self.questions[0].get("question", ""))
186
+ selected_value = self.selections[0]
187
+ options = self.questions[0].get("options", [])
188
+
189
+ if self._is_multi_select(0) and isinstance(selected_value, list):
190
+ # Multi-select summary: show all checked items
191
+ selected_texts = []
192
+ for val in selected_value:
193
+ opt = next((o for o in options if o.get("value") == val), None)
194
+ selected_texts.append(_html_escape(opt.get("text", val) if opt else str(val)))
195
+ lines.append(f"<b>Question:</b> {question}")
196
+ lines.append(f'<style fg="gray"> Selected: {", ".join(selected_texts)}</style>')
197
+ else:
198
+ # Single-select summary
199
+ selected_opt = next((opt for opt in options if opt.get("value") == selected_value), None)
200
+ selected_text = selected_opt.get("text", selected_value) if selected_opt else selected_value
201
+ lines.append(f"<b>Question:</b> {question}")
202
+ lines.append(f'<style fg="gray"> Selected: {_html_escape(str(selected_text))}</style>')
203
+ lines.append("")
204
+ else:
205
+ lines.append("<b>Selections Summary</b>")
206
+ lines.append("")
207
+
208
+ for q_idx, q in enumerate(self.questions):
209
+ question = _html_escape(q.get("question", ""))
210
+ selected_value = self.selections[q_idx] if q_idx < len(self.selections) else None
211
+ options = q.get("options", [])
212
+
213
+ if self._is_multi_select(q_idx) and isinstance(selected_value, list):
214
+ selected_texts = []
215
+ for val in selected_value:
216
+ opt = next((o for o in options if o.get("value") == val), None)
217
+ selected_texts.append(_html_escape(opt.get("text", val) if opt else str(val)))
218
+ lines.append(f"<b>Question {q_idx + 1}:</b> {question}")
219
+ lines.append(f'<style fg="gray"> Selected: {", ".join(selected_texts)}</style>')
220
+ else:
221
+ selected_opt = next((opt for opt in options if opt.get("value") == selected_value), None)
222
+ selected_text = selected_opt.get("text", selected_value) if selected_opt else selected_value
223
+ lines.append(f"<b>Question {q_idx + 1}:</b> {question}")
224
+ lines.append(f'<style fg="gray"> Selected: {_html_escape(str(selected_text))}</style>')
225
+ lines.append("")
226
+ # --- Option list view ---
227
+ else:
228
+ if is_single:
229
+ q_idx = 0
230
+ question = self.questions[0]
231
+ lines.append(f"<b>{_html_escape(question.get('question', ''))}</b>")
232
+ lines.append("")
233
+ else:
234
+ q_idx = self.current_question_idx
235
+ question = self.questions[q_idx]
236
+ q_num = q_idx + 1
237
+ q_total = len(self.questions)
238
+ lines.append(f"<b>Question {q_num}/{q_total}: {_html_escape(question.get('question', ''))}</b>")
239
+ lines.append("")
240
+
241
+ options = question.get("options", [])
242
+ for o_idx, opt in enumerate(options):
243
+ self._render_option(opt, o_idx, q_idx, o_idx == self.selected_indices[q_idx], lines)
244
+
245
+ # Add help text
246
+ lines.append("")
247
+ if self._editing_custom_input:
248
+ lines.append('<style fg="gray">Type your answer. Enter to confirm, Esc to go back</style>')
249
+ elif self._is_multi_select(q_idx):
250
+ lines.append('<style fg="gray">Use ↑↓ to navigate, Space to toggle, Enter to confirm, Esc to cancel</style>')
251
+ elif is_single:
252
+ lines.append('<style fg="gray">Use ↑↓ to navigate, Enter to confirm, Esc to cancel</style>')
253
+ else:
254
+ lines.append('<style fg="gray">Use ↑↓ to navigate options, ←→ for questions, Enter to confirm, Esc to cancel</style>')
255
+
256
+ return HTML("\n".join(lines))
257
+
258
+ def _advance_question(self, event) -> None:
259
+ """Advance to next question or finish.
260
+
261
+ Args:
262
+ event: PromptToolkit event object
263
+ """
264
+ if len(self.questions) == 1:
265
+ # Single question - show summary then auto-exit
266
+ self._showing_summary = True
267
+ event.app.invalidate()
268
+ Timer(1.0, lambda: event.app.exit(result=self.selections[0])).start()
269
+ else:
270
+ # Multi-question - advance or finish
271
+ if self.current_question_idx < len(self.questions) - 1:
272
+ self.current_question_idx += 1
273
+ self._editing_custom_input = False
274
+ event.app.invalidate()
275
+ else:
276
+ self._showing_summary = True
277
+ event.app.invalidate()
278
+ Timer(1.0, lambda: event.app.exit(result=self.selections)).start()
279
+
280
+ def run(self) -> Optional[Union[str, List[str]]]:
281
+ """Display the selection panel and wait for user input.
282
+
283
+ Returns:
284
+ Single question mode: Selected value (str), or None if canceled
285
+ Multi-question mode: List of selected values (List[str]), or None if canceled
286
+ """
287
+ # Create key bindings for navigation
288
+ bindings = KeyBindings()
289
+
290
+ @bindings.add(Keys.Up)
291
+ def move_up(event):
292
+ """Move selection up."""
293
+ if self._showing_summary or self._editing_custom_input:
294
+ return
295
+ if self.selected_indices[self.current_question_idx] > 0:
296
+ self.selected_indices[self.current_question_idx] -= 1
297
+ event.app.invalidate()
298
+
299
+ @bindings.add(Keys.Down)
300
+ def move_down(event):
301
+ """Move selection down."""
302
+ if self._showing_summary or self._editing_custom_input:
303
+ return
304
+ current_options = self.questions[self.current_question_idx].get("options", [])
305
+ if self.selected_indices[self.current_question_idx] < len(current_options) - 1:
306
+ self.selected_indices[self.current_question_idx] += 1
307
+ event.app.invalidate()
308
+
309
+ @bindings.add(Keys.Left)
310
+ def prev_question(event):
311
+ """Go to previous question (multi-question mode only)."""
312
+ if self._showing_summary or self._editing_custom_input:
313
+ return
314
+ if len(self.questions) > 1 and self.current_question_idx > 0:
315
+ self.current_question_idx -= 1
316
+ event.app.invalidate()
317
+
318
+ @bindings.add(Keys.Right)
319
+ def next_question(event):
320
+ """Go to next question (multi-question mode only)."""
321
+ if self._showing_summary or self._editing_custom_input:
322
+ return
323
+ if len(self.questions) > 1 and self.current_question_idx < len(self.questions) - 1:
324
+ self.current_question_idx += 1
325
+ event.app.invalidate()
326
+
327
+ @bindings.add(Keys.Enter)
328
+ def select(event):
329
+ """Confirm selection or toggle custom input editing."""
330
+ if self._showing_summary:
331
+ return
332
+
333
+ if self._editing_custom_input:
334
+ # Confirm custom input text
335
+ typed = self._custom_input_texts.get(self.current_question_idx, "").strip()
336
+ if not typed:
337
+ # Empty input - go back to editing, don't advance
338
+ return
339
+ self.selections[self.current_question_idx] = typed
340
+ self._editing_custom_input = False
341
+ self._advance_question(event)
342
+ else:
343
+ # Check if custom input option is selected
344
+ if self._is_custom_input_selected():
345
+ # Enter edit mode
346
+ self._editing_custom_input = True
347
+ event.app.cursor_position = (0, 0) # Reset cursor
348
+ event.app.invalidate()
349
+ elif self._is_multi_select():
350
+ # Multi-select mode: only advance if at least one option is checked
351
+ q_idx = self.current_question_idx
352
+ checked = self._checked_indices.get(q_idx, set())
353
+ if not checked:
354
+ # Nothing checked yet — ignore Enter, user must toggle with Space
355
+ return
356
+ options = self.questions[q_idx].get("options", [])
357
+ checked_values = [
358
+ options[i].get("value")
359
+ for i in checked
360
+ if i < len(options)
361
+ ]
362
+ self.selections[q_idx] = checked_values
363
+ self._advance_question(event)
364
+ else:
365
+ # Single-select: store and advance
366
+ current_options = self.questions[self.current_question_idx].get("options", [])
367
+ if current_options and self.selected_indices[self.current_question_idx] < len(current_options):
368
+ self.selections[self.current_question_idx] = current_options[self.selected_indices[self.current_question_idx]].get("value")
369
+ self._advance_question(event)
370
+
371
+ @bindings.add(' ')
372
+ def toggle_check(event):
373
+ """Toggle checkbox for multi-select questions."""
374
+ if self._showing_summary:
375
+ return
376
+ if self._editing_custom_input:
377
+ q_idx = self.current_question_idx
378
+ self._custom_input_texts[q_idx] += ' '
379
+ event.app.invalidate()
380
+ return
381
+ if not self._is_multi_select():
382
+ return
383
+ q_idx = self.current_question_idx
384
+ opt_idx = self.selected_indices[q_idx]
385
+ options = self.questions[q_idx].get("options", [])
386
+ # Don't allow toggling the custom input sentinel via Space
387
+ if opt_idx < len(options) and options[opt_idx].get("value") != CUSTOM_INPUT_SENTINEL:
388
+ checked = self._checked_indices.get(q_idx, set())
389
+ if opt_idx in checked:
390
+ checked.discard(opt_idx)
391
+ else:
392
+ checked.add(opt_idx)
393
+ event.app.invalidate()
394
+
395
+ @bindings.add(Keys.Escape)
396
+ def cancel(event):
397
+ """Cancel editing or cancel selection."""
398
+ if self._editing_custom_input:
399
+ # Exit editing mode, return to navigation
400
+ self._editing_custom_input = False
401
+ event.app.invalidate()
402
+ else:
403
+ # Cancel entire selection
404
+ event.app.exit(result=None)
405
+
406
+ # Printable character input for custom input editing
407
+ @bindings.add(Keys.Any)
408
+ def handle_input(event):
409
+ """Handle printable character input when editing custom input."""
410
+ if not self._editing_custom_input or self._showing_summary:
411
+ return
412
+
413
+ data = event.data
414
+ # Filter to printable characters (no control chars)
415
+ if len(data) == 1 and ord(data) >= 32:
416
+ q_idx = self.current_question_idx
417
+ current = self._custom_input_texts.get(q_idx, "")
418
+ self._custom_input_texts[q_idx] = current + data
419
+ event.app.invalidate()
420
+
421
+ @bindings.add(Keys.Backspace)
422
+ def handle_backspace(event):
423
+ """Handle backspace when editing custom input."""
424
+ if not self._editing_custom_input or self._showing_summary:
425
+ return
426
+ q_idx = self.current_question_idx
427
+ current = self._custom_input_texts.get(q_idx, "")
428
+ if current:
429
+ self._custom_input_texts[q_idx] = current[:-1]
430
+ event.app.invalidate()
431
+
432
+ @bindings.add(Keys.Delete)
433
+ def handle_delete(event):
434
+ """Handle delete when editing custom input."""
435
+ if not self._editing_custom_input or self._showing_summary:
436
+ return
437
+ # Delete at cursor position - for simplicity, same as backspace
438
+ # since we don't track cursor position within the text
439
+ q_idx = self.current_question_idx
440
+ current = self._custom_input_texts.get(q_idx, "")
441
+ if current:
442
+ self._custom_input_texts[q_idx] = current[:-1]
443
+ event.app.invalidate()
444
+
445
+ # Create the content control
446
+ def get_content():
447
+ return self._get_display_text()
448
+
449
+ content_control = FormattedTextControl(get_content)
450
+
451
+ # Create layout with the content
452
+ root_container = HSplit([
453
+ Window(content=content_control, height=D(min=1), width=D(min=1), wrap_lines=True),
454
+ ])
455
+
456
+ layout = Layout(root_container)
457
+
458
+ # Create and run the application
459
+ application = Application(
460
+ layout=layout,
461
+ key_bindings=bindings,
462
+ full_screen=False,
463
+ mouse_support=False,
464
+ cursor=None,
465
+ style=TOOLBAR_STYLE,
466
+ )
467
+
468
+ # Use run_async with asyncio to properly await coroutines
469
+ result = asyncio.run(application.run_async())
470
+
471
+ return result
472
+
473
+
474
+ @tool(
475
+ name="select_option",
476
+ description="Ask the user a question with selectable options using arrow keys. An inline panel shows options navigable with arrow keys. A 'Type your own input...' option is auto-appended for free-form answers. Supports single and multi-question forms (single = array with 1 item). Set 'multi_select': true on a question to allow the user to check multiple options with Space and confirm with Enter.",
477
+ parameters={
478
+ "type": "object",
479
+ "properties": {
480
+ "questions": {
481
+ "type": "array",
482
+ "description": "List of questions (single = array with 1 item, multi = array with multiple items).",
483
+ "items": {
484
+ "type": "object",
485
+ "properties": {
486
+ "question": {"type": "string", "description": "The question text"},
487
+ "multi_select": {"type": "boolean", "description": "If true, user can select multiple options using Space. Defaults to false (single-select)."},
488
+ "options": {
489
+ "type": "array",
490
+ "description": "List of options for this question",
491
+ "items": {
492
+ "type": "object",
493
+ "properties": {
494
+ "value": {"type": "string", "description": "Value to return if this option is selected"},
495
+ "text": {"type": "string", "description": "Display text for the option"},
496
+ "description": {"type": "string", "description": "Optional detailed description"}
497
+ },
498
+ "required": ["value", "text"]
499
+ }
500
+ }
501
+ },
502
+ "required": ["question", "options"]
503
+ }
504
+ }
505
+ },
506
+ "required": ["questions"]
507
+ },
508
+ requires_approval=False,
509
+ terminal_policy="yield"
510
+ )
511
+ def select_option(
512
+ questions: List[Dict[str, Any]],
513
+ context: Dict[str, Any] = None
514
+ ) -> str:
515
+ """Present an inline selection panel to the user.
516
+
517
+ Creates a prompt_toolkit-based selection panel where the user can navigate
518
+ options with arrow keys and select by pressing Enter. Pressing Esc cancels.
519
+
520
+ Args:
521
+ questions: List of question objects, each containing:
522
+ - question: The question text
523
+ - options: List of option objects with value, text, and optional description
524
+ - multi_select: (optional) If true, user can toggle multiple options with Space
525
+ context: Tool execution context (contains chat_manager)
526
+
527
+ Returns:
528
+ str: Formatted tool result with exit_code and selected value(s):
529
+ - "exit_code=0\\n{value}" for single question (1 item in array)
530
+ - "exit_code=0\\n{value1, value2, ...}" for multi-question or multi-select
531
+ - "exit_code=1\\n{error_message}" for user cancellation or validation errors
532
+ """
533
+ try:
534
+ # Validate questions parameter
535
+ if not isinstance(questions, list):
536
+ return "exit_code=1\nQuestions must be a list"
537
+
538
+ if not questions:
539
+ return "exit_code=1\nQuestions list cannot be empty"
540
+
541
+ # Validate each question
542
+ for q_idx, q in enumerate(questions):
543
+ if not isinstance(q, dict):
544
+ return f"exit_code=1\nQuestion {q_idx + 1} must be an object"
545
+
546
+ question_text = q.get("question")
547
+ q_options = q.get("options")
548
+
549
+ if not question_text:
550
+ return f"exit_code=1\nQuestion {q_idx + 1} must have a 'question' field"
551
+
552
+ if not q_options or not isinstance(q_options, list):
553
+ return f"exit_code=1\nQuestion {q_idx + 1} must have a non-empty 'options' list"
554
+
555
+ # Validate each option in the question
556
+ for opt_idx, opt in enumerate(q_options):
557
+ if not isinstance(opt, dict):
558
+ return f"exit_code=1\nOption {opt_idx + 1} in question {q_idx + 1} must be an object"
559
+
560
+ value = opt.get("value")
561
+ text = opt.get("text")
562
+
563
+ if not value or not text:
564
+ return f"exit_code=1\nOption {opt_idx + 1} in question {q_idx + 1} must have 'value' and 'text' fields"
565
+
566
+ # Always append custom input option to each question
567
+ for q in questions:
568
+ q["options"] = list(q["options"]) + [CUSTOM_INPUT_OPTION]
569
+
570
+ # Create and run the selection panel
571
+ panel = SelectionPanel(questions)
572
+ result = panel.run()
573
+
574
+ # Handle user cancellation
575
+ if result is None:
576
+ return "exit_code=1\nUser canceled selection"
577
+
578
+ # Return the selected values (single string for 1 question, comma-separated for multiple)
579
+ if isinstance(result, str):
580
+ return f"exit_code=0\n{result}"
581
+ else:
582
+ # Result is a list (multi-question mode or multi-select)
583
+ formatted = []
584
+ for r in result:
585
+ if isinstance(r, list):
586
+ # Multi-select question: comma-separated values
587
+ formatted.append(', '.join(str(v) for v in r))
588
+ else:
589
+ formatted.append(str(r))
590
+ return f"exit_code=0\n{', '.join(formatted)}"
591
+
592
+ except Exception as e:
593
+ return f"exit_code=1\nError displaying selection panel: {str(e)}"