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
@@ -0,0 +1,546 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from rich.console import Group
8
+ from rich.panel import Panel
9
+ from rich.text import Text
10
+
11
+ from deepy.utils import json as json_utils
12
+ from deepy.ui.styles import (
13
+ STYLE_ASSISTANT,
14
+ STYLE_MUTED,
15
+ STYLE_SYSTEM,
16
+ STYLE_TOOL,
17
+ STYLE_USER,
18
+ status_style,
19
+ )
20
+
21
+
22
+ MAX_SUMMARY_CHARS = 160
23
+ MAX_THINKING_SUMMARY_CHARS = 360
24
+ MAX_DIFF_LINES = 80
25
+ DIFF_PREVIEW_TOOLS = {"edit", "write"}
26
+ STYLE_DIFF_ADDED = "#e5e7eb on #14532d"
27
+ STYLE_DIFF_ADDED_GUTTER = "#cbd5e1 on #14532d"
28
+ STYLE_DIFF_REMOVED = "#e5e7eb on #7f1d1d"
29
+ STYLE_DIFF_REMOVED_GUTTER = "#cbd5e1 on #7f1d1d"
30
+ STYLE_WRITE_PREVIEW_GUTTER = "#94a3b8 on #1f2937"
31
+ STYLE_WRITE_PREVIEW_CONTENT = "#d7def8 on #1f2937"
32
+ STYLE_WRITE_PREVIEW_REMOVED = "#fecaca on #7f1d1d"
33
+ ROLE_TITLES = {
34
+ "user": "You",
35
+ "assistant": "Deepy",
36
+ "system": "System",
37
+ "developer": "Developer",
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class DiffPreviewLine:
43
+ marker: str
44
+ content: str
45
+ kind: str
46
+ old_lineno: int | None = None
47
+ new_lineno: int | None = None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class DiffPreview:
52
+ path: str | None
53
+ added: int
54
+ removed: int
55
+ lines: list[DiffPreviewLine]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ToolOutputView:
60
+ name: str
61
+ ok: bool | None
62
+ status: str
63
+ summary: str
64
+ output: str = ""
65
+ error: str | None = None
66
+ path: str | None = None
67
+ diff: str | None = None
68
+ diff_preview: str | None = None
69
+ await_user_response: bool = False
70
+ raw: str = ""
71
+
72
+
73
+ def parse_tool_output(output: str) -> ToolOutputView:
74
+ try:
75
+ payload = json_utils.loads(output)
76
+ except json_utils.JSONDecodeError:
77
+ return _raw_tool_output(output)
78
+
79
+ if not isinstance(payload, dict):
80
+ return _raw_tool_output(output)
81
+
82
+ name = _string_or_default(payload.get("name"), "tool")
83
+ ok = payload.get("ok")
84
+ ok_value = ok if isinstance(ok, bool) else None
85
+ status = _status_text(ok_value)
86
+ metadata = payload.get("metadata")
87
+ metadata_dict = metadata if isinstance(metadata, dict) else {}
88
+ path = _string_or_none(metadata_dict.get("path"))
89
+ diff = _string_or_none(metadata_dict.get("diff"))
90
+ diff_preview = _string_or_none(metadata_dict.get("diff_preview"))
91
+ error = _string_or_none(payload.get("error"))
92
+ text_output = _string_or_default(payload.get("output"), "")
93
+ await_user_response = bool(payload.get("awaitUserResponse"))
94
+
95
+ detail = (error or path or _first_nonempty_line(text_output) or "").strip()
96
+ summary = f"{name} {status}" + (f" - {_truncate(detail)}" if detail else "")
97
+ return ToolOutputView(
98
+ name=name,
99
+ ok=ok_value,
100
+ status=status,
101
+ summary=summary,
102
+ output=text_output,
103
+ error=error,
104
+ path=path,
105
+ diff=diff,
106
+ diff_preview=diff_preview,
107
+ await_user_response=await_user_response,
108
+ raw=output,
109
+ )
110
+
111
+
112
+ def format_tool_output_summary(output: str) -> str:
113
+ return parse_tool_output(output).summary
114
+
115
+
116
+ def format_tool_call_summary(
117
+ name: str,
118
+ arguments: str | None,
119
+ *,
120
+ project_root: str | None = None,
121
+ ) -> str:
122
+ tool_name = name or "tool"
123
+ snippet = build_tool_params_snippet(
124
+ {"name": tool_name, "arguments": arguments or ""},
125
+ project_root=project_root,
126
+ )
127
+ return f"{tool_name} {snippet}".strip()
128
+
129
+
130
+ def format_tool_progress_summary(
131
+ call_summary: str,
132
+ output: str,
133
+ ) -> str:
134
+ view = parse_tool_output(output)
135
+ base = call_summary.strip() or view.name
136
+ detail = _tool_progress_detail(view)
137
+ return f"{base} {view.status}" + (f" - {detail}" if detail else "")
138
+
139
+
140
+ def tool_diff_preview(output: str, *, max_lines: int = MAX_DIFF_LINES) -> str | None:
141
+ view = parse_tool_output(output)
142
+ diff = _tool_diff_text(view)
143
+ if not diff:
144
+ return None
145
+ return _limit_lines(diff, max_lines=max_lines)
146
+
147
+
148
+ def tool_diff_preview_lines(output: str) -> list[DiffPreviewLine]:
149
+ view = parse_tool_output(output)
150
+ diff = _tool_diff_text(view)
151
+ return parse_diff_preview(diff) if diff else []
152
+
153
+
154
+ def render_tool_diff_preview(output: str, *, max_lines: int = MAX_DIFF_LINES) -> Group | None:
155
+ view = parse_tool_output(output)
156
+ raw_diff = _tool_diff_text(view)
157
+ if not raw_diff:
158
+ return None
159
+ diff = raw_diff if view.name.lower() == "write" else _limit_lines(raw_diff, max_lines=max_lines)
160
+ if not diff:
161
+ return None
162
+ preview = parse_diff_preview_view(diff, path=view.path)
163
+ if not preview.lines:
164
+ return None
165
+ if view.name.lower() == "write":
166
+ return Group(
167
+ render_diff_preview_header(preview, label="Wrote"),
168
+ *(render_write_preview_line(line) for line in preview.lines),
169
+ )
170
+ return Group(
171
+ render_diff_preview_header(preview, label="Edited"),
172
+ *(render_diff_preview_line(line) for line in preview.lines),
173
+ )
174
+
175
+
176
+ def parse_diff_preview_view(diff_preview: str, *, path: str | None = None) -> DiffPreview:
177
+ lines = parse_diff_preview(diff_preview)
178
+ return DiffPreview(
179
+ path=path or _diff_path(diff_preview),
180
+ added=sum(1 for line in lines if line.kind == "added"),
181
+ removed=sum(1 for line in lines if line.kind == "removed"),
182
+ lines=lines,
183
+ )
184
+
185
+
186
+ def render_diff_preview_header(preview: DiffPreview, *, label: str) -> Text:
187
+ if preview.path:
188
+ label = f"{label} {preview.path}"
189
+ label = f"{label} (+{preview.added} -{preview.removed})"
190
+ return Text(f"• {label}", style="bold bright_blue")
191
+
192
+
193
+ def render_diff_preview_line(line: DiffPreviewLine) -> Text:
194
+ content = line.content if line.content else " "
195
+ old_lineno = _line_number_text(line.old_lineno)
196
+ new_lineno = _line_number_text(line.new_lineno)
197
+ if line.kind == "added":
198
+ return Text.assemble(
199
+ (f"{old_lineno} {new_lineno} + ", STYLE_DIFF_ADDED_GUTTER),
200
+ (content, STYLE_DIFF_ADDED),
201
+ )
202
+ if line.kind == "removed":
203
+ return Text.assemble(
204
+ (f"{old_lineno} {new_lineno} - ", STYLE_DIFF_REMOVED_GUTTER),
205
+ (content, STYLE_DIFF_REMOVED),
206
+ )
207
+ return Text.assemble(
208
+ (f"{old_lineno} {new_lineno} ", STYLE_MUTED),
209
+ (content, STYLE_MUTED),
210
+ )
211
+
212
+
213
+ def render_write_preview_line(line: DiffPreviewLine) -> Text:
214
+ content = line.content if line.content else " "
215
+ lineno = line.new_lineno if line.new_lineno is not None else line.old_lineno
216
+ marker = "-" if line.kind == "removed" else " "
217
+ gutter_style = (
218
+ STYLE_DIFF_REMOVED_GUTTER if line.kind == "removed" else STYLE_WRITE_PREVIEW_GUTTER
219
+ )
220
+ content_style = STYLE_WRITE_PREVIEW_REMOVED if line.kind == "removed" else STYLE_WRITE_PREVIEW_CONTENT
221
+ return Text.assemble(
222
+ (f"{_line_number_text(lineno)} {marker} ", gutter_style),
223
+ (content, content_style),
224
+ )
225
+
226
+
227
+ def parse_diff_preview(diff_preview: str) -> list[DiffPreviewLine]:
228
+ lines: list[DiffPreviewLine] = []
229
+ old_lineno: int | None = None
230
+ new_lineno: int | None = None
231
+ for line in diff_preview.splitlines():
232
+ if not line or line.startswith("--- ") or line.startswith("+++ "):
233
+ continue
234
+ hunk = _parse_hunk_header(line)
235
+ if hunk is not None:
236
+ old_lineno, new_lineno = hunk
237
+ continue
238
+ if line.startswith("@@"):
239
+ continue
240
+ if line.startswith("+"):
241
+ lines.append(
242
+ DiffPreviewLine(
243
+ marker="+",
244
+ content=line[1:],
245
+ kind="added",
246
+ old_lineno=None,
247
+ new_lineno=new_lineno,
248
+ )
249
+ )
250
+ if new_lineno is not None:
251
+ new_lineno += 1
252
+ elif line.startswith("-"):
253
+ lines.append(
254
+ DiffPreviewLine(
255
+ marker="-",
256
+ content=line[1:],
257
+ kind="removed",
258
+ old_lineno=old_lineno,
259
+ new_lineno=None,
260
+ )
261
+ )
262
+ if old_lineno is not None:
263
+ old_lineno += 1
264
+ else:
265
+ lines.append(
266
+ DiffPreviewLine(
267
+ marker=" ",
268
+ content=line[1:] if line.startswith(" ") else line,
269
+ kind="context",
270
+ old_lineno=old_lineno,
271
+ new_lineno=new_lineno,
272
+ )
273
+ )
274
+ if old_lineno is not None:
275
+ old_lineno += 1
276
+ if new_lineno is not None:
277
+ new_lineno += 1
278
+ return lines
279
+
280
+
281
+ def build_thinking_summary(content: str, message_params: object | None = None) -> str:
282
+ if content:
283
+ normalized = " ".join(content.split())
284
+ result = _truncate(normalized, max_chars=MAX_THINKING_SUMMARY_CHARS)
285
+ if result.endswith((":", ":")):
286
+ result = result[:-1]
287
+ return result
288
+
289
+ if isinstance(message_params, dict):
290
+ reasoning_content = message_params.get("reasoning_content")
291
+ if isinstance(reasoning_content, str) and reasoning_content.strip():
292
+ return "(reasoning...)"
293
+ return ""
294
+
295
+
296
+ def build_tool_params_snippet(tool_function: object | None, *, project_root: str | None = None) -> str:
297
+ if not isinstance(tool_function, dict):
298
+ return ""
299
+ args = tool_function.get("arguments")
300
+ tool_name = tool_function.get("name")
301
+ if not isinstance(args, str) or not args.strip():
302
+ return ""
303
+ try:
304
+ parsed = json_utils.loads(args)
305
+ except json_utils.JSONDecodeError:
306
+ return args.strip()
307
+ if not isinstance(parsed, dict):
308
+ return args.strip()
309
+ return _format_tool_params_snippet(
310
+ tool_name if isinstance(tool_name, str) else None,
311
+ parsed,
312
+ project_root=project_root,
313
+ )
314
+
315
+
316
+ def build_tool_result_snippet(content: str, *, max_chars: int = 2_000) -> str:
317
+ trimmed = content.strip()
318
+ if not trimmed:
319
+ return ""
320
+ try:
321
+ parsed = json_utils.loads(content)
322
+ except json_utils.JSONDecodeError:
323
+ return _format_tool_result_snippet(content, max_chars=max_chars)
324
+ if isinstance(parsed, dict) and "output" in parsed:
325
+ output = parsed["output"]
326
+ value = output if isinstance(output, str) else json_utils.dumps(output)
327
+ return _format_tool_result_snippet(value, max_chars=max_chars)
328
+ return _format_tool_result_snippet(content, max_chars=max_chars)
329
+
330
+
331
+ def is_invisible_execution(content: str) -> bool:
332
+ if not content.strip():
333
+ return False
334
+ try:
335
+ parsed = json_utils.loads(content)
336
+ except json_utils.JSONDecodeError:
337
+ return False
338
+ return isinstance(parsed, dict) and parsed.get("name") == "bash" and parsed.get("ok") is not True
339
+
340
+
341
+ def render_tool_output(output: str) -> Group:
342
+ view = parse_tool_output(output)
343
+ parts: list[Any] = [Text(view.summary, style=status_style(view.ok))]
344
+ diff = render_tool_diff_preview(output)
345
+ if diff:
346
+ parts.append(diff)
347
+ return Group(*parts)
348
+
349
+
350
+ def render_message(
351
+ message: dict[str, Any],
352
+ *,
353
+ project_root: str | None = None,
354
+ ) -> Any:
355
+ role = _string_or_default(message.get("role"), "message")
356
+ content = _message_content_text(message.get("content"))
357
+ if role == "tool":
358
+ return render_tool_output(content)
359
+
360
+ title = ROLE_TITLES.get(role, role.title())
361
+ if role == "assistant":
362
+ return Panel(Text(content), title=title, border_style=STYLE_ASSISTANT, expand=False)
363
+ if role == "user":
364
+ return Panel(Text(content), title=title, border_style=STYLE_USER, expand=False)
365
+ if role == "system":
366
+ label = _system_message_label(content)
367
+ return Panel(Text(content), title=label, border_style=STYLE_SYSTEM, expand=False)
368
+ params_snippet = build_tool_params_snippet(message.get("function"), project_root=project_root)
369
+ if params_snippet:
370
+ return Panel(Text(params_snippet), title=title, border_style=STYLE_TOOL, expand=False)
371
+ return Panel(Text(content), title=title, border_style="dim", expand=False)
372
+
373
+
374
+ def _tool_diff_text(view: ToolOutputView) -> str | None:
375
+ if view.ok is not True or view.name.lower() not in DIFF_PREVIEW_TOOLS:
376
+ return None
377
+ return view.diff_preview or view.diff
378
+
379
+
380
+ def _parse_hunk_header(line: str) -> tuple[int, int] | None:
381
+ match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
382
+ if not match:
383
+ return None
384
+ return int(match.group(1)), int(match.group(2))
385
+
386
+
387
+ def _diff_path(diff_preview: str) -> str | None:
388
+ for line in diff_preview.splitlines():
389
+ if line.startswith("+++ "):
390
+ return _normalize_diff_path(line[4:].strip())
391
+ for line in diff_preview.splitlines():
392
+ if line.startswith("--- "):
393
+ return _normalize_diff_path(line[4:].strip())
394
+ return None
395
+
396
+
397
+ def _normalize_diff_path(path: str) -> str | None:
398
+ if path == "/dev/null":
399
+ return None
400
+ if path.startswith("a/") or path.startswith("b/"):
401
+ path = path[2:]
402
+ return path or None
403
+
404
+
405
+ def _line_number_text(value: int | None) -> str:
406
+ return f"{value:>4}" if value is not None else " "
407
+
408
+
409
+ def _tool_progress_detail(view: ToolOutputView) -> str:
410
+ if view.error:
411
+ return _truncate(view.error)
412
+ if view.await_user_response:
413
+ return _truncate(_first_nonempty_line(view.output))
414
+ return ""
415
+
416
+
417
+ def _message_content_text(content: Any) -> str:
418
+ if isinstance(content, str):
419
+ return content
420
+ if isinstance(content, list):
421
+ parts: list[str] = []
422
+ for item in content:
423
+ if isinstance(item, dict):
424
+ text = item.get("text")
425
+ if isinstance(text, str):
426
+ parts.append(text)
427
+ return "".join(parts)
428
+ if content is None:
429
+ return ""
430
+ return json_utils.dumps(content)
431
+
432
+
433
+ def _system_message_label(content: str) -> str:
434
+ normalized = " ".join(content.split()).casefold()
435
+ if "loaded skills" in normalized:
436
+ return "System Skill"
437
+ if "compacted" in normalized or "summary" in normalized:
438
+ return "Summary"
439
+ return "System"
440
+
441
+
442
+ def _format_tool_params_snippet(
443
+ tool_name: str | None,
444
+ args: dict[str, Any],
445
+ *,
446
+ project_root: str | None,
447
+ ) -> str:
448
+ if tool_name in {"write", "modify"} and "content" in args:
449
+ return _format_write_params_snippet(args, project_root=project_root)
450
+
451
+ if tool_name == "bash":
452
+ command = args.get("command")
453
+ description = args.get("description")
454
+ command_text = command.strip() if isinstance(command, str) else ""
455
+ description_text = description.strip() if isinstance(description, str) else ""
456
+ if command_text and description_text:
457
+ return f"{command_text} # {description_text}"
458
+ return command_text or description_text
459
+
460
+ first_key = next(iter(args), "")
461
+ if not first_key:
462
+ return ""
463
+ value = args[first_key]
464
+ text = value if isinstance(value, str) else json_utils.dumps(value)
465
+ if tool_name == "read":
466
+ return _shorten_project_path(text, project_root=project_root)
467
+ return text
468
+
469
+
470
+ def _format_write_params_snippet(args: dict[str, Any], *, project_root: str | None) -> str:
471
+ path = _string_or_none(args.get("file_path")) or _string_or_none(args.get("path"))
472
+ content = args.get("content")
473
+ path_text = _shorten_project_path(path, project_root=project_root) if path else "file"
474
+ if not isinstance(content, str):
475
+ return path_text
476
+ return f"{path_text} ({_text_size_summary(content)})"
477
+
478
+
479
+ def _text_size_summary(text: str) -> str:
480
+ line_count = 0 if not text else text.count("\n") + 1
481
+ line_label = "line" if line_count == 1 else "lines"
482
+ return f"{line_count:,} {line_label}, {len(text):,} chars"
483
+
484
+
485
+ def _shorten_project_path(path: str, *, project_root: str | None) -> str:
486
+ if project_root and path.startswith(project_root):
487
+ return path[len(project_root) :].lstrip("/\\")
488
+ return path
489
+
490
+
491
+ def _format_tool_result_snippet(value: str, *, max_chars: int) -> str:
492
+ if len(value) <= max_chars:
493
+ return value
494
+ return f"{value[:max_chars]}... (total {len(value)} chars)"
495
+
496
+
497
+ def _raw_tool_output(output: str) -> ToolOutputView:
498
+ return ToolOutputView(
499
+ name="tool",
500
+ ok=None,
501
+ status="raw",
502
+ summary=_truncate(output),
503
+ raw=output,
504
+ )
505
+
506
+
507
+ def _status_text(ok: bool | None) -> str:
508
+ if ok is True:
509
+ return "ok"
510
+ if ok is False:
511
+ return "failed"
512
+ return "unknown"
513
+
514
+
515
+ def _string_or_default(value: Any, default: str) -> str:
516
+ if isinstance(value, str):
517
+ return value
518
+ return default
519
+
520
+
521
+ def _string_or_none(value: Any) -> str | None:
522
+ if isinstance(value, str) and value:
523
+ return value
524
+ return None
525
+
526
+
527
+ def _first_nonempty_line(value: str) -> str | None:
528
+ for line in value.splitlines():
529
+ stripped = line.strip()
530
+ if stripped:
531
+ return stripped
532
+ return None
533
+
534
+
535
+ def _truncate(value: str, max_chars: int = MAX_SUMMARY_CHARS) -> str:
536
+ if len(value) <= max_chars:
537
+ return value
538
+ return value[: max_chars - 15].rstrip() + "... [truncated]"
539
+
540
+
541
+ def _limit_lines(value: str, *, max_lines: int) -> str:
542
+ lines = value.splitlines()
543
+ if len(lines) <= max_lines:
544
+ return value
545
+ omitted = len(lines) - max_lines
546
+ return "\n".join(lines[:max_lines]) + f"\n... [truncated {omitted} diff lines]"