deepy-cli 0.2.10__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.
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/PKG-INFO +1 -1
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/pyproject.toml +1 -1
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/__init__.py +1 -1
- deepy_cli-0.2.11/src/deepy/session_cost.py +183 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/sessions/jsonl.py +36 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/app.py +58 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/exit_summary.py +13 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/terminal.py +156 -46
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/README.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/__main__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/cli.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/config/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/config/settings.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/AskUserQuestion.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/WebFetch.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/WebSearch.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/edit.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/modify.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/read.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/shell.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/todo_write.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/data/tools/write.md +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/errors.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/input_suggestions.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/agent.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/compaction.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/context.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/events.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/model_capabilities.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/provider.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/replay.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/runner.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/llm/thinking.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/mcp.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/prompts/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/prompts/compact.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/prompts/init_agents.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/prompts/rules.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/prompts/runtime_context.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/prompts/system.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/prompts/tool_docs.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/sessions/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/sessions/manager.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/skill_market.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/skills.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/status.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/todos.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tools/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tools/agents.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tools/builtin.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tools/file_state.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tools/result.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tools/shell_output.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tools/shell_utils.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/commands.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/compat.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/diff.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/runner.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/screens.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/state.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/tui/widgets.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/types/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/types/sdk.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/types/tool_payloads.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/app.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/ask_user_question.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/file_mentions.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/loading_text.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/local_command.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/markdown.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/message_view.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/model_picker.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/prompt_buffer.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/prompt_input.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/session_list.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/session_picker.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/skill_picker.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/slash_commands.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/status_footer.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/styles.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/theme_picker.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/thinking_state.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/ui/welcome.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/update_check.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/usage.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/utils/__init__.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/utils/debug_logger.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/utils/error_logger.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/utils/json.py +0 -0
- {deepy_cli-0.2.10 → deepy_cli-0.2.11}/src/deepy/utils/notify.py +0 -0
|
@@ -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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
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
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
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
|
-
|
|
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
|
|
2359
|
+
if cell_len(text) <= max_width:
|
|
2265
2360
|
return text
|
|
2266
2361
|
if max_width <= 1:
|
|
2267
|
-
return
|
|
2268
|
-
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|