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,600 +0,0 @@
1
- """Interactive selection tool for presenting multiple-choice questions to the user."""
2
-
3
- from html import escape as _html_escape
4
- from threading import Timer
5
- from typing import Optional, List, Dict, Any, Union
6
-
7
- from prompt_toolkit import HTML
8
- from prompt_toolkit.application import Application
9
- from prompt_toolkit.key_binding import KeyBindings
10
- from prompt_toolkit.keys import Keys
11
- from prompt_toolkit.layout import Layout, HSplit, Window
12
- from prompt_toolkit.layout.dimension import D
13
- from prompt_toolkit.layout.controls import FormattedTextControl
14
-
15
- from ui.prompt_utils import TOOLBAR_STYLE
16
-
17
- from .helpers.base import tool
18
-
19
- # Sentinel value used to detect when user selects the custom input option
20
- CUSTOM_INPUT_SENTINEL = "__custom_input__"
21
- CUSTOM_INPUT_OPTION = {
22
- "value": CUSTOM_INPUT_SENTINEL,
23
- "text": "Type your own input..."
24
- }
25
-
26
-
27
- class SelectionPanel:
28
- """Inline selection panel with arrow key navigation and inline custom input."""
29
-
30
- # Cursor indicator
31
- _CURSOR = "> "
32
-
33
- def __init__(self, questions: List[Dict[str, Any]]):
34
- """Initialize the selection panel.
35
-
36
- Args:
37
- questions: List of question dicts with 'question', 'options' (each with 'value', 'text', optional 'description')
38
- """
39
- self.questions = questions
40
- self._showing_summary = False
41
-
42
- # Initialize for multi-question mode (handles both single and multiple questions)
43
- self.current_question_idx = 0
44
- self.selections = [None] * len(questions)
45
- # Initialize selected_index for each question
46
- self.selected_indices = [0] * len(questions)
47
-
48
- # Inline custom input editing state
49
- self._editing_custom_input = False
50
- self._custom_input_texts: Dict[int, str] = {} # question_idx -> typed text
51
- self._auto_advance_timer: Optional[Timer] = None # Track for cancellation
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
- self._auto_advance_timer = Timer(1.0, lambda: event.app.exit(result=self.selections[0]))
269
- self._auto_advance_timer.start()
270
- else:
271
- # Multi-question - advance or finish
272
- if self.current_question_idx < len(self.questions) - 1:
273
- self.current_question_idx += 1
274
- self._editing_custom_input = False
275
- event.app.invalidate()
276
- else:
277
- self._showing_summary = True
278
- event.app.invalidate()
279
- self._auto_advance_timer = Timer(1.0, lambda: event.app.exit(result=self.selections))
280
- self._auto_advance_timer.start()
281
-
282
- def run(self) -> Optional[Union[str, List[str]]]:
283
- """Display the selection panel and wait for user input.
284
-
285
- Returns:
286
- Single question mode: Selected value (str), or None if canceled
287
- Multi-question mode: List of selected values (List[str]), or None if canceled
288
- """
289
- # Create key bindings for navigation
290
- bindings = KeyBindings()
291
-
292
- @bindings.add(Keys.Up)
293
- def move_up(event):
294
- """Move selection up."""
295
- if self._showing_summary or self._editing_custom_input:
296
- return
297
- if self.selected_indices[self.current_question_idx] > 0:
298
- self.selected_indices[self.current_question_idx] -= 1
299
- event.app.invalidate()
300
-
301
- @bindings.add(Keys.Down)
302
- def move_down(event):
303
- """Move selection down."""
304
- if self._showing_summary or self._editing_custom_input:
305
- return
306
- current_options = self.questions[self.current_question_idx].get("options", [])
307
- if self.selected_indices[self.current_question_idx] < len(current_options) - 1:
308
- self.selected_indices[self.current_question_idx] += 1
309
- event.app.invalidate()
310
-
311
- @bindings.add(Keys.Left)
312
- def prev_question(event):
313
- """Go to previous question (multi-question mode only)."""
314
- if self._showing_summary or self._editing_custom_input:
315
- return
316
- if len(self.questions) > 1 and self.current_question_idx > 0:
317
- self.current_question_idx -= 1
318
- event.app.invalidate()
319
-
320
- @bindings.add(Keys.Right)
321
- def next_question(event):
322
- """Go to next question (multi-question mode only)."""
323
- if self._showing_summary or self._editing_custom_input:
324
- return
325
- if len(self.questions) > 1 and self.current_question_idx < len(self.questions) - 1:
326
- self.current_question_idx += 1
327
- event.app.invalidate()
328
-
329
- @bindings.add(Keys.Enter)
330
- def select(event):
331
- """Confirm selection or toggle custom input editing."""
332
- if self._showing_summary:
333
- return
334
-
335
- if self._editing_custom_input:
336
- # Confirm custom input text
337
- typed = self._custom_input_texts.get(self.current_question_idx, "").strip()
338
- if not typed:
339
- # Empty input - go back to editing, don't advance
340
- return
341
- self.selections[self.current_question_idx] = typed
342
- self._editing_custom_input = False
343
- self._advance_question(event)
344
- else:
345
- # Check if custom input option is selected
346
- if self._is_custom_input_selected():
347
- # Enter edit mode
348
- self._editing_custom_input = True
349
- event.app.cursor_position = (0, 0) # Reset cursor
350
- event.app.invalidate()
351
- elif self._is_multi_select():
352
- # Multi-select mode: only advance if at least one option is checked
353
- q_idx = self.current_question_idx
354
- checked = self._checked_indices.get(q_idx, set())
355
- if not checked:
356
- # Nothing checked yet — ignore Enter, user must toggle with Space
357
- return
358
- options = self.questions[q_idx].get("options", [])
359
- checked_values = [
360
- options[i].get("value")
361
- for i in checked
362
- if i < len(options)
363
- ]
364
- self.selections[q_idx] = checked_values
365
- self._advance_question(event)
366
- else:
367
- # Single-select: store and advance
368
- current_options = self.questions[self.current_question_idx].get("options", [])
369
- if current_options and self.selected_indices[self.current_question_idx] < len(current_options):
370
- self.selections[self.current_question_idx] = current_options[self.selected_indices[self.current_question_idx]].get("value")
371
- self._advance_question(event)
372
-
373
- @bindings.add(' ')
374
- def toggle_check(event):
375
- """Toggle checkbox for multi-select questions."""
376
- if self._showing_summary:
377
- return
378
- if self._editing_custom_input:
379
- q_idx = self.current_question_idx
380
- self._custom_input_texts[q_idx] += ' '
381
- event.app.invalidate()
382
- return
383
- if not self._is_multi_select():
384
- return
385
- q_idx = self.current_question_idx
386
- opt_idx = self.selected_indices[q_idx]
387
- options = self.questions[q_idx].get("options", [])
388
- # Don't allow toggling the custom input sentinel via Space
389
- if opt_idx < len(options) and options[opt_idx].get("value") != CUSTOM_INPUT_SENTINEL:
390
- checked = self._checked_indices.get(q_idx, set())
391
- if opt_idx in checked:
392
- checked.discard(opt_idx)
393
- else:
394
- checked.add(opt_idx)
395
- event.app.invalidate()
396
-
397
- @bindings.add(Keys.Escape)
398
- def cancel(event):
399
- """Cancel editing or cancel selection."""
400
- if self._editing_custom_input:
401
- # Exit editing mode, return to navigation
402
- self._editing_custom_input = False
403
- event.app.invalidate()
404
- else:
405
- # Cancel entire selection
406
- if self._auto_advance_timer:
407
- self._auto_advance_timer.cancel()
408
- self._auto_advance_timer = None
409
- event.app.exit(result=None)
410
-
411
- # Printable character input for custom input editing
412
- @bindings.add(Keys.Any)
413
- def handle_input(event):
414
- """Handle printable character input when editing custom input."""
415
- if not self._editing_custom_input or self._showing_summary:
416
- return
417
-
418
- data = event.data
419
- # Filter to printable characters (no control chars)
420
- if len(data) == 1 and ord(data) >= 32:
421
- q_idx = self.current_question_idx
422
- current = self._custom_input_texts.get(q_idx, "")
423
- self._custom_input_texts[q_idx] = current + data
424
- event.app.invalidate()
425
-
426
- @bindings.add(Keys.Backspace)
427
- def handle_backspace(event):
428
- """Handle backspace when editing custom input."""
429
- if not self._editing_custom_input or self._showing_summary:
430
- return
431
- q_idx = self.current_question_idx
432
- current = self._custom_input_texts.get(q_idx, "")
433
- if current:
434
- self._custom_input_texts[q_idx] = current[:-1]
435
- event.app.invalidate()
436
-
437
- @bindings.add(Keys.Delete)
438
- def handle_delete(event):
439
- """Handle delete when editing custom input."""
440
- if not self._editing_custom_input or self._showing_summary:
441
- return
442
- # Delete at cursor position - for simplicity, same as backspace
443
- # since we don't track cursor position within the text
444
- q_idx = self.current_question_idx
445
- current = self._custom_input_texts.get(q_idx, "")
446
- if current:
447
- self._custom_input_texts[q_idx] = current[:-1]
448
- event.app.invalidate()
449
-
450
- # Create the content control
451
- def get_content():
452
- return self._get_display_text()
453
-
454
- content_control = FormattedTextControl(get_content)
455
-
456
- # Create layout with the content
457
- root_container = HSplit([
458
- Window(content=content_control, height=D(min=1), width=D(min=1), wrap_lines=True),
459
- ])
460
-
461
- layout = Layout(root_container)
462
-
463
- # Create and run the application
464
- application = Application(
465
- layout=layout,
466
- key_bindings=bindings,
467
- full_screen=False,
468
- mouse_support=False,
469
- cursor=None,
470
- style=TOOLBAR_STYLE,
471
- )
472
-
473
- # Use prompt_toolkit's synchronous runner — avoids creating/destroying
474
- # an event loop with asyncio.run(), which corrupts the parent
475
- # PromptSession's event loop state and causes 100% CPU hangs.
476
- result = application.run()
477
-
478
- return result
479
-
480
-
481
- @tool(
482
- name="select_option",
483
- 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.",
484
- parameters={
485
- "type": "object",
486
- "properties": {
487
- "questions": {
488
- "type": "array",
489
- "description": "List of questions (single = array with 1 item, multi = array with multiple items).",
490
- "items": {
491
- "type": "object",
492
- "properties": {
493
- "question": {"type": "string", "description": "The question text"},
494
- "multi_select": {"type": "boolean", "description": "If true, user can select multiple options using Space. Defaults to false (single-select)."},
495
- "options": {
496
- "type": "array",
497
- "description": "List of options for this question",
498
- "items": {
499
- "type": "object",
500
- "properties": {
501
- "value": {"type": "string", "description": "Value to return if this option is selected"},
502
- "text": {"type": "string", "description": "Display text for the option"},
503
- "description": {"type": "string", "description": "Optional detailed description"}
504
- },
505
- "required": ["value", "text"]
506
- }
507
- }
508
- },
509
- "required": ["question", "options"]
510
- }
511
- }
512
- },
513
- "required": ["questions"]
514
- },
515
- requires_approval=False,
516
- terminal_policy="yield"
517
- )
518
- def select_option(
519
- questions: List[Dict[str, Any]],
520
- context: Dict[str, Any] = None
521
- ) -> str:
522
- """Present an inline selection panel to the user.
523
-
524
- Creates a prompt_toolkit-based selection panel where the user can navigate
525
- options with arrow keys and select by pressing Enter. Pressing Esc cancels.
526
-
527
- Args:
528
- questions: List of question objects, each containing:
529
- - question: The question text
530
- - options: List of option objects with value, text, and optional description
531
- - multi_select: (optional) If true, user can toggle multiple options with Space
532
- context: Tool execution context (contains chat_manager)
533
-
534
- Returns:
535
- str: Formatted tool result with exit_code and selected value(s):
536
- - "exit_code=0\\n{value}" for single question (1 item in array)
537
- - "exit_code=0\\n{value1, value2, ...}" for multi-question or multi-select
538
- - "exit_code=1\\n{error_message}" for user cancellation or validation errors
539
- """
540
- try:
541
- # Validate questions parameter
542
- if not isinstance(questions, list):
543
- return "exit_code=1\nQuestions must be a list"
544
-
545
- if not questions:
546
- return "exit_code=1\nQuestions list cannot be empty"
547
-
548
- # Validate each question
549
- for q_idx, q in enumerate(questions):
550
- if not isinstance(q, dict):
551
- return f"exit_code=1\nQuestion {q_idx + 1} must be an object"
552
-
553
- question_text = q.get("question")
554
- q_options = q.get("options")
555
-
556
- if not question_text:
557
- return f"exit_code=1\nQuestion {q_idx + 1} must have a 'question' field"
558
-
559
- if not q_options or not isinstance(q_options, list):
560
- return f"exit_code=1\nQuestion {q_idx + 1} must have a non-empty 'options' list"
561
-
562
- # Validate each option in the question
563
- for opt_idx, opt in enumerate(q_options):
564
- if not isinstance(opt, dict):
565
- return f"exit_code=1\nOption {opt_idx + 1} in question {q_idx + 1} must be an object"
566
-
567
- value = opt.get("value")
568
- text = opt.get("text")
569
-
570
- if not value or not text:
571
- return f"exit_code=1\nOption {opt_idx + 1} in question {q_idx + 1} must have 'value' and 'text' fields"
572
-
573
- # Always append custom input option to each question
574
- for q in questions:
575
- q["options"] = list(q["options"]) + [CUSTOM_INPUT_OPTION]
576
-
577
- # Create and run the selection panel
578
- panel = SelectionPanel(questions)
579
- result = panel.run()
580
-
581
- # Handle user cancellation
582
- if result is None:
583
- return "exit_code=1\nUser canceled selection"
584
-
585
- # Return the selected values (single string for 1 question, comma-separated for multiple)
586
- if isinstance(result, str):
587
- return f"exit_code=0\n{result}"
588
- else:
589
- # Result is a list (multi-question mode or multi-select)
590
- formatted = []
591
- for r in result:
592
- if isinstance(r, list):
593
- # Multi-select question: comma-separated values
594
- formatted.append(', '.join(str(v) for v in r))
595
- else:
596
- formatted.append(str(r))
597
- return f"exit_code=0\n{', '.join(formatted)}"
598
-
599
- except Exception as e:
600
- return f"exit_code=1\nError displaying selection panel: {str(e)}"