deepy-cli 0.2.9__tar.gz → 0.2.11__tar.gz

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 (97) hide show
  1. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/PKG-INFO +1 -1
  2. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/pyproject.toml +1 -1
  3. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/__init__.py +1 -1
  4. deepy_cli-0.2.11/src/deepy/session_cost.py +183 -0
  5. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/sessions/jsonl.py +36 -0
  6. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/app.py +58 -0
  7. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/exit_summary.py +13 -0
  8. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/terminal.py +173 -56
  9. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/README.md +0 -0
  10. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/__main__.py +0 -0
  11. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/cli.py +0 -0
  12. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/config/__init__.py +0 -0
  13. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/config/settings.py +0 -0
  14. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/__init__.py +0 -0
  15. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  16. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  17. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  18. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/WebFetch.md +0 -0
  19. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/WebSearch.md +0 -0
  20. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/__init__.py +0 -0
  21. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/edit.md +0 -0
  22. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/modify.md +0 -0
  23. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/read.md +0 -0
  24. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/shell.md +0 -0
  25. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/todo_write.md +0 -0
  26. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/data/tools/write.md +0 -0
  27. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/errors.py +0 -0
  28. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/input_suggestions.py +0 -0
  29. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/__init__.py +0 -0
  30. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/agent.py +0 -0
  31. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/compaction.py +0 -0
  32. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/context.py +0 -0
  33. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/events.py +0 -0
  34. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/model_capabilities.py +0 -0
  35. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/provider.py +0 -0
  36. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/replay.py +0 -0
  37. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/runner.py +0 -0
  38. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/llm/thinking.py +0 -0
  39. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/mcp.py +0 -0
  40. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/prompts/__init__.py +0 -0
  41. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/prompts/compact.py +0 -0
  42. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/prompts/init_agents.py +0 -0
  43. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/prompts/rules.py +0 -0
  44. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/prompts/runtime_context.py +0 -0
  45. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/prompts/system.py +0 -0
  46. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/prompts/tool_docs.py +0 -0
  47. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/sessions/__init__.py +0 -0
  48. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/sessions/manager.py +0 -0
  49. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/skill_market.py +0 -0
  50. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/skills.py +0 -0
  51. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/status.py +0 -0
  52. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/todos.py +0 -0
  53. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tools/__init__.py +0 -0
  54. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tools/agents.py +0 -0
  55. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tools/builtin.py +0 -0
  56. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tools/file_state.py +0 -0
  57. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tools/result.py +0 -0
  58. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tools/shell_output.py +0 -0
  59. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tools/shell_utils.py +0 -0
  60. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/__init__.py +0 -0
  61. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/commands.py +0 -0
  62. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/compat.py +0 -0
  63. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/diff.py +0 -0
  64. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/runner.py +0 -0
  65. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/screens.py +0 -0
  66. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/state.py +0 -0
  67. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/tui/widgets.py +0 -0
  68. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/types/__init__.py +0 -0
  69. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/types/sdk.py +0 -0
  70. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/types/tool_payloads.py +0 -0
  71. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/__init__.py +0 -0
  72. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/app.py +0 -0
  73. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/ask_user_question.py +0 -0
  74. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/file_mentions.py +0 -0
  75. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/loading_text.py +0 -0
  76. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/local_command.py +0 -0
  77. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/markdown.py +0 -0
  78. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/message_view.py +0 -0
  79. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/model_picker.py +0 -0
  80. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/prompt_buffer.py +0 -0
  81. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/prompt_input.py +0 -0
  82. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/session_list.py +0 -0
  83. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/session_picker.py +0 -0
  84. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/skill_picker.py +0 -0
  85. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/slash_commands.py +0 -0
  86. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/status_footer.py +0 -0
  87. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/styles.py +0 -0
  88. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/theme_picker.py +0 -0
  89. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/thinking_state.py +0 -0
  90. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/ui/welcome.py +0 -0
  91. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/update_check.py +0 -0
  92. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/usage.py +0 -0
  93. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/utils/__init__.py +0 -0
  94. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/utils/debug_logger.py +0 -0
  95. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/utils/error_logger.py +0 -0
  96. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/utils/json.py +0 -0
  97. {deepy_cli-0.2.9 → deepy_cli-0.2.11}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.9"
3
+ version = "0.2.11"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.9"
3
+ __version__ = "0.2.11"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.parse
4
+ from decimal import Decimal, InvalidOperation
5
+ from typing import Any, Mapping
6
+
7
+ from deepy.config import Settings
8
+
9
+
10
+ def should_track_session_cost(settings: Settings) -> bool:
11
+ if not settings.model.api_key:
12
+ return False
13
+ parsed = urllib.parse.urlparse(settings.model.base_url)
14
+ return parsed.scheme in {"http", "https"} and parsed.hostname == "api.deepseek.com"
15
+
16
+
17
+ def balance_snapshot_to_dict(balance: Any, *, captured_at_ms: int) -> dict[str, Any]:
18
+ unavailable_reason = _string_or_none(_field(balance, "unavailable_reason"))
19
+ if unavailable_reason:
20
+ return {
21
+ "capturedAt": captured_at_ms,
22
+ "unavailableReason": unavailable_reason,
23
+ }
24
+
25
+ infos: list[dict[str, str]] = []
26
+ for item in _field(balance, "balance_infos") or ():
27
+ currency = _string_or_none(_field(item, "currency"))
28
+ total = _string_or_none(_field(item, "total_balance"))
29
+ granted = _string_or_none(_field(item, "granted_balance"))
30
+ topped_up = _string_or_none(_field(item, "topped_up_balance"))
31
+ if None in (currency, total, granted, topped_up):
32
+ return {
33
+ "capturedAt": captured_at_ms,
34
+ "unavailableReason": "invalid balance snapshot",
35
+ }
36
+ infos.append(
37
+ {
38
+ "currency": currency or "",
39
+ "totalBalance": total or "",
40
+ "grantedBalance": granted or "",
41
+ "toppedUpBalance": topped_up or "",
42
+ }
43
+ )
44
+ is_available = _field(balance, "is_available")
45
+ return {
46
+ "capturedAt": captured_at_ms,
47
+ "isAvailable": is_available if isinstance(is_available, bool) else None,
48
+ "balanceInfos": infos,
49
+ }
50
+
51
+
52
+ def start_session_cost(snapshot: Mapping[str, Any]) -> dict[str, Any]:
53
+ cost: dict[str, Any] = {
54
+ "attempted": True,
55
+ "start": dict(snapshot),
56
+ }
57
+ if reason := _snapshot_unavailable_reason(snapshot):
58
+ cost["unavailableReason"] = f"start {reason}"
59
+ return cost
60
+
61
+
62
+ def complete_session_cost(
63
+ existing: Mapping[str, Any] | None,
64
+ end_snapshot: Mapping[str, Any],
65
+ ) -> dict[str, Any]:
66
+ cost: dict[str, Any] = dict(existing or {})
67
+ cost["attempted"] = True
68
+ cost["end"] = dict(end_snapshot)
69
+ start = cost.get("start")
70
+ if not isinstance(start, Mapping):
71
+ cost["amounts"] = []
72
+ cost["unavailableReason"] = "start snapshot missing"
73
+ return cost
74
+ amounts, reason = compute_session_cost_amounts(start, end_snapshot)
75
+ cost["amounts"] = amounts
76
+ if reason:
77
+ cost["unavailableReason"] = reason
78
+ else:
79
+ cost.pop("unavailableReason", None)
80
+ return cost
81
+
82
+
83
+ def compute_session_cost_amounts(
84
+ start_snapshot: Mapping[str, Any],
85
+ end_snapshot: Mapping[str, Any],
86
+ ) -> tuple[list[dict[str, str]], str | None]:
87
+ if reason := _snapshot_unavailable_reason(start_snapshot):
88
+ return [], f"start {reason}"
89
+ if reason := _snapshot_unavailable_reason(end_snapshot):
90
+ return [], f"end {reason}"
91
+
92
+ start_infos = _snapshot_infos_by_currency(start_snapshot)
93
+ end_infos = _snapshot_infos_by_currency(end_snapshot)
94
+ if start_infos is None or end_infos is None:
95
+ return [], "invalid balance snapshot"
96
+ shared = [currency for currency in start_infos if currency in end_infos]
97
+ if not shared:
98
+ return [], "currency mismatch"
99
+
100
+ amounts: list[dict[str, str]] = []
101
+ for currency in shared:
102
+ start_total_text = start_infos[currency]
103
+ end_total_text = end_infos[currency]
104
+ start_total = _decimal(start_total_text)
105
+ end_total = _decimal(end_total_text)
106
+ if start_total is None or end_total is None:
107
+ return [], "invalid balance amount"
108
+ spent = start_total - end_total
109
+ if spent > 0:
110
+ amounts.append(
111
+ {
112
+ "currency": currency,
113
+ "startTotal": start_total_text,
114
+ "endTotal": end_total_text,
115
+ "spent": _format_decimal(spent),
116
+ }
117
+ )
118
+ if not amounts:
119
+ return [], "no measurable spend"
120
+ return amounts, None
121
+
122
+
123
+ def format_session_cost(cost: Any) -> str | None:
124
+ if not isinstance(cost, Mapping) or not cost.get("attempted"):
125
+ return None
126
+ amounts = cost.get("amounts")
127
+ if isinstance(amounts, list) and amounts:
128
+ parts: list[str] = []
129
+ for item in amounts:
130
+ if not isinstance(item, Mapping):
131
+ continue
132
+ currency = _string_or_none(item.get("currency"))
133
+ spent = _string_or_none(item.get("spent"))
134
+ if currency and spent:
135
+ parts.append(f"{currency} {spent}")
136
+ if parts:
137
+ return f"{', '.join(parts)} (DeepSeek balance delta)"
138
+ reason = _string_or_none(cost.get("unavailableReason")) or "unknown"
139
+ return f"unavailable ({reason})"
140
+
141
+
142
+ def _field(value: Any, name: str) -> Any:
143
+ if isinstance(value, Mapping):
144
+ return value.get(name)
145
+ return getattr(value, name, None)
146
+
147
+
148
+ def _string_or_none(value: Any) -> str | None:
149
+ return value if isinstance(value, str) and value else None
150
+
151
+
152
+ def _snapshot_unavailable_reason(snapshot: Mapping[str, Any]) -> str | None:
153
+ return _string_or_none(snapshot.get("unavailableReason"))
154
+
155
+
156
+ def _snapshot_infos_by_currency(snapshot: Mapping[str, Any]) -> dict[str, str] | None:
157
+ raw_infos = snapshot.get("balanceInfos")
158
+ if not isinstance(raw_infos, list):
159
+ return None
160
+ infos: dict[str, str] = {}
161
+ for item in raw_infos:
162
+ if not isinstance(item, Mapping):
163
+ return None
164
+ currency = _string_or_none(item.get("currency"))
165
+ total = _string_or_none(item.get("totalBalance"))
166
+ if not currency or total is None:
167
+ return None
168
+ infos[currency] = total
169
+ return infos
170
+
171
+
172
+ def _decimal(value: str) -> Decimal | None:
173
+ try:
174
+ return Decimal(value)
175
+ except (InvalidOperation, ValueError):
176
+ return None
177
+
178
+
179
+ def _format_decimal(value: Decimal) -> str:
180
+ text = format(value.normalize(), "f")
181
+ if "." not in text:
182
+ return text
183
+ return text.rstrip("0").rstrip(".") or "0"
@@ -40,6 +40,7 @@ class SessionEntry:
40
40
  pending_tokens: int = 0
41
41
  last_usage_record_count: int | None = None
42
42
  todo_state: list[dict[str, str]] | None = None
43
+ session_cost: dict[str, Any] | None = None
43
44
 
44
45
 
45
46
  def project_code(project_root: Path) -> str:
@@ -160,6 +161,7 @@ class DeepyJsonlSession:
160
161
  pending_tokens=0,
161
162
  last_usage_record_count=0,
162
163
  todo_state=[],
164
+ session_cost=None,
163
165
  )
164
166
 
165
167
  def record_usage(self, usage: TokenUsage | dict[str, Any] | None) -> None:
@@ -207,6 +209,32 @@ class DeepyJsonlSession:
207
209
  ) + max(elapsed_ms, 0)
208
210
  self._touch_index(input_suggestion_usage=accumulated)
209
211
 
212
+ def record_session_cost_start(self, snapshot: dict[str, Any]) -> None:
213
+ from deepy.session_cost import start_session_cost
214
+
215
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
216
+ previous_cost = previous.get("sessionCost") if previous else None
217
+ if isinstance(previous_cost, dict) and isinstance(previous_cost.get("start"), dict):
218
+ return
219
+ self._touch_index(session_cost=start_session_cost(snapshot))
220
+
221
+ def record_session_cost_end(self, snapshot: dict[str, Any]) -> None:
222
+ from deepy.session_cost import complete_session_cost
223
+
224
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
225
+ previous_cost = previous.get("sessionCost") if previous else None
226
+ self._touch_index(
227
+ session_cost=complete_session_cost(
228
+ previous_cost if isinstance(previous_cost, dict) else None,
229
+ snapshot,
230
+ )
231
+ )
232
+
233
+ def session_cost(self) -> dict[str, Any] | None:
234
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
235
+ cost = previous.get("sessionCost") if previous else None
236
+ return cost if isinstance(cost, dict) else None
237
+
210
238
  def context_token_state(
211
239
  self,
212
240
  records: list[dict[str, Any]] | None = None,
@@ -361,6 +389,7 @@ class DeepyJsonlSession:
361
389
  last_usage_record_count: int | None = None,
362
390
  todo_state: object = _UNSET,
363
391
  input_suggestion_usage: dict[str, Any] | None = None,
392
+ session_cost: object = _UNSET,
364
393
  ) -> None:
365
394
  self.path.parent.mkdir(parents=True, exist_ok=True)
366
395
  index_path = self.path.parent / "sessions-index.json"
@@ -432,6 +461,11 @@ class DeepyJsonlSession:
432
461
  ),
433
462
  **({"processes": previous["processes"]} if "processes" in previous else {}),
434
463
  }
464
+ if session_cost is _UNSET:
465
+ if isinstance(previous.get("sessionCost"), dict):
466
+ entry["sessionCost"] = previous["sessionCost"]
467
+ elif isinstance(session_cost, dict):
468
+ entry["sessionCost"] = session_cost
435
469
  if todo_state is _UNSET:
436
470
  if "todoState" in previous:
437
471
  entry["todoState"] = previous["todoState"]
@@ -498,6 +532,7 @@ def list_session_entries(project_root: Path, deepy_home: Path | None = None) ->
498
532
  path = f"{session_id}.jsonl"
499
533
  usage = item.get("usage")
500
534
  input_suggestion_usage = item.get("inputSuggestionUsage")
535
+ session_cost = item.get("sessionCost")
501
536
  entries.append(
502
537
  SessionEntry(
503
538
  id=session_id,
@@ -515,6 +550,7 @@ def list_session_entries(project_root: Path, deepy_home: Path | None = None) ->
515
550
  pending_tokens=_coerce_int(item.get("pendingTokens"), 0),
516
551
  last_usage_record_count=_optional_int(item.get("lastUsageRecordCount")),
517
552
  todo_state=normalize_persisted_todo_state(item.get("todoState")),
553
+ session_cost=session_cost if isinstance(session_cost, dict) else None,
518
554
  )
519
555
  )
520
556
  return entries
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import shutil
5
+ import time
5
6
  from collections import OrderedDict
6
7
  from collections.abc import Callable, Coroutine
7
8
  from pathlib import Path
@@ -39,6 +40,7 @@ from deepy.mcp import load_mcp_config
39
40
  from deepy.prompts.init_agents import build_agents_init_prompt
40
41
  from deepy.prompts.rules import has_agents_instructions
41
42
  from deepy.sessions import DeepyJsonlSession, SessionEntry, list_session_entries
43
+ from deepy.session_cost import balance_snapshot_to_dict, should_track_session_cost
42
44
  from deepy.sessions.manager import DeepySessionManager
43
45
  from deepy.skill_market import (
44
46
  InstalledSkill,
@@ -389,6 +391,7 @@ class DeepyTuiApp(App[None]):
389
391
  self._todo_text = ""
390
392
  self._local_command_sequence = 0
391
393
  self.exit_summary_text: str | None = None
394
+ self._pending_session_cost_start: dict[str, Any] | None = None
392
395
 
393
396
  def compose(self) -> ComposeResult:
394
397
  yield Header(show_clock=True)
@@ -495,6 +498,7 @@ class DeepyTuiApp(App[None]):
495
498
 
496
499
  def _start_model_turn(self, prompt: str, skill_names: list[str], *, status: str) -> None:
497
500
  self._clear_input_suggestion()
501
+ self._pending_session_cost_start = self._capture_session_cost_start()
498
502
  self.state = set_busy(reset_turn_buffers(self.state), True, status)
499
503
  self._assistant_block = None
500
504
  self._thinking_block = None
@@ -1166,6 +1170,7 @@ class DeepyTuiApp(App[None]):
1166
1170
  summary = message.summary
1167
1171
  await self._flush_assistant_block()
1168
1172
  self.state = set_session_id(self.state, summary.session_id)
1173
+ self._record_pending_session_cost_start(summary.session_id)
1169
1174
  self.state = set_usage(self.state, summary.usage)
1170
1175
  self.state = set_pending_questions(self.state, summary.pending_questions)
1171
1176
  self.state = set_busy(self.state, False, "Idle")
@@ -1179,6 +1184,7 @@ class DeepyTuiApp(App[None]):
1179
1184
  @on(TurnFailedMessage)
1180
1185
  async def on_turn_failed(self, message: TurnFailedMessage) -> None:
1181
1186
  message.stop()
1187
+ self._pending_session_cost_start = None
1182
1188
  self.state = set_busy(self.state, False, "Error")
1183
1189
  await self._append_block(ErrorBlock(str(message.error)))
1184
1190
  self._update_status("Error")
@@ -1468,6 +1474,7 @@ class DeepyTuiApp(App[None]):
1468
1474
  self.set_timer(2.0, self._clear_quit_confirm)
1469
1475
 
1470
1476
  def _exit_with_summary(self) -> None:
1477
+ self._record_session_cost_end()
1471
1478
  self.exit_summary_text = self._build_exit_summary_text()
1472
1479
  self.exit()
1473
1480
 
@@ -1497,6 +1504,53 @@ class DeepyTuiApp(App[None]):
1497
1504
  session_id=self.state.session_id,
1498
1505
  )
1499
1506
 
1507
+ def _capture_session_cost_start(self) -> dict[str, Any] | None:
1508
+ if not should_track_session_cost(self.settings):
1509
+ return None
1510
+ if self.state.session_id and self._session_cost_has_start(self.state.session_id):
1511
+ return None
1512
+ return balance_snapshot_to_dict(
1513
+ fetch_deepseek_balance(self.settings),
1514
+ captured_at_ms=_now_ms(),
1515
+ )
1516
+
1517
+ def _record_pending_session_cost_start(self, session_id: str | None) -> None:
1518
+ if not session_id or self._pending_session_cost_start is None:
1519
+ return
1520
+ try:
1521
+ DeepyJsonlSession.open(self.project_root, session_id).record_session_cost_start(
1522
+ self._pending_session_cost_start
1523
+ )
1524
+ except Exception:
1525
+ pass
1526
+ finally:
1527
+ self._pending_session_cost_start = None
1528
+
1529
+ def _record_session_cost_end(self) -> None:
1530
+ session_id = self.state.session_id
1531
+ if (
1532
+ not session_id
1533
+ or not should_track_session_cost(self.settings)
1534
+ or not self._session_cost_has_start(session_id)
1535
+ ):
1536
+ return
1537
+ snapshot = balance_snapshot_to_dict(
1538
+ fetch_deepseek_balance(self.settings),
1539
+ captured_at_ms=_now_ms(),
1540
+ )
1541
+ try:
1542
+ DeepyJsonlSession.open(self.project_root, session_id).record_session_cost_end(snapshot)
1543
+ except Exception:
1544
+ return
1545
+
1546
+ def _session_cost_has_start(self, session_id: str) -> bool:
1547
+ return any(
1548
+ entry.id == session_id
1549
+ and isinstance(entry.session_cost, dict)
1550
+ and isinstance(entry.session_cost.get("start"), dict)
1551
+ for entry in list_session_entries(self.project_root)
1552
+ )
1553
+
1500
1554
  def _clear_quit_confirm(self) -> None:
1501
1555
  if self.state.quit_confirm_pending:
1502
1556
  self.state = set_quit_confirm(self.state, False)
@@ -1924,6 +1978,10 @@ def _format_market_skills(skills: list[MarketSkill]) -> str:
1924
1978
  return "\n".join(lines)
1925
1979
 
1926
1980
 
1981
+ def _now_ms() -> int:
1982
+ return int(time.time() * 1000)
1983
+
1984
+
1927
1985
  def _format_installed_records(records: list[InstalledSkill]) -> str:
1928
1986
  if not records:
1929
1987
  return "No market-installed skills."
@@ -4,6 +4,8 @@ from dataclasses import dataclass
4
4
  from collections.abc import Sequence
5
5
  from typing import Any, Mapping
6
6
 
7
+ from deepy.session_cost import format_session_cost
8
+
7
9
 
8
10
  INNER_WIDTH = 98
9
11
  CONTENT_WIDTH = INNER_WIDTH - 4
@@ -92,6 +94,9 @@ def build_exit_summary_text(
92
94
  ),
93
95
  )
94
96
  )
97
+ cost = format_session_cost(_get_session_cost(session))
98
+ if cost:
99
+ rows.append(("session cost", cost))
95
100
  return _simple_box("Deepy Session Summary", rows)
96
101
 
97
102
  def _usage_summary(
@@ -134,6 +139,14 @@ def _get_input_suggestion_usage(session: Any | None) -> Any:
134
139
  return getattr(session, "input_suggestion_usage", None)
135
140
 
136
141
 
142
+ def _get_session_cost(session: Any | None) -> Any:
143
+ if session is None:
144
+ return None
145
+ if isinstance(session, Mapping):
146
+ return session.get("session_cost") or session.get("sessionCost")
147
+ return getattr(session, "session_cost", None)
148
+
149
+
137
150
  def _get_session_id(session: Any | None) -> str | None:
138
151
  if session is None:
139
152
  return None
@@ -15,6 +15,7 @@ from dataclasses import dataclass, replace
15
15
  from pathlib import Path
16
16
  from typing import Any
17
17
 
18
+ from rich.cells import cell_len
18
19
  from rich.console import Console
19
20
  from rich.prompt import Prompt
20
21
  from rich.text import Text
@@ -47,6 +48,7 @@ from deepy.mcp import DeepyMcpRuntime, format_mcp_status
47
48
  from deepy.prompts.init_agents import build_agents_init_prompt
48
49
  from deepy.prompts.rules import has_agents_instructions
49
50
  from deepy.sessions import DeepyJsonlSession, SessionEntry, list_session_entries
51
+ from deepy.session_cost import balance_snapshot_to_dict, should_track_session_cost
50
52
  from deepy.sessions.manager import DeepySessionManager
51
53
  from deepy.skill_market import (
52
54
  install_market_skill,
@@ -282,6 +284,7 @@ def run_interactive(
282
284
  continue
283
285
  request = slash.argument or f"Use the {skill.name} skill."
284
286
  anchor_status_output = _print_submitted_user_input(output, text, palette=palette)
287
+ cost_start = _capture_session_cost_start(root, session_id, settings)
285
288
  summary = _run_once_with_status(
286
289
  output,
287
290
  run_once,
@@ -296,6 +299,7 @@ def run_interactive(
296
299
  anchor_status_output_lines=1 if anchor_status_output else 0,
297
300
  )
298
301
  session_id = summary.session_id
302
+ _record_session_cost_start(root, session_id, cost_start)
299
303
  clarification_rounds = 0
300
304
  while summary.status == "waiting_for_user":
301
305
  if clarification_rounds >= MAX_CLARIFICATION_ROUNDS_PER_TURN:
@@ -308,6 +312,7 @@ def run_interactive(
308
312
  if not response:
309
313
  break
310
314
  clarification_rounds += 1
315
+ cost_start = _capture_session_cost_start(root, session_id, settings)
311
316
  summary = _run_once_with_status(
312
317
  output,
313
318
  run_once,
@@ -322,6 +327,7 @@ def run_interactive(
322
327
  anchor_status_output=True,
323
328
  )
324
329
  session_id = summary.session_id
330
+ _record_session_cost_start(root, session_id, cost_start)
325
331
  _print_assistant_output(output, summary.output, palette=palette)
326
332
  _print_usage_footer(output, summary, settings=settings, project_root=root, palette=palette)
327
333
  _prepare_input_suggestion(
@@ -340,6 +346,7 @@ def run_interactive(
340
346
  continue
341
347
  if slash.name == "init":
342
348
  anchor_status_output = _print_submitted_user_input(output, text, palette=palette)
349
+ cost_start = _capture_session_cost_start(root, session_id, settings)
343
350
  summary = _run_once_with_status(
344
351
  output,
345
352
  run_once,
@@ -354,6 +361,7 @@ def run_interactive(
354
361
  anchor_status_output_lines=1 if anchor_status_output else 0,
355
362
  )
356
363
  session_id = summary.session_id
364
+ _record_session_cost_start(root, session_id, cost_start)
357
365
  _print_assistant_output(output, summary.output, palette=palette)
358
366
  _print_usage_footer(output, summary, settings=settings, project_root=root, palette=palette)
359
367
  _prepare_input_suggestion(
@@ -407,6 +415,7 @@ def run_interactive(
407
415
  continue
408
416
 
409
417
  anchor_status_output = _print_submitted_user_input(output, text, palette=palette)
418
+ cost_start = _capture_session_cost_start(root, session_id, settings)
410
419
  summary = _run_once_with_status(
411
420
  output,
412
421
  run_once,
@@ -421,6 +430,7 @@ def run_interactive(
421
430
  anchor_status_output_lines=1 if anchor_status_output else 0,
422
431
  )
423
432
  session_id = summary.session_id
433
+ _record_session_cost_start(root, session_id, cost_start)
424
434
  clarification_rounds = 0
425
435
  while summary.status == "waiting_for_user":
426
436
  if clarification_rounds >= MAX_CLARIFICATION_ROUNDS_PER_TURN:
@@ -433,6 +443,7 @@ def run_interactive(
433
443
  if not response:
434
444
  break
435
445
  clarification_rounds += 1
446
+ cost_start = _capture_session_cost_start(root, session_id, settings)
436
447
  summary = _run_once_with_status(
437
448
  output,
438
449
  run_once,
@@ -447,6 +458,7 @@ def run_interactive(
447
458
  anchor_status_output=True,
448
459
  )
449
460
  session_id = summary.session_id
461
+ _record_session_cost_start(root, session_id, cost_start)
450
462
  _print_assistant_output(output, summary.output, palette=palette)
451
463
  _print_usage_footer(output, summary, settings=settings, project_root=root, palette=palette)
452
464
  _prepare_input_suggestion(
@@ -610,6 +622,7 @@ def _run_once_with_status(
610
622
  status_started_at=started_at,
611
623
  palette=active_palette,
612
624
  footer=footer,
625
+ output_lock=getattr(status, "output_lock", None),
613
626
  )
614
627
  stop_status_refresh = threading.Event()
615
628
  status_thread = threading.Thread(
@@ -805,6 +818,7 @@ class TerminalStreamRenderer:
805
818
  status_started_at: float | None = None,
806
819
  palette: UiPalette | None = None,
807
820
  footer: StatusFooter | None = None,
821
+ output_lock: threading.RLock | None = None,
808
822
  ) -> None:
809
823
  self.console = console
810
824
  self.project_root = project_root
@@ -818,16 +832,28 @@ class TerminalStreamRenderer:
818
832
  self.pending_tool_calls: dict[str, ToolCallDisplay] = {}
819
833
  self.reasoning_started = False
820
834
  self.reasoning_buffer = ""
835
+ self.output_lock = output_lock
821
836
 
822
837
  def __call__(self, event: DeepyStreamEvent) -> None:
823
- _print_stream_event(
824
- self.console,
825
- event,
826
- project_root=self.project_root,
827
- pending_tool_calls=self.pending_tool_calls,
828
- reasoning_sink=self,
829
- palette=self.palette,
830
- )
838
+ if self.output_lock is None:
839
+ _print_stream_event(
840
+ self.console,
841
+ event,
842
+ project_root=self.project_root,
843
+ pending_tool_calls=self.pending_tool_calls,
844
+ reasoning_sink=self,
845
+ palette=self.palette,
846
+ )
847
+ return
848
+ with self.output_lock:
849
+ _print_stream_event(
850
+ self.console,
851
+ event,
852
+ project_root=self.project_root,
853
+ pending_tool_calls=self.pending_tool_calls,
854
+ reasoning_sink=self,
855
+ palette=self.palette,
856
+ )
831
857
 
832
858
  def add_reasoning(self, text: str) -> None:
833
859
  if not text:
@@ -863,6 +889,13 @@ class TerminalStreamRenderer:
863
889
  )
864
890
 
865
891
  def flush(self) -> None:
892
+ if self.output_lock is not None:
893
+ with self.output_lock:
894
+ self._flush_unlocked()
895
+ return
896
+ self._flush_unlocked()
897
+
898
+ def _flush_unlocked(self) -> None:
866
899
  if self.reasoning_buffer:
867
900
  self.console.print()
868
901
  self.reasoning_started = False
@@ -1977,6 +2010,7 @@ def _print_exit_summary(
1977
2010
  session_entry: SessionEntry | None = None
1978
2011
  messages: list[dict[str, object]] = []
1979
2012
  if session_id:
2013
+ _record_session_cost_end(project_root, session_id, settings)
1980
2014
  session_entry = next(
1981
2015
  (entry for entry in list_session_entries(project_root) if entry.id == session_id),
1982
2016
  None,
@@ -1995,6 +2029,60 @@ def _print_exit_summary(
1995
2029
  )
1996
2030
 
1997
2031
 
2032
+ def _capture_session_cost_start(
2033
+ project_root: Path,
2034
+ session_id: str | None,
2035
+ settings: Settings,
2036
+ ) -> dict[str, Any] | None:
2037
+ if not should_track_session_cost(settings):
2038
+ return None
2039
+ if session_id and _session_cost_has_start(project_root, session_id):
2040
+ return None
2041
+ return balance_snapshot_to_dict(
2042
+ fetch_deepseek_balance(settings),
2043
+ captured_at_ms=_now_ms(),
2044
+ )
2045
+
2046
+
2047
+ def _record_session_cost_start(
2048
+ project_root: Path,
2049
+ session_id: str | None,
2050
+ snapshot: dict[str, Any] | None,
2051
+ ) -> None:
2052
+ if not session_id or snapshot is None:
2053
+ return
2054
+ try:
2055
+ DeepyJsonlSession.open(project_root, session_id).record_session_cost_start(snapshot)
2056
+ except Exception:
2057
+ return
2058
+
2059
+
2060
+ def _record_session_cost_end(project_root: Path, session_id: str, settings: Settings) -> None:
2061
+ if not should_track_session_cost(settings) or not _session_cost_has_start(project_root, session_id):
2062
+ return
2063
+ snapshot = balance_snapshot_to_dict(
2064
+ fetch_deepseek_balance(settings),
2065
+ captured_at_ms=_now_ms(),
2066
+ )
2067
+ try:
2068
+ DeepyJsonlSession.open(project_root, session_id).record_session_cost_end(snapshot)
2069
+ except Exception:
2070
+ return
2071
+
2072
+
2073
+ def _session_cost_has_start(project_root: Path, session_id: str) -> bool:
2074
+ return any(
2075
+ entry.id == session_id
2076
+ and isinstance(entry.session_cost, dict)
2077
+ and isinstance(entry.session_cost.get("start"), dict)
2078
+ for entry in list_session_entries(project_root)
2079
+ )
2080
+
2081
+
2082
+ def _now_ms() -> int:
2083
+ return int(time.time() * 1000)
2084
+
2085
+
1998
2086
  def _print_usage_footer(
1999
2087
  console: Console,
2000
2088
  summary: RunSummary,
@@ -2149,7 +2237,8 @@ def _status_display(
2149
2237
  anchor_output_lines: int = 0,
2150
2238
  ):
2151
2239
  if _should_use_bottom_status_overlay(console):
2152
- status = _TerminalBottomStatus(console, palette=palette)
2240
+ output_lock = threading.RLock()
2241
+ status = _TerminalBottomStatus(console, palette=palette, output_lock=output_lock)
2153
2242
  status.start(anchor_output=anchor_output, anchor_output_lines=anchor_output_lines)
2154
2243
  status.update(initial_status)
2155
2244
  try:
@@ -2172,54 +2261,60 @@ class _TerminalBottomStatus:
2172
2261
  console: Console,
2173
2262
  *,
2174
2263
  palette: UiPalette,
2264
+ output_lock: threading.RLock | None = None,
2175
2265
  ) -> None:
2176
2266
  self.console = console
2177
2267
  self.palette = palette
2178
2268
  self.rows = 0
2179
2269
  self.columns = 0
2270
+ self.output_lock = output_lock or threading.RLock()
2180
2271
 
2181
2272
  def start(self, *, anchor_output: bool = False, anchor_output_lines: int = 0) -> None:
2182
- self.columns, self.rows = shutil.get_terminal_size((80, 24))
2183
- if self.rows <= 1:
2184
- return
2185
- scroll_bottom = self.rows - 1
2186
- scroll_lines = max(anchor_output_lines, 2 if anchor_output else 0)
2187
- if scroll_lines:
2188
- output_row = max(scroll_bottom - scroll_lines + 1, 1)
2189
- scroll_text = "\n" * scroll_lines
2190
- self.console.file.write(
2191
- f"\x1b[1;{scroll_bottom}r\x1b[{scroll_bottom};1H"
2192
- f"{scroll_text}"
2193
- f"\x1b[{output_row};1H"
2194
- )
2195
- else:
2196
- self.console.file.write(f"\x1b7\x1b[1;{scroll_bottom}r\x1b[{scroll_bottom};1H\x1b8")
2197
- self.console.file.flush()
2273
+ with self.output_lock:
2274
+ self.columns, self.rows = shutil.get_terminal_size((80, 24))
2275
+ if self.rows <= 1:
2276
+ return
2277
+ scroll_bottom = self.rows - 1
2278
+ scroll_lines = max(anchor_output_lines, 2 if anchor_output else 0)
2279
+ if scroll_lines:
2280
+ output_row = max(scroll_bottom - scroll_lines + 1, 1)
2281
+ scroll_text = "\n" * scroll_lines
2282
+ self.console.file.write(
2283
+ f"\x1b[1;{scroll_bottom}r\x1b[{scroll_bottom};1H"
2284
+ f"{scroll_text}"
2285
+ f"\x1b[{output_row};1H"
2286
+ )
2287
+ else:
2288
+ self.console.file.write(
2289
+ f"\x1b7\x1b[1;{scroll_bottom}r\x1b[{scroll_bottom};1H\x1b8"
2290
+ )
2291
+ self.console.file.flush()
2198
2292
 
2199
2293
  def update(self, status: Text) -> None:
2200
- columns, rows = shutil.get_terminal_size((80, 24))
2201
- self.columns = columns
2202
- self.rows = rows
2203
- if rows <= 1:
2204
- return
2205
- self._write_line(rows, status.plain, _terminal_runtime_status_style(self.palette))
2206
- self.console.file.flush()
2294
+ with self.output_lock:
2295
+ columns, rows = shutil.get_terminal_size((80, 24))
2296
+ self.columns = columns
2297
+ self.rows = rows
2298
+ if rows <= 1:
2299
+ return
2300
+ self._write_line(rows, status.plain, _terminal_runtime_status_style(self.palette))
2301
+ self.console.file.flush()
2207
2302
 
2208
2303
  def clear(self) -> None:
2209
- columns, rows = shutil.get_terminal_size((80, 24))
2210
- self.columns = columns
2211
- self.rows = rows
2212
- if rows <= 1:
2213
- return
2214
- self.console.file.write("\x1b7\x1b[r")
2215
- self.console.file.write(f"\x1b[{rows};1H\x1b[2K")
2216
- self.console.file.write("\x1b8")
2217
- self.console.file.flush()
2304
+ with self.output_lock:
2305
+ columns, rows = shutil.get_terminal_size((80, 24))
2306
+ self.columns = columns
2307
+ self.rows = rows
2308
+ if rows <= 1:
2309
+ return
2310
+ self.console.file.write("\x1b7\x1b[r")
2311
+ self.console.file.write(f"\x1b[{rows};1H\x1b[2K")
2312
+ self.console.file.write("\x1b8")
2313
+ self.console.file.flush()
2218
2314
 
2219
2315
  def _write_line(self, row: int, text: str, style: str) -> None:
2220
2316
  width = max(self.columns - 1, 1)
2221
- line = _truncate_status_line(text, max_width=width)
2222
- padded = line.ljust(width)
2317
+ padded = _fit_status_line(text, width=width)
2223
2318
  self.console.file.write(f"\x1b7\x1b[{row};1H\x1b[2K{style}{padded}\x1b[0m\x1b8")
2224
2319
 
2225
2320
 
@@ -2261,11 +2356,26 @@ def _ansi_rgb(prefix: str, color: str) -> str:
2261
2356
 
2262
2357
 
2263
2358
  def _truncate_status_line(text: str, *, max_width: int) -> str:
2264
- if len(text) <= max_width:
2359
+ if cell_len(text) <= max_width:
2265
2360
  return text
2266
2361
  if max_width <= 1:
2267
- return text[:max_width]
2268
- return f"{text[: max_width - 1]}…"
2362
+ return "…" if max_width == 1 else ""
2363
+ suffix = "…"
2364
+ available = max_width - cell_len(suffix)
2365
+ used = 0
2366
+ result: list[str] = []
2367
+ for char in text:
2368
+ char_width = cell_len(char)
2369
+ if used + char_width > available:
2370
+ break
2371
+ result.append(char)
2372
+ used += char_width
2373
+ return "".join(result).rstrip() + suffix
2374
+
2375
+
2376
+ def _fit_status_line(text: str, *, width: int) -> str:
2377
+ line = _truncate_status_line(text, max_width=max(width, 0))
2378
+ return line + (" " * max(0, width - cell_len(line)))
2269
2379
 
2270
2380
 
2271
2381
  def _working_status_text(
@@ -2464,19 +2574,12 @@ def _windows_terminal_cursor_row() -> int | None:
2464
2574
  try:
2465
2575
  windll = getattr(ctypes, "windll")
2466
2576
  kernel32 = windll.kernel32
2467
- with contextlib.suppress(Exception):
2468
- kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
2469
- kernel32.GetStdHandle.restype = wintypes.HANDLE
2470
- kernel32.GetConsoleScreenBufferInfo.argtypes = [
2471
- wintypes.HANDLE,
2472
- ctypes.POINTER(_WindowsConsoleScreenBufferInfo),
2473
- ]
2474
- kernel32.GetConsoleScreenBufferInfo.restype = wintypes.BOOL
2475
- handle = kernel32.GetStdHandle(_STD_OUTPUT_HANDLE)
2577
+ get_std_handle, get_console_screen_buffer_info = _windows_console_functions(kernel32)
2578
+ handle = get_std_handle(_STD_OUTPUT_HANDLE)
2476
2579
  if handle in {None, 0, _INVALID_HANDLE_VALUE}:
2477
2580
  return None
2478
2581
  info = _WindowsConsoleScreenBufferInfo()
2479
- if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(info)):
2582
+ if not get_console_screen_buffer_info(handle, ctypes.byref(info)):
2480
2583
  return None
2481
2584
  return _visible_cursor_row_from_windows_buffer(
2482
2585
  cursor_y=int(info.dwCursorPosition.Y),
@@ -2487,6 +2590,20 @@ def _windows_terminal_cursor_row() -> int | None:
2487
2590
  return None
2488
2591
 
2489
2592
 
2593
+ def _windows_console_functions(kernel32: Any) -> tuple[Callable[[int], Any], Callable[[Any, Any], Any]]:
2594
+ prototype_factory = getattr(ctypes, "WINFUNCTYPE", None)
2595
+ if prototype_factory is None:
2596
+ return kernel32.GetStdHandle, kernel32.GetConsoleScreenBufferInfo
2597
+ return (
2598
+ prototype_factory(wintypes.HANDLE, wintypes.DWORD)(("GetStdHandle", kernel32)),
2599
+ prototype_factory(
2600
+ wintypes.BOOL,
2601
+ wintypes.HANDLE,
2602
+ ctypes.POINTER(_WindowsConsoleScreenBufferInfo),
2603
+ )(("GetConsoleScreenBufferInfo", kernel32)),
2604
+ )
2605
+
2606
+
2490
2607
  def _visible_cursor_row_from_windows_buffer(
2491
2608
  *,
2492
2609
  cursor_y: int,
File without changes
File without changes
File without changes
File without changes
File without changes