python-agent-harness 1.5.0__py3-none-any.whl
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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Input handling: SlashCompleter, UiQuestion, key bindings, and the
|
|
2
|
+
InputMixin that provides prompt reading and question blocking.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from prompt_toolkit import PromptSession
|
|
13
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
14
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
15
|
+
from prompt_toolkit.history import FileHistory
|
|
16
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
17
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from .. import config
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ..session import Session
|
|
25
|
+
|
|
26
|
+
SLASH_COMMANDS = [
|
|
27
|
+
"/plan",
|
|
28
|
+
"/build",
|
|
29
|
+
"/init",
|
|
30
|
+
"/review",
|
|
31
|
+
"/explain",
|
|
32
|
+
"/compact",
|
|
33
|
+
"/save",
|
|
34
|
+
"/summary",
|
|
35
|
+
"/sessions",
|
|
36
|
+
"/restore",
|
|
37
|
+
"/clear",
|
|
38
|
+
"/model",
|
|
39
|
+
"/exit",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _custom_slash_commands() -> list[str]:
|
|
44
|
+
from ..commands import load_custom_commands
|
|
45
|
+
|
|
46
|
+
return sorted(f"/{c.name}" for c in load_custom_commands())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _history_path() -> str:
|
|
50
|
+
d = config.SESSION_DIR / "python-agent-harness"
|
|
51
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
return str(d / "input_history")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _make_key_bindings() -> KeyBindings:
|
|
56
|
+
"""Esc+Enter (or Alt+Enter) submits; plain Enter inserts a newline.
|
|
57
|
+
|
|
58
|
+
Tab triggers completion explicitly (first Tab inserts the common
|
|
59
|
+
part / opens the menu, further Tabs cycle), Shift+Tab cycles
|
|
60
|
+
backwards — prompt_toolkit's defaults don't reliably bind Tab in
|
|
61
|
+
every mode/version.
|
|
62
|
+
"""
|
|
63
|
+
kb = KeyBindings()
|
|
64
|
+
|
|
65
|
+
@kb.add("escape", "enter")
|
|
66
|
+
def _submit(event: Any) -> None:
|
|
67
|
+
event.current_buffer.validate_and_handle()
|
|
68
|
+
|
|
69
|
+
@kb.add("c-i")
|
|
70
|
+
def _complete(event: Any) -> None:
|
|
71
|
+
b = event.current_buffer
|
|
72
|
+
if b.complete_state:
|
|
73
|
+
b.complete_next()
|
|
74
|
+
else:
|
|
75
|
+
b.start_completion(insert_common_part=True)
|
|
76
|
+
|
|
77
|
+
@kb.add("s-tab")
|
|
78
|
+
def _complete_backward(event: Any) -> None:
|
|
79
|
+
b = event.current_buffer
|
|
80
|
+
if b.complete_state:
|
|
81
|
+
b.complete_previous()
|
|
82
|
+
else:
|
|
83
|
+
b.start_completion(select_first=True)
|
|
84
|
+
|
|
85
|
+
return kb
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _make_prompt_session(
|
|
89
|
+
history: FileHistory, completer: Completer, **kwargs: Any
|
|
90
|
+
) -> PromptSession:
|
|
91
|
+
"""Create the TUI's input session.
|
|
92
|
+
|
|
93
|
+
``complete_while_typing`` is off on purpose: it races with Tab's
|
|
94
|
+
``start_completion`` (a keystroke-triggered completion can create
|
|
95
|
+
the completion state just before the Tab-triggered task runs, which
|
|
96
|
+
then bails out without inserting the common part). Tab must be the
|
|
97
|
+
single, deterministic trigger.
|
|
98
|
+
"""
|
|
99
|
+
return PromptSession(
|
|
100
|
+
history=history,
|
|
101
|
+
key_bindings=_make_key_bindings(),
|
|
102
|
+
completer=completer,
|
|
103
|
+
complete_while_typing=False,
|
|
104
|
+
multiline=True,
|
|
105
|
+
enable_suspend=True,
|
|
106
|
+
**kwargs,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SlashCompleter(Completer):
|
|
111
|
+
"""Tab-completion for the input line.
|
|
112
|
+
|
|
113
|
+
- A first token starting with ``/`` completes against the known
|
|
114
|
+
slash commands (builtins + custom commands from
|
|
115
|
+
prompts/commands/*.md); if no command matches, it is treated as
|
|
116
|
+
an absolute path.
|
|
117
|
+
- After a slash command's space, Tab completes paths relative to
|
|
118
|
+
the session's project dir (absolute and ``~`` paths work too).
|
|
119
|
+
- Any other ``~``-prefixed or ``/``-containing token (e.g.
|
|
120
|
+
``~/wor``, ``docs/``) completes as a path: ``~`` against $HOME,
|
|
121
|
+
otherwise relative to the project dir. Plain words without ``/``
|
|
122
|
+
are left alone.
|
|
123
|
+
- Directories get a trailing ``/`` so repeated Tab drills deeper;
|
|
124
|
+
``~`` alone completes to ``~/``.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, get_project_dir: Callable[[], str]) -> None:
|
|
128
|
+
self.get_project_dir = get_project_dir
|
|
129
|
+
|
|
130
|
+
def _slash_commands(self) -> list[str]:
|
|
131
|
+
return sorted(set(SLASH_COMMANDS + _custom_slash_commands()))
|
|
132
|
+
|
|
133
|
+
def _complete_paths(self, arg: str) -> Iterable[Completion]:
|
|
134
|
+
expanded = os.path.expanduser(arg)
|
|
135
|
+
if not arg:
|
|
136
|
+
directory, prefix = self.get_project_dir() or os.getcwd(), ""
|
|
137
|
+
elif expanded.endswith(os.sep):
|
|
138
|
+
base = (
|
|
139
|
+
expanded
|
|
140
|
+
if os.path.isabs(expanded)
|
|
141
|
+
else os.path.join(self.get_project_dir() or os.getcwd(), expanded)
|
|
142
|
+
)
|
|
143
|
+
directory, prefix = base, ""
|
|
144
|
+
elif os.path.isdir(expanded):
|
|
145
|
+
# "~" or an existing dir without a trailing slash: complete
|
|
146
|
+
# the trailing slash itself (bash-style), not its siblings.
|
|
147
|
+
yield Completion(text="/", start_position=0, display=arg + "/")
|
|
148
|
+
return
|
|
149
|
+
else:
|
|
150
|
+
base = (
|
|
151
|
+
expanded
|
|
152
|
+
if os.path.isabs(expanded)
|
|
153
|
+
else os.path.join(self.get_project_dir() or os.getcwd(), expanded)
|
|
154
|
+
)
|
|
155
|
+
directory, prefix = os.path.dirname(base), os.path.basename(base)
|
|
156
|
+
try:
|
|
157
|
+
entries = sorted(os.listdir(directory or "."))
|
|
158
|
+
except OSError:
|
|
159
|
+
return
|
|
160
|
+
for name in entries:
|
|
161
|
+
if not name.startswith(prefix):
|
|
162
|
+
continue
|
|
163
|
+
suffix = name[len(prefix) :]
|
|
164
|
+
if os.path.isdir(os.path.join(directory, name)):
|
|
165
|
+
suffix += "/"
|
|
166
|
+
display = name + "/"
|
|
167
|
+
else:
|
|
168
|
+
display = name
|
|
169
|
+
# start_position=0 appends at the cursor; the typed prefix is
|
|
170
|
+
# already in the buffer, so only the remaining suffix is inserted.
|
|
171
|
+
yield Completion(text=suffix, start_position=0, display=display)
|
|
172
|
+
|
|
173
|
+
def get_completions(self, document: Any, complete_event: Any):
|
|
174
|
+
text = document.text_before_cursor
|
|
175
|
+
if text.startswith("/"):
|
|
176
|
+
if " " not in text:
|
|
177
|
+
cmds = [c for c in self._slash_commands() if c.startswith(text)]
|
|
178
|
+
for cmd in cmds:
|
|
179
|
+
yield Completion(cmd, start_position=-len(text))
|
|
180
|
+
if cmds:
|
|
181
|
+
return
|
|
182
|
+
yield from self._complete_paths(text) # absolute path
|
|
183
|
+
return
|
|
184
|
+
arg = text.split(" ", 1)[1]
|
|
185
|
+
yield from self._complete_paths(arg)
|
|
186
|
+
return
|
|
187
|
+
token = text.rsplit(" ", 1)[-1] if " " in text else text
|
|
188
|
+
if token.startswith("~") or "/" in token:
|
|
189
|
+
yield from self._complete_paths(token)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class UiQuestion:
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
prompt: str,
|
|
196
|
+
multiple: bool = False,
|
|
197
|
+
options: list[str] | None = None,
|
|
198
|
+
custom: bool = True,
|
|
199
|
+
keys: list[str] | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
self.prompt = prompt
|
|
202
|
+
self.multiple = multiple
|
|
203
|
+
self.options = options or []
|
|
204
|
+
self.custom = custom
|
|
205
|
+
# keyed choices (e.g. ["y", "n"] for a confirm): render the
|
|
206
|
+
# options as a keyed list and resolve typed keys to labels,
|
|
207
|
+
# instead of the numbered-list style of the Question tool
|
|
208
|
+
self.keys = keys or []
|
|
209
|
+
self.answer: str | None = None
|
|
210
|
+
self.event = threading.Event()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _resolve_keyed_choice(answer: str, options: list[str], keys: list[str]) -> str:
|
|
214
|
+
"""Map bare keys in ANSWER to the matching option label.
|
|
215
|
+
|
|
216
|
+
Comma-separated keys pick several options (multiple select);
|
|
217
|
+
non-key tokens pass through unchanged as free-text answers.
|
|
218
|
+
"""
|
|
219
|
+
if not options or not keys or not answer.strip():
|
|
220
|
+
return answer
|
|
221
|
+
resolved: list[str] = []
|
|
222
|
+
for part in answer.split(","):
|
|
223
|
+
part = part.strip()
|
|
224
|
+
if part.lower() in keys:
|
|
225
|
+
resolved.append(options[keys.index(part.lower())])
|
|
226
|
+
continue
|
|
227
|
+
resolved.append(part)
|
|
228
|
+
return ", ".join(resolved)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_numbered_choice(answer: str, options: list[str]) -> str:
|
|
232
|
+
"""Map bare numbers in ANSWER (1-based) to the matching option label.
|
|
233
|
+
|
|
234
|
+
Comma-separated numbers pick several options (multiple select);
|
|
235
|
+
non-numeric tokens pass through unchanged as free-text answers;
|
|
236
|
+
out-of-range numbers are kept as typed. Empty answers stay empty.
|
|
237
|
+
"""
|
|
238
|
+
if not options or not answer.strip():
|
|
239
|
+
return answer
|
|
240
|
+
resolved: list[str] = []
|
|
241
|
+
for part in answer.split(","):
|
|
242
|
+
part = part.strip()
|
|
243
|
+
if part.isdigit():
|
|
244
|
+
idx = int(part)
|
|
245
|
+
if 1 <= idx <= len(options):
|
|
246
|
+
resolved.append(options[idx - 1])
|
|
247
|
+
continue
|
|
248
|
+
resolved.append(part)
|
|
249
|
+
return ", ".join(resolved)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class InputMixin:
|
|
253
|
+
"""Input handling methods for the TUI.
|
|
254
|
+
|
|
255
|
+
Expects the host class to provide: ``session``, ``console``,
|
|
256
|
+
``question``, ``prompt_session``, ``_data_event``.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
if TYPE_CHECKING:
|
|
260
|
+
session: Session
|
|
261
|
+
console: Console
|
|
262
|
+
question: UiQuestion | None
|
|
263
|
+
prompt_session: PromptSession
|
|
264
|
+
_data_event: threading.Event
|
|
265
|
+
|
|
266
|
+
def _render_frame(self) -> Any: ...
|
|
267
|
+
|
|
268
|
+
def _flush(self) -> None: ...
|
|
269
|
+
|
|
270
|
+
def _input_prompt(self) -> FormattedText:
|
|
271
|
+
"""Styled input prompt: short model name + caret.
|
|
272
|
+
|
|
273
|
+
Shows the model actually in use (the part after the last '/',
|
|
274
|
+
e.g. ``deepseek-ai/deepseek-flash-v4`` → ``deepseek-flash-v4``)
|
|
275
|
+
so the active model stays visible while typing.
|
|
276
|
+
"""
|
|
277
|
+
model = self.session.model or ""
|
|
278
|
+
short = model.rsplit("/", 1)[-1] if "/" in model else model
|
|
279
|
+
title = getattr(self.session.store, "title", None)
|
|
280
|
+
if title:
|
|
281
|
+
title = title.strip()
|
|
282
|
+
if len(title) > 20:
|
|
283
|
+
title = title[:20]
|
|
284
|
+
return FormattedText(
|
|
285
|
+
[
|
|
286
|
+
("bold cyan", f"{short} " if short else ""),
|
|
287
|
+
("dim", f"({title}) "),
|
|
288
|
+
("ansibrightblack", "> "),
|
|
289
|
+
]
|
|
290
|
+
)
|
|
291
|
+
return FormattedText(
|
|
292
|
+
[
|
|
293
|
+
("bold cyan", f"{short} " if short else ""),
|
|
294
|
+
("ansibrightblack", "> "),
|
|
295
|
+
]
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _read_multiline(self) -> str | None:
|
|
299
|
+
try:
|
|
300
|
+
with patch_stdout():
|
|
301
|
+
text = self.prompt_session.prompt(self._input_prompt())
|
|
302
|
+
except EOFError:
|
|
303
|
+
# Ctrl-D: quit
|
|
304
|
+
return None
|
|
305
|
+
except KeyboardInterrupt:
|
|
306
|
+
# Ctrl-C: cancel this input, stay in the app
|
|
307
|
+
self.console.print("[dim]input cancelled[/dim]")
|
|
308
|
+
return ""
|
|
309
|
+
return text
|
|
310
|
+
|
|
311
|
+
def _ask_question_blocking(self) -> None:
|
|
312
|
+
q = self.question
|
|
313
|
+
if q is None:
|
|
314
|
+
return
|
|
315
|
+
self.console.print(self._render_frame())
|
|
316
|
+
self.console.print()
|
|
317
|
+
self._flush()
|
|
318
|
+
options = q.options or []
|
|
319
|
+
keys = q.keys or []
|
|
320
|
+
if keys and options and len(keys) == len(options):
|
|
321
|
+
# keyed choices (e.g. y/n confirm): type the key to pick —
|
|
322
|
+
# same list look as the Question tool, keys instead of numbers
|
|
323
|
+
self.console.print(Text(q.prompt))
|
|
324
|
+
for key, opt in zip(keys, options, strict=True):
|
|
325
|
+
line = Text(f" {key}) ", style="cyan")
|
|
326
|
+
line.append(opt)
|
|
327
|
+
self.console.print(line)
|
|
328
|
+
hint = "Enter keys, comma-separated" if q.multiple else "Enter a key"
|
|
329
|
+
if q.custom:
|
|
330
|
+
hint += ", or type your own answer"
|
|
331
|
+
self.console.print(f"[dim]{hint}[/dim]")
|
|
332
|
+
prompt = "> "
|
|
333
|
+
elif options:
|
|
334
|
+
# option labels get a numbered list: type the number to pick
|
|
335
|
+
self.console.print(Text(q.prompt))
|
|
336
|
+
for i, opt in enumerate(options, 1):
|
|
337
|
+
line = Text(f" {i}) ", style="cyan")
|
|
338
|
+
line.append(opt)
|
|
339
|
+
self.console.print(line)
|
|
340
|
+
hint = "Enter numbers, comma-separated" if q.multiple else "Enter a number"
|
|
341
|
+
if q.custom:
|
|
342
|
+
hint += ", or type your own answer"
|
|
343
|
+
self.console.print(f"[dim]{hint}[/dim]")
|
|
344
|
+
prompt = "> "
|
|
345
|
+
else:
|
|
346
|
+
prompt = q.prompt + " > "
|
|
347
|
+
try:
|
|
348
|
+
with patch_stdout():
|
|
349
|
+
answer = self.prompt_session.prompt(prompt, multiline=False)
|
|
350
|
+
except (EOFError, KeyboardInterrupt):
|
|
351
|
+
answer = ""
|
|
352
|
+
if keys:
|
|
353
|
+
q.answer = _resolve_keyed_choice(answer, options, keys)
|
|
354
|
+
else:
|
|
355
|
+
q.answer = _resolve_numbered_choice(answer, options)
|
|
356
|
+
q.event.set()
|
|
357
|
+
self.question = None
|
|
358
|
+
self._data_event.set() # re-render promptly after the answer
|
|
359
|
+
|
|
360
|
+
def _ask_sync(self, q: UiQuestion) -> str:
|
|
361
|
+
"""Block the worker thread until the main thread answers.
|
|
362
|
+
|
|
363
|
+
Cancel-aware: the wait polls the session cancel event, so a
|
|
364
|
+
Ctrl-C outside the answer prompt (e.g. during the render loop)
|
|
365
|
+
unblocks the worker immediately instead of wedging it until a
|
|
366
|
+
question is answered. A cancelled run returns an empty answer.
|
|
367
|
+
"""
|
|
368
|
+
self.question = q
|
|
369
|
+
cancel = getattr(self.session, "cancel_event", None)
|
|
370
|
+
while not q.event.wait(0.1):
|
|
371
|
+
if cancel is not None and cancel.is_set():
|
|
372
|
+
return ""
|
|
373
|
+
return q.answer or ""
|
|
374
|
+
|
|
375
|
+
def _ui_confirm(self, prompt: str) -> bool:
|
|
376
|
+
"""PlanExit confirmation: same look as the Question tool, but a
|
|
377
|
+
y/n keyed choice list instead of numbers (two choices only)."""
|
|
378
|
+
q = UiQuestion(
|
|
379
|
+
prompt,
|
|
380
|
+
options=list(config.PLAN_EXIT_OPTIONS),
|
|
381
|
+
keys=["y", "n"],
|
|
382
|
+
custom=False,
|
|
383
|
+
)
|
|
384
|
+
answer = self._ask_sync(q).strip().lower()
|
|
385
|
+
# resolved answers arrive as the option label; legacy free-text
|
|
386
|
+
# (y/yes/a/1/true) keeps working for muscle memory
|
|
387
|
+
return answer == config.PLAN_EXIT_OPTIONS[0].lower() or answer in (
|
|
388
|
+
"y",
|
|
389
|
+
"yes",
|
|
390
|
+
"a",
|
|
391
|
+
"true",
|
|
392
|
+
"1",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def _ui_ask(self, questions: list[dict]) -> str:
|
|
396
|
+
lines = []
|
|
397
|
+
for q in questions:
|
|
398
|
+
prompt = q.get("question", "")
|
|
399
|
+
options = q.get("options") or []
|
|
400
|
+
multiple = bool(q.get("multiple"))
|
|
401
|
+
custom = q.get("custom", True)
|
|
402
|
+
ui_q = UiQuestion(
|
|
403
|
+
prompt,
|
|
404
|
+
multiple=multiple,
|
|
405
|
+
options=list(options),
|
|
406
|
+
custom=custom,
|
|
407
|
+
)
|
|
408
|
+
answer = self._ask_sync(ui_q)
|
|
409
|
+
if multiple:
|
|
410
|
+
answer = ", ".join(a.strip() for a in answer.split(",") if a.strip())
|
|
411
|
+
lines.append(f'"{prompt}" = "{answer}"')
|
|
412
|
+
return "\n".join(lines) if lines else "Unanswered"
|