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,85 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class FileSnapshot:
9
+ mtime_ns: int
10
+ size: int
11
+ full_read: bool
12
+
13
+
14
+ @dataclass
15
+ class FileSnippet:
16
+ id: str
17
+ path: Path
18
+ start_line: int
19
+ end_line: int
20
+ text: str
21
+
22
+
23
+ @dataclass
24
+ class FileState:
25
+ _snapshots: dict[Path, FileSnapshot] = field(default_factory=dict)
26
+ _snippets: dict[str, FileSnippet] = field(default_factory=dict)
27
+ _next_snippet_id: int = 0
28
+
29
+ def mark_read(self, path: Path, *, full: bool = True) -> None:
30
+ resolved = path.resolve()
31
+ stat = resolved.stat()
32
+ existing = self._snapshots.get(resolved)
33
+ self._snapshots[resolved] = FileSnapshot(
34
+ mtime_ns=stat.st_mtime_ns,
35
+ size=stat.st_size,
36
+ full_read=full or bool(existing and existing.full_read),
37
+ )
38
+
39
+ def check_writable(
40
+ self,
41
+ path: Path,
42
+ *,
43
+ require_read: bool = True,
44
+ allow_partial: bool = False,
45
+ ) -> tuple[bool, str | None]:
46
+ resolved = path.resolve()
47
+ snapshot = self._snapshots.get(resolved)
48
+ if snapshot is None:
49
+ if require_read and resolved.exists():
50
+ return False, "File must be read before it can be modified."
51
+ return True, None
52
+ if require_read and not snapshot.full_read and not allow_partial:
53
+ return False, "File must be read before it can be modified."
54
+ if not resolved.exists():
55
+ return False, "File changed since it was read: it no longer exists."
56
+ stat = resolved.stat()
57
+ if stat.st_mtime_ns != snapshot.mtime_ns or stat.st_size != snapshot.size:
58
+ return False, "File changed since it was read; read it again before editing."
59
+ return True, None
60
+
61
+ def mark_written(self, path: Path) -> None:
62
+ if path.exists():
63
+ self.mark_read(path)
64
+
65
+ def create_snippet(
66
+ self,
67
+ path: Path,
68
+ *,
69
+ start_line: int,
70
+ end_line: int,
71
+ text: str,
72
+ ) -> FileSnippet:
73
+ self._next_snippet_id += 1
74
+ snippet = FileSnippet(
75
+ id=f"snippet_{self._next_snippet_id}",
76
+ path=path.resolve(),
77
+ start_line=start_line,
78
+ end_line=end_line,
79
+ text=text,
80
+ )
81
+ self._snippets[snippet.id] = snippet
82
+ return snippet
83
+
84
+ def get_snippet(self, snippet_id: str) -> FileSnippet | None:
85
+ return self._snippets.get(snippet_id)
deepy/tools/result.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from deepy.utils import json as json_utils
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ToolResult:
11
+ ok: bool
12
+ name: str
13
+ output: str = ""
14
+ error: str | None = None
15
+ metadata: dict[str, Any] = field(default_factory=dict)
16
+ awaitUserResponse: bool = False
17
+ followUpMessages: list[dict[str, Any]] | None = None
18
+
19
+ def to_dict(self) -> dict[str, Any]:
20
+ payload = {
21
+ "ok": self.ok,
22
+ "name": self.name,
23
+ "output": self.output,
24
+ "error": self.error,
25
+ "metadata": self.metadata,
26
+ "awaitUserResponse": self.awaitUserResponse,
27
+ }
28
+ if self.followUpMessages is not None:
29
+ payload["followUpMessages"] = self.followUpMessages
30
+ return payload
31
+
32
+ def to_json(self) -> str:
33
+ return json_utils.dumps(self.to_dict())
34
+
35
+ @classmethod
36
+ def ok_result(
37
+ cls,
38
+ name: str,
39
+ output: str = "",
40
+ *,
41
+ metadata: dict[str, Any] | None = None,
42
+ ) -> "ToolResult":
43
+ return cls(ok=True, name=name, output=output, metadata=metadata or {})
44
+
45
+ @classmethod
46
+ def error_result(
47
+ cls,
48
+ name: str,
49
+ error: str,
50
+ *,
51
+ output: str = "",
52
+ metadata: dict[str, Any] | None = None,
53
+ ) -> "ToolResult":
54
+ return cls(ok=False, name=name, output=output, error=error, metadata=metadata or {})
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ ShellKind = str
7
+ NUL_REDIRECT_RE = re.compile(r"(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])")
8
+
9
+
10
+ def get_shell_kind(shell_path: str) -> ShellKind:
11
+ executable = shell_path.replace("\\", "/").split("/")[-1].lower()
12
+ if executable in {"bash", "bash.exe"}:
13
+ return "bash"
14
+ if executable in {"zsh", "zsh.exe"}:
15
+ return "zsh"
16
+ return "unknown"
17
+
18
+
19
+ def build_shell_init_command(shell_path: str) -> str | None:
20
+ kind = get_shell_kind(shell_path)
21
+ if kind == "zsh":
22
+ return 'ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"; if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi'
23
+ if kind == "bash":
24
+ return 'BASHRC="${BASH_ENV:-$HOME/.bashrc}"; if [ -f "$BASHRC" ]; then . "$BASHRC"; fi'
25
+ return None
26
+
27
+
28
+ def build_disable_extglob_command(shell_path: str) -> str | None:
29
+ kind = get_shell_kind(shell_path)
30
+ if kind == "bash":
31
+ return "shopt -u extglob 2>/dev/null || true"
32
+ if kind == "zsh":
33
+ return "setopt NO_EXTENDED_GLOB 2>/dev/null || true"
34
+ return None
35
+
36
+
37
+ def rewrite_windows_null_redirect(command: str) -> str:
38
+ return NUL_REDIRECT_RE.sub(r"\1/dev/null", command)
39
+
40
+
41
+ def windows_path_to_posix_path(windows_path: str) -> str:
42
+ if windows_path.startswith("\\\\"):
43
+ return windows_path.replace("\\", "/")
44
+ drive_match = re.match(r"^([A-Za-z]):[/\\]", windows_path)
45
+ if drive_match:
46
+ drive_letter = drive_match.group(1).lower()
47
+ return f"/{drive_letter}{windows_path[2:].replace('\\', '/')}"
48
+ return windows_path.replace("\\", "/")
49
+
50
+
51
+ def posix_path_to_windows_path(posix_path: str) -> str:
52
+ if posix_path.startswith("//"):
53
+ return posix_path.replace("/", "\\")
54
+
55
+ cygdrive_match = re.match(r"^/cygdrive/([A-Za-z])(/|$)", posix_path)
56
+ if cygdrive_match:
57
+ drive_letter = cygdrive_match.group(1).upper()
58
+ rest = posix_path[len(f"/cygdrive/{cygdrive_match.group(1)}") :]
59
+ return f"{drive_letter}:{(rest or '\\').replace('/', '\\')}"
60
+
61
+ drive_match = re.match(r"^/([A-Za-z])(/|$)", posix_path)
62
+ if drive_match:
63
+ drive_letter = drive_match.group(1).upper()
64
+ rest = posix_path[2:]
65
+ return f"{drive_letter}:{(rest or '\\').replace('/', '\\')}"
66
+
67
+ return posix_path.replace("/", "\\")
68
+
69
+
70
+ def normalize_file_path(path: str, platform: str) -> str:
71
+ if platform == "win32":
72
+ return posix_path_to_windows_path(path)
73
+ return path
74
+
75
+
76
+ def is_absolute_file_path(path: str, platform: str) -> bool:
77
+ if platform == "win32":
78
+ if re.match(r"^[A-Za-z]:[/\\]", path):
79
+ return True
80
+ if path.startswith("\\\\"):
81
+ return True
82
+ return bool(re.match(r"^/(?:cygdrive/)?[A-Za-z](?:/|$)", path))
83
+ return path.startswith("/")
deepy/ui/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .terminal import SlashCommand, parse_slash_command, run_interactive
4
+
5
+ __all__ = ["SlashCommand", "parse_slash_command", "run_interactive"]
deepy/ui/app.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, replace
4
+ from typing import Any, Literal
5
+
6
+ from deepy.llm.context import estimate_tokens_for_text
7
+ from deepy.sessions import SessionEntry
8
+ from deepy.skills import SkillInfo
9
+ from deepy.ui.ask_user_question import AskUserQuestionItem
10
+
11
+
12
+ AppView = Literal["chat", "sessions", "skills", "status", "question"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class StreamProgress:
17
+ text_tokens: int = 0
18
+ reasoning_tokens: int = 0
19
+ tool_calls: int = 0
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class DeepyAppState:
24
+ current_view: AppView = "chat"
25
+ busy: bool = False
26
+ messages: list[dict[str, Any]] = field(default_factory=list)
27
+ sessions: list[SessionEntry] = field(default_factory=list)
28
+ skills: list[SkillInfo] = field(default_factory=list)
29
+ status_line: str = ""
30
+ error_line: str = ""
31
+ stream_progress: StreamProgress = field(default_factory=StreamProgress)
32
+ running_processes: dict[str, dict[str, str]] = field(default_factory=dict)
33
+ pending_questions: list[AskUserQuestionItem] = field(default_factory=list)
34
+
35
+
36
+ def set_view(state: DeepyAppState, view: AppView) -> DeepyAppState:
37
+ return replace(state, current_view=view)
38
+
39
+
40
+ def set_busy(state: DeepyAppState, busy: bool, *, status_line: str = "") -> DeepyAppState:
41
+ return replace(state, busy=busy, status_line=status_line)
42
+
43
+
44
+ def append_message(state: DeepyAppState, message: dict[str, Any]) -> DeepyAppState:
45
+ return replace(state, messages=[*state.messages, message])
46
+
47
+
48
+ def set_sessions(state: DeepyAppState, sessions: list[SessionEntry]) -> DeepyAppState:
49
+ return replace(state, sessions=list(sessions))
50
+
51
+
52
+ def set_skills(state: DeepyAppState, skills: list[SkillInfo]) -> DeepyAppState:
53
+ return replace(state, skills=list(skills))
54
+
55
+
56
+ def set_status_line(state: DeepyAppState, status_line: str) -> DeepyAppState:
57
+ return replace(state, status_line=status_line, error_line="")
58
+
59
+
60
+ def set_error_line(state: DeepyAppState, error_line: str) -> DeepyAppState:
61
+ return replace(state, error_line=error_line)
62
+
63
+
64
+ def update_stream_progress(
65
+ state: DeepyAppState,
66
+ *,
67
+ text_delta: str = "",
68
+ reasoning_delta: str = "",
69
+ tool_call: bool = False,
70
+ ) -> DeepyAppState:
71
+ progress = state.stream_progress
72
+ return replace(
73
+ state,
74
+ stream_progress=StreamProgress(
75
+ text_tokens=progress.text_tokens + _estimate_tokens(text_delta),
76
+ reasoning_tokens=progress.reasoning_tokens + _estimate_tokens(reasoning_delta),
77
+ tool_calls=progress.tool_calls + (1 if tool_call else 0),
78
+ ),
79
+ )
80
+
81
+
82
+ def set_running_processes(
83
+ state: DeepyAppState,
84
+ running_processes: dict[str, dict[str, str]],
85
+ ) -> DeepyAppState:
86
+ return replace(
87
+ state,
88
+ running_processes={pid: dict(value) for pid, value in running_processes.items()},
89
+ )
90
+
91
+
92
+ def set_pending_questions(
93
+ state: DeepyAppState,
94
+ pending_questions: list[AskUserQuestionItem],
95
+ ) -> DeepyAppState:
96
+ return replace(
97
+ state,
98
+ current_view="question" if pending_questions else state.current_view,
99
+ pending_questions=list(pending_questions),
100
+ )
101
+
102
+
103
+ def clear_for_new_session(state: DeepyAppState) -> DeepyAppState:
104
+ return replace(
105
+ state,
106
+ current_view="chat",
107
+ busy=False,
108
+ messages=[],
109
+ status_line="Started a new session.",
110
+ error_line="",
111
+ stream_progress=StreamProgress(),
112
+ running_processes={},
113
+ pending_questions=[],
114
+ )
115
+
116
+
117
+ def _estimate_tokens(text: str) -> int:
118
+ return estimate_tokens_for_text(text)
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Mapping
5
+
6
+ from deepy.utils import json as json_utils
7
+
8
+
9
+ OTHER_VALUE = "__other__"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AskUserQuestionOption:
14
+ label: str
15
+ description: str | None = None
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AskUserQuestionItem:
20
+ question: str
21
+ options: list[AskUserQuestionOption]
22
+ multi_select: bool | None = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class PendingAskUserQuestion:
27
+ message_id: str
28
+ session_id: str
29
+ questions: list[AskUserQuestionItem]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AskUserQuestionOptionEntry:
34
+ label: str
35
+ value: str
36
+ description: str | None = None
37
+ is_other: bool = False
38
+
39
+
40
+ def build_options(question: AskUserQuestionItem | None) -> list[AskUserQuestionOptionEntry]:
41
+ if question is None:
42
+ return []
43
+ return [
44
+ *[
45
+ AskUserQuestionOptionEntry(
46
+ label=option.label,
47
+ value=option.label,
48
+ description=option.description,
49
+ )
50
+ for option in question.options
51
+ ],
52
+ AskUserQuestionOptionEntry(label="Other", value=OTHER_VALUE, is_other=True),
53
+ ]
54
+
55
+
56
+ def build_answer_for_question(
57
+ question: AskUserQuestionItem,
58
+ focused_option: AskUserQuestionOptionEntry | None,
59
+ selected_values: list[str],
60
+ other_text: str,
61
+ ) -> str | None:
62
+ trimmed_other = other_text.strip()
63
+ if question.multi_select:
64
+ labels = [value.strip() for value in selected_values if value != OTHER_VALUE and value.strip()]
65
+ if OTHER_VALUE in selected_values and not trimmed_other:
66
+ return None
67
+ if trimmed_other:
68
+ labels.append(trimmed_other)
69
+ return ", ".join(labels) if labels else None
70
+
71
+ if focused_option is None:
72
+ return None
73
+ if focused_option.is_other:
74
+ return trimmed_other or None
75
+ return focused_option.label
76
+
77
+
78
+ def find_pending_ask_user_question(
79
+ messages: list[Mapping[str, Any]],
80
+ status: str | None,
81
+ ) -> PendingAskUserQuestion | None:
82
+ if status != "waiting_for_user":
83
+ return None
84
+
85
+ for message in reversed(messages):
86
+ if message.get("role") != "tool" or message.get("visible") is False:
87
+ continue
88
+ questions = parse_ask_user_question_content(message.get("content"))
89
+ if not questions:
90
+ continue
91
+ message_id = message.get("id")
92
+ session_id = message.get("session_id") or message.get("sessionId")
93
+ if not isinstance(message_id, str) or not isinstance(session_id, str):
94
+ continue
95
+ return PendingAskUserQuestion(
96
+ message_id=message_id,
97
+ session_id=session_id,
98
+ questions=questions,
99
+ )
100
+ return None
101
+
102
+
103
+ def parse_ask_user_question_content(content: Any) -> list[AskUserQuestionItem]:
104
+ if not isinstance(content, str) or not content:
105
+ return []
106
+ try:
107
+ parsed = json_utils.loads(content)
108
+ except json_utils.JSONDecodeError:
109
+ return []
110
+ if not isinstance(parsed, Mapping) or parsed.get("awaitUserResponse") is not True:
111
+ return []
112
+ metadata = parsed.get("metadata")
113
+ if not isinstance(metadata, Mapping) or metadata.get("kind") != "ask_user_question":
114
+ return []
115
+ return normalize_questions(metadata.get("questions"))
116
+
117
+
118
+ def normalize_questions(raw: Any) -> list[AskUserQuestionItem]:
119
+ if not isinstance(raw, list):
120
+ return []
121
+
122
+ questions: list[AskUserQuestionItem] = []
123
+ for item in raw:
124
+ if not isinstance(item, Mapping):
125
+ continue
126
+ question = _stripped_string(item.get("question"))
127
+ raw_options = item.get("options")
128
+ if not question or not isinstance(raw_options, list):
129
+ continue
130
+ options = [
131
+ option
132
+ for raw_option in raw_options
133
+ if (option := normalize_option(raw_option)) is not None
134
+ ]
135
+ if not options:
136
+ continue
137
+ multi_select = item.get("multiSelect")
138
+ questions.append(
139
+ AskUserQuestionItem(
140
+ question=question,
141
+ options=options,
142
+ multi_select=multi_select if isinstance(multi_select, bool) else None,
143
+ )
144
+ )
145
+ return questions
146
+
147
+
148
+ def normalize_option(raw: Any) -> AskUserQuestionOption | None:
149
+ if not isinstance(raw, Mapping):
150
+ return None
151
+ label = _stripped_string(raw.get("label"))
152
+ if not label:
153
+ return None
154
+ description = _stripped_string(raw.get("description"))
155
+ return AskUserQuestionOption(label=label, description=description or None)
156
+
157
+
158
+ def format_ask_user_question_answers(answers: Mapping[str, str]) -> str:
159
+ answer_text = ", ".join(
160
+ f'"{_escape_answer_part(question)}"="{_escape_answer_part(answer)}"'
161
+ for question, answer in answers.items()
162
+ )
163
+ return (
164
+ f"User has answered your questions: {answer_text}. "
165
+ "You can now continue with the user's answers in mind."
166
+ )
167
+
168
+
169
+ def format_ask_user_question_decline() -> str:
170
+ return (
171
+ "The user declined to answer the questions. Continue with the available context, "
172
+ "or ask again if the information is required."
173
+ )
174
+
175
+
176
+ def _stripped_string(value: Any) -> str:
177
+ return value.strip() if isinstance(value, str) else ""
178
+
179
+
180
+ def _escape_answer_part(value: str) -> str:
181
+ normalized = " ".join(value.split())
182
+ return normalized.replace("\\", "\\\\").replace('"', '\\"')
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Mapping
5
+
6
+
7
+ INNER_WIDTH = 98
8
+ CONTENT_WIDTH = INNER_WIDTH - 4
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class UsageFields:
13
+ prompt_tokens: int = 0
14
+ completion_tokens: int = 0
15
+ cached_tokens: int = 0
16
+ reasoning_tokens: int = 0
17
+
18
+ @property
19
+ def has_usage(self) -> bool:
20
+ return self.prompt_tokens > 0 or self.completion_tokens > 0 or self.reasoning_tokens > 0
21
+
22
+
23
+ def extract_usage_fields(usage: Any) -> UsageFields:
24
+ if not isinstance(usage, Mapping):
25
+ return UsageFields()
26
+
27
+ prompt_tokens = _number_field(usage.get("prompt_tokens"))
28
+ completion_tokens = _number_field(usage.get("completion_tokens"))
29
+ cached_tokens = 0
30
+
31
+ prompt_details = usage.get("prompt_tokens_details")
32
+ if isinstance(prompt_details, Mapping):
33
+ cached_tokens = _number_field(prompt_details.get("cached_tokens"))
34
+
35
+ if cached_tokens == 0:
36
+ cached_tokens = _number_field(usage.get("prompt_cache_hit_tokens"))
37
+ completion_details = usage.get("completion_tokens_details")
38
+ output_details = usage.get("output_tokens_details")
39
+ reasoning_tokens = 0
40
+ if isinstance(completion_details, Mapping):
41
+ reasoning_tokens = _number_field(completion_details.get("reasoning_tokens"))
42
+ if reasoning_tokens == 0 and isinstance(output_details, Mapping):
43
+ reasoning_tokens = _number_field(output_details.get("reasoning_tokens"))
44
+ if reasoning_tokens == 0:
45
+ reasoning_tokens = _number_field(usage.get("reasoning_tokens"))
46
+
47
+ return UsageFields(
48
+ prompt_tokens=prompt_tokens,
49
+ completion_tokens=completion_tokens,
50
+ cached_tokens=cached_tokens,
51
+ reasoning_tokens=reasoning_tokens,
52
+ )
53
+
54
+
55
+ def build_exit_summary_text(
56
+ *,
57
+ session: Any | None = None,
58
+ messages: list[Mapping[str, Any]] | None = None,
59
+ model: str | None = None,
60
+ ) -> str:
61
+ usage = extract_usage_fields(_get_usage(session))
62
+ assistant_count = sum(1 for message in messages or [] if message.get("role") == "assistant")
63
+
64
+ rows = [
65
+ "",
66
+ "Goodbye!",
67
+ "",
68
+ ]
69
+
70
+ if usage.has_usage:
71
+ rows.extend(_usage_rows(usage, assistant_count=assistant_count, model=model or "unknown"))
72
+
73
+ rows.append("")
74
+ body = "\n".join(_box_line(row) for row in rows)
75
+ border = "─" * INNER_WIDTH
76
+ return f"╭{border}╮\n{body}\n╰{border}╯"
77
+
78
+
79
+ def _usage_rows(usage: UsageFields, *, assistant_count: int, model: str) -> list[str]:
80
+ col_model = 26
81
+ col_reqs = 6
82
+ col_input = 14
83
+ col_output = 14
84
+ col_cached = 14
85
+ col_reasoning = 14
86
+ table_width = col_model + col_reqs + col_input + col_output + col_cached + col_reasoning
87
+ header = (
88
+ _pad_right("Cumulative Model Usage", col_model)
89
+ + _pad_left("Reqs", col_reqs)
90
+ + _pad_left("Input Tokens", col_input)
91
+ + _pad_left("Output Tokens", col_output)
92
+ + _pad_left("Cached Tokens", col_cached)
93
+ + _pad_left("Reasoning", col_reasoning)
94
+ )
95
+ data = (
96
+ _pad_right(model, col_model)
97
+ + _pad_right(str(assistant_count).rjust(col_reqs), col_reqs)
98
+ + _pad_right(_format_number(usage.prompt_tokens).rjust(col_input), col_input)
99
+ + _pad_right(_format_number(usage.completion_tokens).rjust(col_output), col_output)
100
+ + _pad_right(_format_number(usage.cached_tokens).rjust(col_cached), col_cached)
101
+ + _pad_right(_format_number(usage.reasoning_tokens).rjust(col_reasoning), col_reasoning)
102
+ )
103
+ return [
104
+ header,
105
+ "─" * table_width,
106
+ data,
107
+ "",
108
+ ]
109
+
110
+
111
+ def _get_usage(session: Any | None) -> Any:
112
+ if session is None:
113
+ return None
114
+ if isinstance(session, Mapping):
115
+ return session.get("usage")
116
+ return getattr(session, "usage", None)
117
+
118
+
119
+ def _number_field(value: Any) -> int:
120
+ if isinstance(value, bool):
121
+ return 0
122
+ if isinstance(value, int):
123
+ return value
124
+ if isinstance(value, float) and value.is_integer():
125
+ return int(value)
126
+ return 0
127
+
128
+
129
+ def _format_number(value: int) -> str:
130
+ return f"{value:,}"
131
+
132
+
133
+ def _box_line(text: str) -> str:
134
+ return f"│ {_pad_right(text, CONTENT_WIDTH)} │"
135
+
136
+
137
+ def _pad_right(text: str, width: int) -> str:
138
+ return text + " " * max(0, width - len(text))
139
+
140
+
141
+ def _pad_left(text: str, width: int) -> str:
142
+ return " " * max(0, width - len(text)) + text