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.
Files changed (69) hide show
  1. deepy/__init__.py +9 -0
  2. deepy/__main__.py +7 -0
  3. deepy/cli.py +413 -0
  4. deepy/config/__init__.py +21 -0
  5. deepy/config/settings.py +237 -0
  6. deepy/data/__init__.py +1 -0
  7. deepy/data/tools/AskUserQuestion.md +10 -0
  8. deepy/data/tools/WebFetch.md +9 -0
  9. deepy/data/tools/WebSearch.md +9 -0
  10. deepy/data/tools/__init__.py +1 -0
  11. deepy/data/tools/bash.md +7 -0
  12. deepy/data/tools/edit.md +13 -0
  13. deepy/data/tools/modify.md +17 -0
  14. deepy/data/tools/read.md +8 -0
  15. deepy/data/tools/write.md +12 -0
  16. deepy/errors.py +63 -0
  17. deepy/llm/__init__.py +13 -0
  18. deepy/llm/agent.py +31 -0
  19. deepy/llm/context.py +109 -0
  20. deepy/llm/events.py +187 -0
  21. deepy/llm/model_capabilities.py +7 -0
  22. deepy/llm/provider.py +81 -0
  23. deepy/llm/replay.py +120 -0
  24. deepy/llm/runner.py +412 -0
  25. deepy/llm/thinking.py +30 -0
  26. deepy/prompts/__init__.py +6 -0
  27. deepy/prompts/compact.py +100 -0
  28. deepy/prompts/rules.py +24 -0
  29. deepy/prompts/runtime_context.py +98 -0
  30. deepy/prompts/system.py +72 -0
  31. deepy/prompts/tool_docs.py +21 -0
  32. deepy/sessions/__init__.py +17 -0
  33. deepy/sessions/jsonl.py +306 -0
  34. deepy/sessions/manager.py +202 -0
  35. deepy/skills.py +202 -0
  36. deepy/status.py +65 -0
  37. deepy/tools/__init__.py +6 -0
  38. deepy/tools/agents.py +343 -0
  39. deepy/tools/builtin.py +2113 -0
  40. deepy/tools/file_state.py +85 -0
  41. deepy/tools/result.py +54 -0
  42. deepy/tools/shell_utils.py +83 -0
  43. deepy/ui/__init__.py +5 -0
  44. deepy/ui/app.py +118 -0
  45. deepy/ui/ask_user_question.py +182 -0
  46. deepy/ui/exit_summary.py +142 -0
  47. deepy/ui/loading_text.py +87 -0
  48. deepy/ui/markdown.py +152 -0
  49. deepy/ui/message_view.py +546 -0
  50. deepy/ui/prompt_buffer.py +176 -0
  51. deepy/ui/prompt_input.py +286 -0
  52. deepy/ui/session_list.py +140 -0
  53. deepy/ui/session_picker.py +179 -0
  54. deepy/ui/slash_commands.py +67 -0
  55. deepy/ui/styles.py +21 -0
  56. deepy/ui/terminal.py +959 -0
  57. deepy/ui/thinking_state.py +29 -0
  58. deepy/ui/welcome.py +195 -0
  59. deepy/update_check.py +195 -0
  60. deepy/usage.py +192 -0
  61. deepy/utils/__init__.py +15 -0
  62. deepy/utils/debug_logger.py +62 -0
  63. deepy/utils/error_logger.py +107 -0
  64. deepy/utils/json.py +29 -0
  65. deepy/utils/notify.py +66 -0
  66. deepy_cli-0.1.1.dist-info/METADATA +205 -0
  67. deepy_cli-0.1.1.dist-info/RECORD +69 -0
  68. deepy_cli-0.1.1.dist-info/WHEEL +4 -0
  69. 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)