deepy-cli 0.1.1__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.
- deepy/__init__.py +9 -0
- deepy/__main__.py +7 -0
- deepy/cli.py +413 -0
- deepy/config/__init__.py +21 -0
- deepy/config/settings.py +237 -0
- deepy/data/__init__.py +1 -0
- deepy/data/tools/AskUserQuestion.md +10 -0
- deepy/data/tools/WebFetch.md +9 -0
- deepy/data/tools/WebSearch.md +9 -0
- deepy/data/tools/__init__.py +1 -0
- deepy/data/tools/bash.md +7 -0
- deepy/data/tools/edit.md +13 -0
- deepy/data/tools/modify.md +17 -0
- deepy/data/tools/read.md +8 -0
- deepy/data/tools/write.md +12 -0
- deepy/errors.py +63 -0
- deepy/llm/__init__.py +13 -0
- deepy/llm/agent.py +31 -0
- deepy/llm/context.py +109 -0
- deepy/llm/events.py +187 -0
- deepy/llm/model_capabilities.py +7 -0
- deepy/llm/provider.py +81 -0
- deepy/llm/replay.py +120 -0
- deepy/llm/runner.py +412 -0
- deepy/llm/thinking.py +30 -0
- deepy/prompts/__init__.py +6 -0
- deepy/prompts/compact.py +100 -0
- deepy/prompts/rules.py +24 -0
- deepy/prompts/runtime_context.py +98 -0
- deepy/prompts/system.py +72 -0
- deepy/prompts/tool_docs.py +21 -0
- deepy/sessions/__init__.py +17 -0
- deepy/sessions/jsonl.py +306 -0
- deepy/sessions/manager.py +202 -0
- deepy/skills.py +202 -0
- deepy/status.py +65 -0
- deepy/tools/__init__.py +6 -0
- deepy/tools/agents.py +343 -0
- deepy/tools/builtin.py +2113 -0
- deepy/tools/file_state.py +85 -0
- deepy/tools/result.py +54 -0
- deepy/tools/shell_utils.py +83 -0
- deepy/ui/__init__.py +5 -0
- deepy/ui/app.py +118 -0
- deepy/ui/ask_user_question.py +182 -0
- deepy/ui/exit_summary.py +142 -0
- deepy/ui/loading_text.py +87 -0
- deepy/ui/markdown.py +152 -0
- deepy/ui/message_view.py +546 -0
- deepy/ui/prompt_buffer.py +176 -0
- deepy/ui/prompt_input.py +286 -0
- deepy/ui/session_list.py +140 -0
- deepy/ui/session_picker.py +179 -0
- deepy/ui/slash_commands.py +67 -0
- deepy/ui/styles.py +21 -0
- deepy/ui/terminal.py +959 -0
- deepy/ui/thinking_state.py +29 -0
- deepy/ui/welcome.py +195 -0
- deepy/update_check.py +195 -0
- deepy/usage.py +192 -0
- deepy/utils/__init__.py +15 -0
- deepy/utils/debug_logger.py +62 -0
- deepy/utils/error_logger.py +107 -0
- deepy/utils/json.py +29 -0
- deepy/utils/notify.py +66 -0
- deepy_cli-0.1.1.dist-info/METADATA +205 -0
- deepy_cli-0.1.1.dist-info/RECORD +69 -0
- deepy_cli-0.1.1.dist-info/WHEEL +4 -0
- deepy_cli-0.1.1.dist-info/entry_points.txt +3 -0
deepy/ui/terminal.py
ADDED
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.prompt import Prompt
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from deepy import __version__
|
|
16
|
+
from deepy.config import Settings
|
|
17
|
+
from deepy.llm.events import DeepyStreamEvent
|
|
18
|
+
from deepy.llm.runner import RunSummary, run_prompt_once
|
|
19
|
+
from deepy.sessions import DeepyJsonlSession, SessionEntry, list_session_entries
|
|
20
|
+
from deepy.skills import discover_skills, find_skill, format_skills_for_terminal, read_skill_body
|
|
21
|
+
from deepy.status import build_status_report, format_status_report
|
|
22
|
+
from deepy.update_check import VersionUpdate
|
|
23
|
+
from deepy.update_check import check_for_version_update
|
|
24
|
+
from deepy.ui.ask_user_question import OTHER_VALUE
|
|
25
|
+
from deepy.ui.ask_user_question import AskUserQuestionItem
|
|
26
|
+
from deepy.ui.ask_user_question import AskUserQuestionOptionEntry
|
|
27
|
+
from deepy.ui.ask_user_question import build_answer_for_question
|
|
28
|
+
from deepy.ui.ask_user_question import build_options
|
|
29
|
+
from deepy.ui.ask_user_question import format_ask_user_question_answers
|
|
30
|
+
from deepy.ui.ask_user_question import format_ask_user_question_decline
|
|
31
|
+
from deepy.ui.ask_user_question import normalize_questions
|
|
32
|
+
from deepy.ui.exit_summary import build_exit_summary_text
|
|
33
|
+
from deepy.ui.message_view import (
|
|
34
|
+
build_thinking_summary,
|
|
35
|
+
format_tool_call_summary,
|
|
36
|
+
format_tool_progress_summary,
|
|
37
|
+
parse_tool_output,
|
|
38
|
+
render_tool_diff_preview,
|
|
39
|
+
)
|
|
40
|
+
from deepy.ui.markdown import render_markdown
|
|
41
|
+
from deepy.ui.prompt_input import CTRL_D_EXIT_CONFIRM_SIGNAL
|
|
42
|
+
from deepy.ui.prompt_input import build_prompt_toolbar, create_prompt_session, prompt_for_input
|
|
43
|
+
from deepy.ui.session_list import resolve_session_selection
|
|
44
|
+
from deepy.ui.session_picker import ResumeSessionPreview
|
|
45
|
+
from deepy.ui.session_picker import format_resume_session_choices
|
|
46
|
+
from deepy.ui.session_picker import pick_resume_session
|
|
47
|
+
from deepy.ui.slash_commands import build_slash_commands
|
|
48
|
+
from deepy.ui.styles import (
|
|
49
|
+
STYLE_ASSISTANT,
|
|
50
|
+
STYLE_ERROR,
|
|
51
|
+
STYLE_INFO,
|
|
52
|
+
STYLE_MUTED,
|
|
53
|
+
STYLE_USER,
|
|
54
|
+
status_style,
|
|
55
|
+
)
|
|
56
|
+
from deepy.ui.welcome import build_welcome_panel
|
|
57
|
+
from deepy.usage import TokenUsage, format_usage_line
|
|
58
|
+
from deepy.utils import json as json_utils
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
RunOnce = Callable[..., Awaitable[RunSummary]]
|
|
62
|
+
InputFunc = Callable[[str], str]
|
|
63
|
+
VersionUpdateChecker = Callable[[str], VersionUpdate | None]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class SlashCommand:
|
|
68
|
+
name: str
|
|
69
|
+
argument: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ToolCallDisplay:
|
|
74
|
+
summary: str
|
|
75
|
+
name: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_slash_command(text: str) -> SlashCommand | None:
|
|
79
|
+
stripped = text.strip()
|
|
80
|
+
if not stripped.startswith("/"):
|
|
81
|
+
return None
|
|
82
|
+
command, _, argument = stripped[1:].partition(" ")
|
|
83
|
+
return SlashCommand(name=command, argument=argument.strip())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_interactive(
|
|
87
|
+
settings: Settings,
|
|
88
|
+
*,
|
|
89
|
+
project_root: Path | None = None,
|
|
90
|
+
console: Console | None = None,
|
|
91
|
+
run_once: RunOnce = run_prompt_once,
|
|
92
|
+
version_update_checker: VersionUpdateChecker | None = check_for_version_update,
|
|
93
|
+
) -> int:
|
|
94
|
+
root = (project_root or Path.cwd()).resolve()
|
|
95
|
+
output = console or Console()
|
|
96
|
+
session_id: str | None = None
|
|
97
|
+
version_update = _check_startup_version_update(version_update_checker)
|
|
98
|
+
|
|
99
|
+
loaded_skill_names: list[str] = []
|
|
100
|
+
ctrl_d_exit_pending = False
|
|
101
|
+
context_status = _format_context_footer(
|
|
102
|
+
session_id,
|
|
103
|
+
project_root=root,
|
|
104
|
+
settings=settings,
|
|
105
|
+
)
|
|
106
|
+
prompt_session = create_prompt_session(
|
|
107
|
+
slash_commands=build_slash_commands(discover_skills(root)),
|
|
108
|
+
)
|
|
109
|
+
output.print(
|
|
110
|
+
build_welcome_panel(
|
|
111
|
+
model=settings.model.name,
|
|
112
|
+
thinking_enabled=settings.model.thinking_enabled,
|
|
113
|
+
reasoning_effort=settings.model.reasoning_effort,
|
|
114
|
+
project_root=root,
|
|
115
|
+
skills=discover_skills(root),
|
|
116
|
+
current_version=__version__,
|
|
117
|
+
version_update=version_update,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
text = prompt_for_input(
|
|
124
|
+
prompt_session,
|
|
125
|
+
bottom_toolbar=build_prompt_toolbar(context_status),
|
|
126
|
+
)
|
|
127
|
+
except EOFError:
|
|
128
|
+
if ctrl_d_exit_pending:
|
|
129
|
+
output.print()
|
|
130
|
+
return 0
|
|
131
|
+
ctrl_d_exit_pending = True
|
|
132
|
+
output.print(f"[{STYLE_MUTED}]Press Ctrl+D again to exit.[/]")
|
|
133
|
+
continue
|
|
134
|
+
except KeyboardInterrupt:
|
|
135
|
+
output.print()
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
if text == CTRL_D_EXIT_CONFIRM_SIGNAL:
|
|
139
|
+
if ctrl_d_exit_pending:
|
|
140
|
+
output.print()
|
|
141
|
+
return 0
|
|
142
|
+
ctrl_d_exit_pending = True
|
|
143
|
+
output.print(f"[{STYLE_MUTED}]Press Ctrl+D again to exit.[/]")
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
ctrl_d_exit_pending = False
|
|
147
|
+
if not text:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
slash = parse_slash_command(text)
|
|
151
|
+
if slash is not None:
|
|
152
|
+
next_session = _handle_slash_command(
|
|
153
|
+
slash,
|
|
154
|
+
output,
|
|
155
|
+
root,
|
|
156
|
+
session_id,
|
|
157
|
+
loaded_skill_names,
|
|
158
|
+
settings=settings,
|
|
159
|
+
)
|
|
160
|
+
if next_session == "__exit__":
|
|
161
|
+
return 0
|
|
162
|
+
session_id = next_session
|
|
163
|
+
if slash.name in {"new", "resume"}:
|
|
164
|
+
context_status = _format_context_footer(
|
|
165
|
+
session_id,
|
|
166
|
+
project_root=root,
|
|
167
|
+
settings=settings,
|
|
168
|
+
)
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
_print_user_input(output, text)
|
|
172
|
+
summary = _run_once_with_status(
|
|
173
|
+
output,
|
|
174
|
+
run_once,
|
|
175
|
+
text,
|
|
176
|
+
project_root=root,
|
|
177
|
+
settings=settings,
|
|
178
|
+
session_id=session_id,
|
|
179
|
+
skill_names=list(loaded_skill_names),
|
|
180
|
+
)
|
|
181
|
+
session_id = summary.session_id
|
|
182
|
+
if summary.status == "waiting_for_user":
|
|
183
|
+
response = _collect_pending_question_response(output, summary.pending_questions)
|
|
184
|
+
if response:
|
|
185
|
+
_print_user_input(output, response)
|
|
186
|
+
summary = _run_once_with_status(
|
|
187
|
+
output,
|
|
188
|
+
run_once,
|
|
189
|
+
response,
|
|
190
|
+
project_root=root,
|
|
191
|
+
settings=settings,
|
|
192
|
+
session_id=session_id,
|
|
193
|
+
skill_names=list(loaded_skill_names),
|
|
194
|
+
)
|
|
195
|
+
session_id = summary.session_id
|
|
196
|
+
_print_assistant_output(output, summary.output)
|
|
197
|
+
_print_usage_footer(output, summary, settings=settings, project_root=root)
|
|
198
|
+
context_status = _format_context_footer(
|
|
199
|
+
summary.session_id,
|
|
200
|
+
project_root=root,
|
|
201
|
+
settings=settings,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _check_startup_version_update(
|
|
206
|
+
version_update_checker: VersionUpdateChecker | None,
|
|
207
|
+
) -> VersionUpdate | None:
|
|
208
|
+
if version_update_checker is None:
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
return version_update_checker(__version__)
|
|
212
|
+
except Exception:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run_once_with_status(
|
|
217
|
+
console: Console,
|
|
218
|
+
run_once: RunOnce,
|
|
219
|
+
prompt: str,
|
|
220
|
+
**kwargs: object,
|
|
221
|
+
) -> RunSummary:
|
|
222
|
+
original_emit_event = kwargs.pop("emit_event", None)
|
|
223
|
+
project_root = kwargs.get("project_root")
|
|
224
|
+
project_root_text = str(project_root) if project_root is not None else None
|
|
225
|
+
renderer: TerminalStreamRenderer | None = None
|
|
226
|
+
started_at = time.monotonic()
|
|
227
|
+
|
|
228
|
+
with console.status(_working_status_text(started_at), spinner="dots") as status:
|
|
229
|
+
renderer = TerminalStreamRenderer(
|
|
230
|
+
console,
|
|
231
|
+
project_root=project_root_text,
|
|
232
|
+
status=status,
|
|
233
|
+
status_started_at=started_at,
|
|
234
|
+
)
|
|
235
|
+
stop_status_refresh = threading.Event()
|
|
236
|
+
status_thread = threading.Thread(
|
|
237
|
+
target=_refresh_working_status,
|
|
238
|
+
args=(renderer, stop_status_refresh),
|
|
239
|
+
daemon=True,
|
|
240
|
+
)
|
|
241
|
+
status_thread.start()
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
def emit_event(event: DeepyStreamEvent) -> None:
|
|
245
|
+
renderer(event)
|
|
246
|
+
if callable(original_emit_event):
|
|
247
|
+
original_emit_event(event)
|
|
248
|
+
|
|
249
|
+
summary = asyncio.run(run_once(prompt, **kwargs, emit_event=emit_event))
|
|
250
|
+
finally:
|
|
251
|
+
stop_status_refresh.set()
|
|
252
|
+
status_thread.join(timeout=0.2)
|
|
253
|
+
|
|
254
|
+
renderer.flush()
|
|
255
|
+
return summary
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TerminalStreamRenderer:
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
console: Console,
|
|
262
|
+
*,
|
|
263
|
+
project_root: str | None = None,
|
|
264
|
+
status: Any | None = None,
|
|
265
|
+
status_started_at: float | None = None,
|
|
266
|
+
) -> None:
|
|
267
|
+
self.console = console
|
|
268
|
+
self.project_root = project_root
|
|
269
|
+
self.status = status
|
|
270
|
+
self.status_started_at = (
|
|
271
|
+
status_started_at if status_started_at is not None else time.monotonic()
|
|
272
|
+
)
|
|
273
|
+
self.status_detail = ""
|
|
274
|
+
self.pending_tool_calls: dict[str, ToolCallDisplay] = {}
|
|
275
|
+
self.reasoning_text = ""
|
|
276
|
+
self.reasoning_flushed = False
|
|
277
|
+
|
|
278
|
+
def __call__(self, event: DeepyStreamEvent) -> None:
|
|
279
|
+
_print_stream_event(
|
|
280
|
+
self.console,
|
|
281
|
+
event,
|
|
282
|
+
project_root=self.project_root,
|
|
283
|
+
pending_tool_calls=self.pending_tool_calls,
|
|
284
|
+
reasoning_sink=self,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def add_reasoning(self, text: str) -> None:
|
|
288
|
+
if self.reasoning_flushed:
|
|
289
|
+
self.reasoning_text = ""
|
|
290
|
+
self.reasoning_flushed = False
|
|
291
|
+
self.reasoning_text += text
|
|
292
|
+
summary = build_thinking_summary(self.reasoning_text)
|
|
293
|
+
if self.status is not None and summary:
|
|
294
|
+
self.update_status(f"Thinking {summary}")
|
|
295
|
+
|
|
296
|
+
def set_tool_status(self, summary: str) -> None:
|
|
297
|
+
if self.status is not None and summary:
|
|
298
|
+
self.update_status(f"Running {summary}")
|
|
299
|
+
|
|
300
|
+
def update_status(self, detail: str | None = None) -> None:
|
|
301
|
+
if detail is not None:
|
|
302
|
+
self.status_detail = detail
|
|
303
|
+
if self.status is not None:
|
|
304
|
+
self.status.update(_working_status_text(self.status_started_at, self.status_detail))
|
|
305
|
+
|
|
306
|
+
def flush(self) -> None:
|
|
307
|
+
if self.reasoning_flushed:
|
|
308
|
+
return
|
|
309
|
+
summary = build_thinking_summary(self.reasoning_text)
|
|
310
|
+
if not summary:
|
|
311
|
+
return
|
|
312
|
+
self.console.print(
|
|
313
|
+
Text.assemble(
|
|
314
|
+
("• ", STYLE_MUTED),
|
|
315
|
+
("Thinking ", f"bold {STYLE_MUTED}"),
|
|
316
|
+
(summary, STYLE_MUTED),
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
self.reasoning_flushed = True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _handle_slash_command(
|
|
323
|
+
command: SlashCommand,
|
|
324
|
+
console: Console,
|
|
325
|
+
project_root: Path,
|
|
326
|
+
current_session_id: str | None,
|
|
327
|
+
loaded_skill_names: list[str] | None = None,
|
|
328
|
+
settings: Settings | None = None,
|
|
329
|
+
input_func: InputFunc | None = None,
|
|
330
|
+
) -> str | None:
|
|
331
|
+
loaded_skill_names = loaded_skill_names if loaded_skill_names is not None else []
|
|
332
|
+
settings = settings or Settings()
|
|
333
|
+
if command.name in {"exit", "quit"}:
|
|
334
|
+
_print_exit_summary(console, project_root, current_session_id, settings)
|
|
335
|
+
return "__exit__"
|
|
336
|
+
if command.name == "help":
|
|
337
|
+
console.print("/help Show commands")
|
|
338
|
+
console.print("/skills List available skills")
|
|
339
|
+
console.print("/skill NAME Show a skill document")
|
|
340
|
+
console.print("/use NAME Load a skill for subsequent prompts")
|
|
341
|
+
console.print("/status Show project status")
|
|
342
|
+
console.print("/sessions List project sessions")
|
|
343
|
+
console.print("/resume ID Resume a session")
|
|
344
|
+
console.print("/new Start a new session")
|
|
345
|
+
console.print("/exit Quit")
|
|
346
|
+
return current_session_id
|
|
347
|
+
if command.name == "new":
|
|
348
|
+
loaded_skill_names.clear()
|
|
349
|
+
console.print("Started a new session.")
|
|
350
|
+
return None
|
|
351
|
+
if command.name == "resume":
|
|
352
|
+
entries = list_session_entries(project_root)
|
|
353
|
+
previews = _build_resume_session_previews(project_root, entries)
|
|
354
|
+
if command.argument:
|
|
355
|
+
selected = resolve_session_selection(entries, command.argument)
|
|
356
|
+
session_id = selected.id if selected is not None else command.argument
|
|
357
|
+
_resume_session(console, project_root, session_id)
|
|
358
|
+
return session_id
|
|
359
|
+
if not entries:
|
|
360
|
+
console.print("No sessions found.")
|
|
361
|
+
return current_session_id
|
|
362
|
+
invalid_selection = False
|
|
363
|
+
if input_func is not None:
|
|
364
|
+
console.print(format_resume_session_choices(previews))
|
|
365
|
+
selection = input_func("Resume session number or id")
|
|
366
|
+
selected = resolve_session_selection(entries, selection)
|
|
367
|
+
session_id = selected.id if selected is not None else ""
|
|
368
|
+
invalid_selection = bool(selection.strip()) and selected is None
|
|
369
|
+
else:
|
|
370
|
+
session_id = pick_resume_session(previews) or ""
|
|
371
|
+
selected = resolve_session_selection(entries, session_id) if session_id else None
|
|
372
|
+
invalid_selection = bool(session_id) and selected is None
|
|
373
|
+
if selected is None:
|
|
374
|
+
message = "Invalid session selection." if invalid_selection else "Resume canceled."
|
|
375
|
+
style = STYLE_ERROR if invalid_selection else STYLE_MUTED
|
|
376
|
+
console.print(f"[{style}]{message}[/]")
|
|
377
|
+
return current_session_id
|
|
378
|
+
_resume_session(console, project_root, selected.id)
|
|
379
|
+
return selected.id
|
|
380
|
+
if command.name == "sessions":
|
|
381
|
+
entries = list_session_entries(project_root)
|
|
382
|
+
if not entries:
|
|
383
|
+
console.print("No sessions found.")
|
|
384
|
+
return current_session_id
|
|
385
|
+
for entry in entries:
|
|
386
|
+
console.print(
|
|
387
|
+
f"{entry.id}\tupdated={entry.updated_at}\thistory_tokens={entry.active_tokens}\t"
|
|
388
|
+
f"{format_usage_line(entry.usage)}"
|
|
389
|
+
)
|
|
390
|
+
return current_session_id
|
|
391
|
+
if command.name == "status":
|
|
392
|
+
console.print(format_status_report(build_status_report(project_root, settings)))
|
|
393
|
+
return current_session_id
|
|
394
|
+
if command.name == "skills":
|
|
395
|
+
console.print(format_skills_for_terminal(discover_skills(project_root)))
|
|
396
|
+
return current_session_id
|
|
397
|
+
if command.name == "skill":
|
|
398
|
+
if not command.argument:
|
|
399
|
+
console.print(f"[{STYLE_ERROR}]Usage:[/] /skill NAME")
|
|
400
|
+
return current_session_id
|
|
401
|
+
skill = find_skill(project_root, command.argument)
|
|
402
|
+
if skill is None:
|
|
403
|
+
console.print(f"[{STYLE_ERROR}]Skill not found:[/] {command.argument}")
|
|
404
|
+
return current_session_id
|
|
405
|
+
console.print(read_skill_body(skill) or "(empty skill)")
|
|
406
|
+
return current_session_id
|
|
407
|
+
if command.name == "use":
|
|
408
|
+
if not command.argument:
|
|
409
|
+
console.print(f"[{STYLE_ERROR}]Usage:[/] /use NAME")
|
|
410
|
+
return current_session_id
|
|
411
|
+
skill = find_skill(project_root, command.argument)
|
|
412
|
+
if skill is None:
|
|
413
|
+
console.print(f"[{STYLE_ERROR}]Skill not found:[/] {command.argument}")
|
|
414
|
+
return current_session_id
|
|
415
|
+
if skill.name not in loaded_skill_names:
|
|
416
|
+
loaded_skill_names.append(skill.name)
|
|
417
|
+
console.print(f"Loaded skill: {skill.name}")
|
|
418
|
+
return current_session_id
|
|
419
|
+
|
|
420
|
+
console.print(f"[{STYLE_ERROR}]Unknown command:[/] /{command.name}")
|
|
421
|
+
return current_session_id
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _resume_session(console: Console, project_root: Path, session_id: str) -> None:
|
|
425
|
+
console.print(Text.assemble(("Resuming session ", STYLE_MUTED), (session_id, STYLE_INFO)))
|
|
426
|
+
_print_session_history(console, project_root, session_id)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _build_resume_session_previews(
|
|
430
|
+
project_root: Path,
|
|
431
|
+
entries: list[SessionEntry],
|
|
432
|
+
) -> list[ResumeSessionPreview]:
|
|
433
|
+
previews: list[ResumeSessionPreview] = []
|
|
434
|
+
for entry in entries:
|
|
435
|
+
items = _load_session_items(project_root, entry.id)
|
|
436
|
+
previews.append(
|
|
437
|
+
ResumeSessionPreview(
|
|
438
|
+
id=entry.id,
|
|
439
|
+
title=_session_title(items),
|
|
440
|
+
status=_session_status(items),
|
|
441
|
+
updated_at=entry.updated_at,
|
|
442
|
+
active_tokens=entry.active_tokens,
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
return previews
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _print_session_history(console: Console, project_root: Path, session_id: str) -> None:
|
|
449
|
+
items = _load_session_items(project_root, session_id)
|
|
450
|
+
if not items:
|
|
451
|
+
console.print(f"[{STYLE_MUTED}]No visible history for this session.[/]")
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
console.print(Text("History", style=f"bold {STYLE_MUTED}"))
|
|
455
|
+
renderer = TerminalStreamRenderer(console, project_root=str(project_root))
|
|
456
|
+
for item in items:
|
|
457
|
+
_print_history_item(console, item, renderer)
|
|
458
|
+
renderer.flush()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _print_history_item(
|
|
462
|
+
console: Console,
|
|
463
|
+
item: dict[str, Any],
|
|
464
|
+
renderer: TerminalStreamRenderer,
|
|
465
|
+
) -> None:
|
|
466
|
+
item_type = _item_type(item)
|
|
467
|
+
role = _role(item)
|
|
468
|
+
|
|
469
|
+
if item_type == "reasoning":
|
|
470
|
+
renderer(DeepyStreamEvent(kind="reasoning_delta", text=_reasoning_text(item)))
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
if item_type == "function_call":
|
|
474
|
+
renderer(_history_tool_call_event(item))
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
if item_type == "function_call_output":
|
|
478
|
+
renderer(_history_tool_output_event(item))
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
if role == "tool":
|
|
482
|
+
renderer(_history_tool_output_event(item))
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
if role == "user":
|
|
486
|
+
renderer.flush()
|
|
487
|
+
_print_user_input(console, _item_text(item))
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
if role == "assistant":
|
|
491
|
+
text = _item_text(item)
|
|
492
|
+
tool_calls = _chat_tool_calls(item)
|
|
493
|
+
if text.strip():
|
|
494
|
+
renderer.flush()
|
|
495
|
+
_print_assistant_output(console, text)
|
|
496
|
+
for tool_call in tool_calls:
|
|
497
|
+
renderer(_history_tool_call_event(tool_call))
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _history_tool_call_event(item: dict[str, Any]) -> DeepyStreamEvent:
|
|
502
|
+
return DeepyStreamEvent(
|
|
503
|
+
kind="tool_call",
|
|
504
|
+
name=_tool_call_name(item),
|
|
505
|
+
payload={
|
|
506
|
+
"call_id": _call_id(item),
|
|
507
|
+
"arguments": _tool_call_arguments(item),
|
|
508
|
+
},
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _history_tool_output_event(item: dict[str, Any]) -> DeepyStreamEvent:
|
|
513
|
+
return DeepyStreamEvent(
|
|
514
|
+
kind="tool_output",
|
|
515
|
+
payload={"call_id": _call_id(item)},
|
|
516
|
+
text=_tool_output_text(item),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _load_session_items(project_root: Path, session_id: str) -> list[dict[str, Any]]:
|
|
521
|
+
try:
|
|
522
|
+
return asyncio.run(DeepyJsonlSession.open(project_root, session_id).get_items())
|
|
523
|
+
except Exception:
|
|
524
|
+
return []
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _session_title(items: list[dict[str, Any]]) -> str:
|
|
528
|
+
for item in items:
|
|
529
|
+
if _role(item) == "user":
|
|
530
|
+
text = _item_text(item)
|
|
531
|
+
if text.strip():
|
|
532
|
+
return text
|
|
533
|
+
for item in items:
|
|
534
|
+
text = _item_text(item)
|
|
535
|
+
if text.strip():
|
|
536
|
+
return text
|
|
537
|
+
return "Untitled"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _session_status(items: list[dict[str, Any]]) -> str:
|
|
541
|
+
if not items:
|
|
542
|
+
return "empty"
|
|
543
|
+
for item in reversed(items):
|
|
544
|
+
if _role(item) == "user":
|
|
545
|
+
break
|
|
546
|
+
if _is_waiting_tool_output(item):
|
|
547
|
+
return "waiting"
|
|
548
|
+
last = items[-1]
|
|
549
|
+
if _item_type(last) == "function_call":
|
|
550
|
+
return "interrupted"
|
|
551
|
+
if _is_failed_tool_output(last):
|
|
552
|
+
return "failed"
|
|
553
|
+
return "completed"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _is_waiting_tool_output(item: dict[str, Any]) -> bool:
|
|
557
|
+
if _item_type(item) != "function_call_output" and _role(item) != "tool":
|
|
558
|
+
return False
|
|
559
|
+
return parse_tool_output(_tool_output_text(item)).await_user_response
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _is_failed_tool_output(item: dict[str, Any]) -> bool:
|
|
563
|
+
if _item_type(item) != "function_call_output" and _role(item) != "tool":
|
|
564
|
+
return False
|
|
565
|
+
return parse_tool_output(_tool_output_text(item)).ok is False
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _item_text(item: dict[str, Any]) -> str:
|
|
569
|
+
if "content" in item:
|
|
570
|
+
return _content_text(item["content"])
|
|
571
|
+
if "text" in item:
|
|
572
|
+
return _content_text(item["text"])
|
|
573
|
+
if "output" in item:
|
|
574
|
+
return _content_text(item["output"])
|
|
575
|
+
return ""
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _reasoning_text(item: dict[str, Any]) -> str:
|
|
579
|
+
parts: list[str] = []
|
|
580
|
+
for key in ("content", "summary", "text"):
|
|
581
|
+
if key in item:
|
|
582
|
+
text = _content_text(item[key])
|
|
583
|
+
if text.strip():
|
|
584
|
+
parts.append(text)
|
|
585
|
+
return "\n".join(parts)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _tool_output_text(item: dict[str, Any]) -> str:
|
|
589
|
+
if "output" in item:
|
|
590
|
+
return _content_text(item["output"])
|
|
591
|
+
return _item_text(item)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _content_text(value: object) -> str:
|
|
595
|
+
if isinstance(value, str):
|
|
596
|
+
return value
|
|
597
|
+
if isinstance(value, list):
|
|
598
|
+
parts: list[str] = []
|
|
599
|
+
for part in value:
|
|
600
|
+
text = _content_text_part(part)
|
|
601
|
+
if text:
|
|
602
|
+
parts.append(text)
|
|
603
|
+
return "\n".join(parts)
|
|
604
|
+
if value is None:
|
|
605
|
+
return ""
|
|
606
|
+
if isinstance(value, dict):
|
|
607
|
+
text = _content_text_part(value)
|
|
608
|
+
return text or json_utils.dumps(value)
|
|
609
|
+
return str(value)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _content_text_part(part: object) -> str:
|
|
613
|
+
if isinstance(part, str):
|
|
614
|
+
return part
|
|
615
|
+
if not isinstance(part, dict):
|
|
616
|
+
return ""
|
|
617
|
+
for key in ("text", "input_text", "output_text", "refusal"):
|
|
618
|
+
value = part.get(key)
|
|
619
|
+
if isinstance(value, str):
|
|
620
|
+
return value
|
|
621
|
+
return ""
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _chat_tool_calls(item: dict[str, Any]) -> list[dict[str, Any]]:
|
|
625
|
+
value = item.get("tool_calls")
|
|
626
|
+
if not isinstance(value, list):
|
|
627
|
+
return []
|
|
628
|
+
return [tool_call for tool_call in value if isinstance(tool_call, dict)]
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _tool_call_name(item: dict[str, Any]) -> str:
|
|
632
|
+
name = item.get("name")
|
|
633
|
+
if isinstance(name, str) and name:
|
|
634
|
+
return name
|
|
635
|
+
function = item.get("function")
|
|
636
|
+
if isinstance(function, dict):
|
|
637
|
+
function_name = function.get("name")
|
|
638
|
+
if isinstance(function_name, str) and function_name:
|
|
639
|
+
return function_name
|
|
640
|
+
return "tool"
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _tool_call_arguments(item: dict[str, Any]) -> str:
|
|
644
|
+
arguments = item.get("arguments")
|
|
645
|
+
if isinstance(arguments, str):
|
|
646
|
+
return arguments
|
|
647
|
+
if arguments is not None:
|
|
648
|
+
return json_utils.dumps(arguments)
|
|
649
|
+
function = item.get("function")
|
|
650
|
+
if isinstance(function, dict):
|
|
651
|
+
function_arguments = function.get("arguments")
|
|
652
|
+
if isinstance(function_arguments, str):
|
|
653
|
+
return function_arguments
|
|
654
|
+
if function_arguments is not None:
|
|
655
|
+
return json_utils.dumps(function_arguments)
|
|
656
|
+
return ""
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _item_type(item: dict[str, Any]) -> str:
|
|
660
|
+
value = item.get("type")
|
|
661
|
+
return value if isinstance(value, str) else ""
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _role(item: dict[str, Any]) -> str:
|
|
665
|
+
value = item.get("role")
|
|
666
|
+
return value if isinstance(value, str) else ""
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _call_id(item: dict[str, Any]) -> str:
|
|
670
|
+
for key in ("call_id", "tool_call_id", "id"):
|
|
671
|
+
value = item.get(key)
|
|
672
|
+
if isinstance(value, str):
|
|
673
|
+
return value
|
|
674
|
+
return ""
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _print_exit_summary(
|
|
678
|
+
console: Console,
|
|
679
|
+
project_root: Path,
|
|
680
|
+
session_id: str | None,
|
|
681
|
+
settings: Settings,
|
|
682
|
+
) -> None:
|
|
683
|
+
session_entry: SessionEntry | None = None
|
|
684
|
+
messages: list[dict[str, object]] = []
|
|
685
|
+
if session_id:
|
|
686
|
+
session_entry = next(
|
|
687
|
+
(entry for entry in list_session_entries(project_root) if entry.id == session_id),
|
|
688
|
+
None,
|
|
689
|
+
)
|
|
690
|
+
try:
|
|
691
|
+
messages = asyncio.run(DeepyJsonlSession.open(project_root, session_id).get_items())
|
|
692
|
+
except Exception:
|
|
693
|
+
messages = []
|
|
694
|
+
console.print(
|
|
695
|
+
build_exit_summary_text(
|
|
696
|
+
session=session_entry,
|
|
697
|
+
messages=messages,
|
|
698
|
+
model=settings.model.name,
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _print_usage_footer(
|
|
704
|
+
console: Console,
|
|
705
|
+
summary: RunSummary,
|
|
706
|
+
*,
|
|
707
|
+
settings: Settings | None = None,
|
|
708
|
+
project_root: Path | None = None,
|
|
709
|
+
) -> None:
|
|
710
|
+
if summary.usage.known:
|
|
711
|
+
duration = _format_duration_ms(summary.duration_ms) if summary.duration_ms > 0 else ""
|
|
712
|
+
prefix = f"time {duration} · " if duration else ""
|
|
713
|
+
console.print(
|
|
714
|
+
f"[{STYLE_MUTED}]turn API usage[/] {prefix}{_format_turn_usage_line(summary.usage)}"
|
|
715
|
+
)
|
|
716
|
+
elif summary.duration_ms > 0:
|
|
717
|
+
console.print(f"[{STYLE_MUTED}]turn time[/] {_format_duration_ms(summary.duration_ms)}")
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _format_context_footer(
|
|
721
|
+
session_id: str | None,
|
|
722
|
+
*,
|
|
723
|
+
project_root: Path | None = None,
|
|
724
|
+
settings: Settings | None = None,
|
|
725
|
+
) -> str:
|
|
726
|
+
if settings is None:
|
|
727
|
+
return ""
|
|
728
|
+
|
|
729
|
+
window_tokens = settings.context.window_tokens
|
|
730
|
+
compact_threshold = settings.context.resolved_compact_threshold
|
|
731
|
+
if window_tokens <= 0:
|
|
732
|
+
return ""
|
|
733
|
+
|
|
734
|
+
used_tokens = _session_active_tokens(project_root, session_id)
|
|
735
|
+
used_text = f"{used_tokens:,}" if used_tokens is not None else "unknown"
|
|
736
|
+
used_ratio = (
|
|
737
|
+
f" ({used_tokens / window_tokens * 100:.1f}%)"
|
|
738
|
+
if used_tokens is not None
|
|
739
|
+
else ""
|
|
740
|
+
)
|
|
741
|
+
if compact_threshold > 0:
|
|
742
|
+
compact_progress = (
|
|
743
|
+
f" ({used_tokens / compact_threshold * 100:.1f}%)"
|
|
744
|
+
if used_tokens is not None
|
|
745
|
+
else ""
|
|
746
|
+
)
|
|
747
|
+
parts = [f"context used {used_text} / {compact_threshold:,} to compact{compact_progress}"]
|
|
748
|
+
parts.append(f"window {window_tokens:,}")
|
|
749
|
+
if used_tokens is not None and used_tokens >= compact_threshold:
|
|
750
|
+
parts.append("compact next request")
|
|
751
|
+
else:
|
|
752
|
+
parts = [f"context used {used_text} / {window_tokens:,}{used_ratio}"]
|
|
753
|
+
|
|
754
|
+
return " · ".join(parts)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _session_active_tokens(project_root: Path | None, session_id: str | None) -> int | None:
|
|
758
|
+
if not session_id:
|
|
759
|
+
return 0
|
|
760
|
+
if project_root is None:
|
|
761
|
+
return None
|
|
762
|
+
try:
|
|
763
|
+
entries = list_session_entries(project_root)
|
|
764
|
+
except Exception:
|
|
765
|
+
return None
|
|
766
|
+
entry = next((item for item in entries if item.id == session_id), None)
|
|
767
|
+
return entry.active_tokens if entry is not None else None
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _format_turn_usage_line(usage: TokenUsage) -> str:
|
|
771
|
+
prefix = f"requests {usage.requests:,} · " if usage.requests > 0 else ""
|
|
772
|
+
return f"{prefix}{format_usage_line(usage)}"
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _refresh_working_status(
|
|
776
|
+
renderer: TerminalStreamRenderer,
|
|
777
|
+
stop_event: threading.Event,
|
|
778
|
+
) -> None:
|
|
779
|
+
while not stop_event.wait(1):
|
|
780
|
+
renderer.update_status()
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _working_status_text(started_at: float, detail: str = "") -> Text:
|
|
784
|
+
elapsed = _format_duration_ms(int((time.monotonic() - started_at) * 1000)) or "0s"
|
|
785
|
+
text = Text.assemble(
|
|
786
|
+
("Working ", f"bold {STYLE_MUTED}"),
|
|
787
|
+
(f"({elapsed} · esc to interrupt)", STYLE_MUTED),
|
|
788
|
+
)
|
|
789
|
+
if detail:
|
|
790
|
+
text.append(" · ", style=STYLE_MUTED)
|
|
791
|
+
text.append(detail, style=STYLE_MUTED)
|
|
792
|
+
return text
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _format_duration_ms(duration_ms: int) -> str:
|
|
796
|
+
seconds = max(0, int(duration_ms // 1000))
|
|
797
|
+
hours = seconds // 3600
|
|
798
|
+
minutes = (seconds % 3600) // 60
|
|
799
|
+
remaining_seconds = seconds % 60
|
|
800
|
+
if hours:
|
|
801
|
+
return f"{hours}h {minutes}m"
|
|
802
|
+
if minutes:
|
|
803
|
+
return f"{minutes}m {remaining_seconds}s"
|
|
804
|
+
return f"{remaining_seconds}s"
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _print_user_input(console: Console, text: str) -> None:
|
|
808
|
+
if not text.strip():
|
|
809
|
+
return
|
|
810
|
+
lines = text.rstrip().splitlines() or [text.rstrip()]
|
|
811
|
+
rendered = Text()
|
|
812
|
+
for index, line in enumerate(lines):
|
|
813
|
+
if index:
|
|
814
|
+
rendered.append("\n")
|
|
815
|
+
rendered.append(" ", style=STYLE_USER)
|
|
816
|
+
else:
|
|
817
|
+
rendered.append("> ", style=STYLE_USER)
|
|
818
|
+
rendered.append(line, style=STYLE_USER)
|
|
819
|
+
console.print(rendered)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _print_assistant_output(console: Console, text: str) -> None:
|
|
823
|
+
if not text.strip():
|
|
824
|
+
return
|
|
825
|
+
console.print()
|
|
826
|
+
console.print(f"[bold {STYLE_ASSISTANT}]Deepy[/]")
|
|
827
|
+
console.print(render_markdown(text.rstrip()))
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _print_stream_event(
|
|
831
|
+
console: Console,
|
|
832
|
+
event: DeepyStreamEvent,
|
|
833
|
+
*,
|
|
834
|
+
project_root: str | None = None,
|
|
835
|
+
pending_tool_calls: dict[str, ToolCallDisplay] | None = None,
|
|
836
|
+
reasoning_sink: TerminalStreamRenderer | None = None,
|
|
837
|
+
) -> None:
|
|
838
|
+
if event.kind in {"text_delta", "message"}:
|
|
839
|
+
return
|
|
840
|
+
if event.kind == "reasoning_delta":
|
|
841
|
+
if reasoning_sink is not None:
|
|
842
|
+
reasoning_sink.add_reasoning(event.text)
|
|
843
|
+
return
|
|
844
|
+
if event.kind == "tool_call":
|
|
845
|
+
summary = format_tool_call_summary(
|
|
846
|
+
event.name or "tool",
|
|
847
|
+
_string_payload(event.payload.get("arguments")),
|
|
848
|
+
project_root=project_root,
|
|
849
|
+
)
|
|
850
|
+
if pending_tool_calls is not None:
|
|
851
|
+
call_id = _string_payload(event.payload.get("call_id"))
|
|
852
|
+
if call_id:
|
|
853
|
+
pending_tool_calls[call_id] = ToolCallDisplay(
|
|
854
|
+
summary=summary,
|
|
855
|
+
name=event.name or "tool",
|
|
856
|
+
)
|
|
857
|
+
if reasoning_sink is not None:
|
|
858
|
+
reasoning_sink.set_tool_status(summary)
|
|
859
|
+
return
|
|
860
|
+
console.print(_status_line(summary, STYLE_INFO))
|
|
861
|
+
return
|
|
862
|
+
if event.kind == "tool_output":
|
|
863
|
+
if reasoning_sink is not None:
|
|
864
|
+
reasoning_sink.flush()
|
|
865
|
+
view = parse_tool_output(event.text)
|
|
866
|
+
call_id = _string_payload(event.payload.get("call_id"))
|
|
867
|
+
call = pending_tool_calls.pop(call_id, None) if pending_tool_calls is not None else None
|
|
868
|
+
call_summary = call.summary if call is not None else view.name
|
|
869
|
+
summary = format_tool_progress_summary(call_summary, event.text)
|
|
870
|
+
console.print(_status_line(summary, status_style(view.ok)))
|
|
871
|
+
diff = render_tool_diff_preview(event.text)
|
|
872
|
+
if diff:
|
|
873
|
+
console.print(diff)
|
|
874
|
+
return
|
|
875
|
+
if event.kind == "agent_updated":
|
|
876
|
+
return
|
|
877
|
+
if event.kind == "usage":
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _string_payload(value: object) -> str:
|
|
882
|
+
return value if isinstance(value, str) else ""
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _status_line(text: str, style: str) -> Text:
|
|
886
|
+
return Text.assemble(("• ", style), (text, f"bold {style}"))
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _collect_pending_question_response(
|
|
890
|
+
console: Console,
|
|
891
|
+
pending_questions: list[dict[str, object]],
|
|
892
|
+
input_func: InputFunc | None = None,
|
|
893
|
+
) -> str:
|
|
894
|
+
questions = normalize_questions(pending_questions)
|
|
895
|
+
if not questions:
|
|
896
|
+
return ""
|
|
897
|
+
answers: dict[str, str] = {}
|
|
898
|
+
chooser = input_func or (lambda prompt: Prompt.ask(prompt, default=""))
|
|
899
|
+
for question in questions:
|
|
900
|
+
answer = _prompt_for_question(console, question, chooser)
|
|
901
|
+
if answer is None:
|
|
902
|
+
return format_ask_user_question_decline()
|
|
903
|
+
answers[question.question] = answer
|
|
904
|
+
return format_ask_user_question_answers(answers)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _prompt_for_question(
|
|
908
|
+
console: Console,
|
|
909
|
+
question: AskUserQuestionItem,
|
|
910
|
+
input_func: InputFunc,
|
|
911
|
+
) -> str | None:
|
|
912
|
+
options = build_options(question)
|
|
913
|
+
console.print(f"\n[bold]Question:[/bold] {question.question}")
|
|
914
|
+
for index, option in enumerate(options, 1):
|
|
915
|
+
detail = f" - {option.description}" if option.description else ""
|
|
916
|
+
console.print(f"{index}. {option.label}{detail}")
|
|
917
|
+
raw_answer = input_func("Answer number, text, or empty to decline").strip()
|
|
918
|
+
if not raw_answer:
|
|
919
|
+
return None
|
|
920
|
+
return _answer_question_from_text(question, raw_answer)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _answer_question_from_text(question: AskUserQuestionItem, raw_answer: str) -> str | None:
|
|
924
|
+
options = build_options(question)
|
|
925
|
+
if question.multi_select:
|
|
926
|
+
selected_values: list[str] = []
|
|
927
|
+
custom_values: list[str] = []
|
|
928
|
+
for token in [part.strip() for part in raw_answer.split(",") if part.strip()]:
|
|
929
|
+
option = _option_from_token(options, token)
|
|
930
|
+
if option is not None:
|
|
931
|
+
selected_values.append(option.value)
|
|
932
|
+
else:
|
|
933
|
+
custom_values.append(token)
|
|
934
|
+
if custom_values:
|
|
935
|
+
selected_values.append(OTHER_VALUE)
|
|
936
|
+
return build_answer_for_question(
|
|
937
|
+
question,
|
|
938
|
+
None,
|
|
939
|
+
selected_values,
|
|
940
|
+
", ".join(custom_values),
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
option = _option_from_token(options, raw_answer)
|
|
944
|
+
if option is None:
|
|
945
|
+
option = next((item for item in options if item.value == OTHER_VALUE), None)
|
|
946
|
+
other_text = raw_answer if option is not None and option.is_other else ""
|
|
947
|
+
return build_answer_for_question(question, option, [], other_text)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _option_from_token(
|
|
951
|
+
options: list[AskUserQuestionOptionEntry],
|
|
952
|
+
token: str,
|
|
953
|
+
) -> AskUserQuestionOptionEntry | None:
|
|
954
|
+
if token.isdigit():
|
|
955
|
+
index = int(token) - 1
|
|
956
|
+
if 0 <= index < len(options):
|
|
957
|
+
return options[index]
|
|
958
|
+
lowered = token.casefold()
|
|
959
|
+
return next((option for option in options if option.label.casefold() == lowered), None)
|