bone-agent 1.4.0 → 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 (126) 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 -201
  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 -144
  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 -50
  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/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. 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)}"