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.
- package/bin/bone.js +39 -0
- package/package.json +25 -39
- package/LICENSE +0 -21
- package/README.md +0 -184
- package/bin/npm-wrapper.js +0 -235
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +0 -141
- package/prompts/main/ask_questions.md +0 -31
- package/prompts/main/batch_independent_calls.md +0 -5
- package/prompts/main/casual_interactions.md +0 -11
- package/prompts/main/code_references.md +0 -8
- package/prompts/main/communication_style.md +0 -12
- package/prompts/main/context_reliability.md +0 -12
- package/prompts/main/conversational_tool_calling.md +0 -15
- package/prompts/main/dream.md +0 -36
- package/prompts/main/editing_pattern.md +0 -13
- package/prompts/main/error_handling.md +0 -6
- package/prompts/main/exploration_pattern.md +0 -21
- package/prompts/main/intro.md +0 -1
- package/prompts/main/obsidian.md +0 -16
- package/prompts/main/obsidian_project.md +0 -79
- package/prompts/main/professional_objectivity.md +0 -3
- package/prompts/main/targeted_searching.md +0 -10
- package/prompts/main/task_lists_pattern.md +0 -8
- package/prompts/main/temp_folder.md +0 -9
- package/prompts/main/think_before_acting.md +0 -10
- package/prompts/main/tone_and_style.md +0 -4
- package/prompts/main/tool_preferences.md +0 -24
- package/prompts/main/trust_subagent_context.md +0 -21
- package/prompts/main/when_to_use_sub_agent.md +0 -7
- package/prompts/micro/ask_questions.md +0 -1
- package/prompts/micro/batch_independent_calls.md +0 -1
- package/prompts/micro/casual_interactions.md +0 -1
- package/prompts/micro/code_references.md +0 -1
- package/prompts/micro/communication_style.md +0 -1
- package/prompts/micro/context_reliability.md +0 -1
- package/prompts/micro/conversational_tool_calling.md +0 -1
- package/prompts/micro/editing_pattern.md +0 -1
- package/prompts/micro/error_handling.md +0 -1
- package/prompts/micro/exploration_pattern.md +0 -1
- package/prompts/micro/intro.md +0 -1
- package/prompts/micro/obsidian.md +0 -4
- package/prompts/micro/obsidian_project.md +0 -5
- package/prompts/micro/professional_objectivity.md +0 -1
- package/prompts/micro/targeted_searching.md +0 -1
- package/prompts/micro/task_lists_pattern.md +0 -1
- package/prompts/micro/temp_folder.md +0 -1
- package/prompts/micro/think_before_acting.md +0 -5
- package/prompts/micro/tone_and_style.md +0 -1
- package/prompts/micro/tool_preferences.md +0 -1
- package/prompts/micro/trust_subagent_context.md +0 -1
- package/prompts/micro/when_to_use_sub_agent.md +0 -1
- package/requirements.txt +0 -9
- package/src/__init__.py +0 -11
- package/src/core/__init__.py +0 -1
- package/src/core/agentic.py +0 -985
- package/src/core/chat_manager.py +0 -1564
- package/src/core/config_manager.py +0 -253
- package/src/core/cron.py +0 -582
- package/src/core/cron_allowlist.py +0 -118
- package/src/core/memory.py +0 -145
- package/src/core/retry.py +0 -71
- package/src/core/sub_agent.py +0 -326
- package/src/core/tool_approval.py +0 -220
- package/src/core/tool_feedback.py +0 -778
- package/src/exceptions.py +0 -79
- package/src/llm/__init__.py +0 -1
- package/src/llm/client.py +0 -171
- package/src/llm/config.py +0 -492
- package/src/llm/prompts.py +0 -489
- package/src/llm/providers.py +0 -436
- package/src/llm/streaming.py +0 -163
- package/src/llm/token_tracker.py +0 -384
- package/src/tools/__init__.py +0 -212
- package/src/tools/constants.py +0 -59
- package/src/tools/create_file.py +0 -136
- package/src/tools/directory.py +0 -389
- package/src/tools/edit.py +0 -545
- package/src/tools/file_reader.py +0 -322
- package/src/tools/helpers/__init__.py +0 -105
- package/src/tools/helpers/base.py +0 -550
- package/src/tools/helpers/converters.py +0 -44
- package/src/tools/helpers/file_helpers.py +0 -189
- package/src/tools/helpers/formatters.py +0 -411
- package/src/tools/helpers/loader.py +0 -231
- package/src/tools/helpers/parallel_executor.py +0 -231
- package/src/tools/helpers/path_resolver.py +0 -232
- package/src/tools/helpers/plugin_manifest.py +0 -156
- package/src/tools/obsidian.py +0 -96
- package/src/tools/review_sub_agent.py +0 -189
- package/src/tools/rg_search.py +0 -460
- package/src/tools/search_plugins.py +0 -109
- package/src/tools/select_option.py +0 -600
- package/src/tools/shell.py +0 -302
- package/src/tools/sub_agent.py +0 -139
- package/src/tools/task_list.py +0 -269
- package/src/tools/web_search.py +0 -61
- package/src/ui/__init__.py +0 -1
- package/src/ui/banner.py +0 -87
- package/src/ui/commands.py +0 -2809
- package/src/ui/displays.py +0 -214
- package/src/ui/loader.py +0 -284
- package/src/ui/main.py +0 -647
- package/src/ui/prompt_utils.py +0 -113
- package/src/ui/setting_selector.py +0 -590
- package/src/ui/setup_wizard.py +0 -294
- package/src/ui/sub_agent_panel.py +0 -234
- package/src/ui/tool_confirmation.py +0 -215
- package/src/utils/__init__.py +0 -1
- package/src/utils/citation_parser.py +0 -199
- package/src/utils/editor.py +0 -158
- package/src/utils/gitignore_filter.py +0 -149
- package/src/utils/logger.py +0 -254
- package/src/utils/paths.py +0 -30
- package/src/utils/result_parsers.py +0 -108
- package/src/utils/safe_commands.py +0 -243
- package/src/utils/settings.py +0 -191
- package/src/utils/user_message_logger.py +0 -120
- package/src/utils/validation.py +0 -191
- 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)}"
|