iac-code 0.1.0__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.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- iac_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
"""ResumePicker dialog — pick a session to resume.
|
|
2
|
+
|
|
3
|
+
Header with "Resume Session (X of Y)", a search box, three-line item
|
|
4
|
+
rows (project group header, title with focus marker, "<time> · <branch>
|
|
5
|
+
· <size>" subtitle), and a footer of available shortcuts.
|
|
6
|
+
|
|
7
|
+
Pressing Space (with the search field empty) enters preview mode: the
|
|
8
|
+
picker switches into the alternate screen buffer, replays the focused
|
|
9
|
+
session at full terminal height via :meth:`Renderer.replay_history`,
|
|
10
|
+
and lets the user navigate with ``Up``/``Down``/``PageUp``/``PageDown``/
|
|
11
|
+
``Home``/``End`` *and* the mouse wheel (which the alt-screen normally
|
|
12
|
+
swallows — we explicitly enable SGR mouse tracking for the duration of
|
|
13
|
+
the preview so wheel ticks are forwarded to us as scroll commands).
|
|
14
|
+
``Enter`` resumes the session, ``Esc`` returns to the picker list, and
|
|
15
|
+
because the alt-screen is restored on exit nothing the preview drew
|
|
16
|
+
ever leaks into the user's main-buffer scrollback.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import io
|
|
22
|
+
import time
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from rich.cells import cell_len
|
|
26
|
+
from rich.console import Console, Group, RenderableType
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
|
|
29
|
+
from iac_code.agent.message import Message, ToolResultBlock
|
|
30
|
+
from iac_code.i18n import _
|
|
31
|
+
from iac_code.services.session_index import SessionEntry, SessionIndex
|
|
32
|
+
from iac_code.ui.components.fuzzy_picker import fuzzy_match
|
|
33
|
+
from iac_code.ui.components.search_box import SearchBox
|
|
34
|
+
from iac_code.ui.core.in_place_render import InPlaceRenderer
|
|
35
|
+
from iac_code.ui.core.key_event import KeyEvent
|
|
36
|
+
from iac_code.ui.core.raw_input import RawInputCapture, query_cursor_row
|
|
37
|
+
from iac_code.ui.core.screen import ScreenManager
|
|
38
|
+
from iac_code.utils.project_paths import get_git_branch
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from iac_code.ui.renderer import Renderer
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Wheel ticks are tiny — scroll a few lines per tick so the preview
|
|
45
|
+
# moves at a comfortable pace under fast spinning.
|
|
46
|
+
_WHEEL_LINES = 3
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResumePicker:
|
|
50
|
+
"""Interactive picker for session resume.
|
|
51
|
+
|
|
52
|
+
Construct with a populated :class:`SessionIndex`, the user's current
|
|
53
|
+
cwd (used for the default "current directory" filter), and the
|
|
54
|
+
current session id (excluded from the list). Call :meth:`run` to
|
|
55
|
+
block until the user selects an entry or cancels.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
VISIBLE_ITEM_LINES = 3 # project-header / title / subtitle
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
index: SessionIndex,
|
|
63
|
+
current_cwd: str,
|
|
64
|
+
current_session_id: str | None,
|
|
65
|
+
keybinding_manager: object | None = None,
|
|
66
|
+
renderer: "Renderer | None" = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._index = index
|
|
69
|
+
self._current_cwd = current_cwd
|
|
70
|
+
self._current_session_id = current_session_id
|
|
71
|
+
self._km = keybinding_manager
|
|
72
|
+
# Live REPL renderer — reused inside the preview so the dump
|
|
73
|
+
# uses the same tool-name translation, argument formatting, and
|
|
74
|
+
# result-summary helpers as the live UI.
|
|
75
|
+
self._renderer = renderer
|
|
76
|
+
|
|
77
|
+
self._show_all_projects = False
|
|
78
|
+
self._only_current_branch = False
|
|
79
|
+
self._show_preview = False
|
|
80
|
+
self._current_branch: str | None = get_git_branch(current_cwd)
|
|
81
|
+
|
|
82
|
+
self._all_entries: list[SessionEntry] = []
|
|
83
|
+
self._filtered: list[SessionEntry] = []
|
|
84
|
+
self._focused_index = 0
|
|
85
|
+
self._visible_from = 0
|
|
86
|
+
self._done = False
|
|
87
|
+
self._result: SessionEntry | None = None
|
|
88
|
+
# Set in run(); used by _visible_count to size the list to the
|
|
89
|
+
# current terminal height.
|
|
90
|
+
self._console: Console | None = None
|
|
91
|
+
# 1-indexed cursor row when the picker started — used to compute
|
|
92
|
+
# how many lines below the cursor are still in the viewport. None
|
|
93
|
+
# when the terminal didn't answer DSR-6; falls back to a smaller
|
|
94
|
+
# default.
|
|
95
|
+
self._entry_row: int | None = None
|
|
96
|
+
# Loaded session messages keyed by session_id — populated lazily
|
|
97
|
+
# so a re-preview doesn't re-read the JSONL file.
|
|
98
|
+
self._messages_cache: dict[str, list[Message]] = {}
|
|
99
|
+
# Cache of pre-rendered preview body lines (one entry per
|
|
100
|
+
# ``(session_id, width)``) — replay_history uses
|
|
101
|
+
# ``random_completion_verb()`` which is non-deterministic, so
|
|
102
|
+
# caching keeps the body stable across redraws caused by
|
|
103
|
+
# scrolling.
|
|
104
|
+
self._rendered_body_cache: dict[tuple[str, int], list[str]] = {}
|
|
105
|
+
# Number of body lines hidden below the visible window. ``0``
|
|
106
|
+
# pins the newest content to the bottom. Up arrow / wheel up
|
|
107
|
+
# *increases* the offset (reveals older content above).
|
|
108
|
+
self._preview_scroll_offset = 0
|
|
109
|
+
# Body height observed during the last redraw — needed by
|
|
110
|
+
# PageUp/PageDown to scroll a screenful at a time.
|
|
111
|
+
self._preview_body_height_last = 0
|
|
112
|
+
|
|
113
|
+
self._search_box = SearchBox(
|
|
114
|
+
placeholder=_("Search..."),
|
|
115
|
+
on_change=self._on_query_change,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self._reload_entries()
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Public API
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def run(self) -> SessionEntry | None:
|
|
125
|
+
import sys
|
|
126
|
+
|
|
127
|
+
self._console = Console()
|
|
128
|
+
renderer = InPlaceRenderer(self._console)
|
|
129
|
+
screen = ScreenManager(self._console)
|
|
130
|
+
try:
|
|
131
|
+
with RawInputCapture() as cap:
|
|
132
|
+
# Query *inside* raw mode — under cooked mode the
|
|
133
|
+
# response wouldn't be readable until a newline.
|
|
134
|
+
self._entry_row = query_cursor_row(sys.stdin.fileno())
|
|
135
|
+
while not self._done:
|
|
136
|
+
if self._show_preview and self._filtered:
|
|
137
|
+
renderer.clear()
|
|
138
|
+
self._run_preview_loop(cap, screen)
|
|
139
|
+
continue
|
|
140
|
+
renderer.render(
|
|
141
|
+
self.render(),
|
|
142
|
+
cursor_to=self._search_cursor_pos(),
|
|
143
|
+
)
|
|
144
|
+
key_event = cap.read_key(timeout=0.1)
|
|
145
|
+
if key_event is not None:
|
|
146
|
+
self.handle_key(key_event)
|
|
147
|
+
except OSError:
|
|
148
|
+
return None
|
|
149
|
+
finally:
|
|
150
|
+
renderer.clear()
|
|
151
|
+
self._console = None
|
|
152
|
+
self._entry_row = None
|
|
153
|
+
return self._result
|
|
154
|
+
|
|
155
|
+
# Layout: row 0 = header, row 1 = blank, row 2 = search box.
|
|
156
|
+
_SEARCH_BOX_ROW = 2
|
|
157
|
+
|
|
158
|
+
def _search_cursor_pos(self) -> tuple[int, int]:
|
|
159
|
+
sb = self._search_box
|
|
160
|
+
if not sb.value:
|
|
161
|
+
col = 2
|
|
162
|
+
else:
|
|
163
|
+
col = 2 + cell_len(sb.value[: sb.cursor])
|
|
164
|
+
return (self._SEARCH_BOX_ROW, col)
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Key handling
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def handle_key(self, key_event: KeyEvent) -> bool:
|
|
171
|
+
key = key_event.key
|
|
172
|
+
ctrl = key_event.ctrl
|
|
173
|
+
|
|
174
|
+
if ctrl and key == "c":
|
|
175
|
+
self._done = True
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
if self._show_preview:
|
|
179
|
+
return self._handle_key_preview(key_event)
|
|
180
|
+
return self._handle_key_list(key_event)
|
|
181
|
+
|
|
182
|
+
def _handle_key_preview(self, key_event: KeyEvent) -> bool:
|
|
183
|
+
key = key_event.key
|
|
184
|
+
ctrl = key_event.ctrl
|
|
185
|
+
|
|
186
|
+
if key == "escape":
|
|
187
|
+
self._show_preview = False
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
if key == "enter":
|
|
191
|
+
if self._filtered:
|
|
192
|
+
self._result = self._filtered[self._focused_index]
|
|
193
|
+
self._done = True
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
# Up/Down/Page keys scroll the preview body. ``Up`` reveals
|
|
197
|
+
# older content (offset grows), ``Down`` reveals newer.
|
|
198
|
+
if key == "up" or (ctrl and key == "p"):
|
|
199
|
+
self._scroll_preview(1)
|
|
200
|
+
return True
|
|
201
|
+
if key == "down" or (ctrl and key == "n"):
|
|
202
|
+
self._scroll_preview(-1)
|
|
203
|
+
return True
|
|
204
|
+
if key == "wheel_up":
|
|
205
|
+
self._scroll_preview(_WHEEL_LINES)
|
|
206
|
+
return True
|
|
207
|
+
if key == "wheel_down":
|
|
208
|
+
self._scroll_preview(-_WHEEL_LINES)
|
|
209
|
+
return True
|
|
210
|
+
if key == "pageup":
|
|
211
|
+
self._scroll_preview(max(1, self._preview_body_height_last - 1))
|
|
212
|
+
return True
|
|
213
|
+
if key == "pagedown":
|
|
214
|
+
self._scroll_preview(-max(1, self._preview_body_height_last - 1))
|
|
215
|
+
return True
|
|
216
|
+
if key == "home":
|
|
217
|
+
self._preview_scroll_offset = 1 << 30 # clamp on next draw
|
|
218
|
+
return True
|
|
219
|
+
if key == "end":
|
|
220
|
+
self._preview_scroll_offset = 0
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
def _handle_key_list(self, key_event: KeyEvent) -> bool:
|
|
226
|
+
key = key_event.key
|
|
227
|
+
ctrl = key_event.ctrl
|
|
228
|
+
|
|
229
|
+
if key == "escape":
|
|
230
|
+
self._done = True
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
if key == "enter":
|
|
234
|
+
if self._filtered:
|
|
235
|
+
self._result = self._filtered[self._focused_index]
|
|
236
|
+
self._done = True
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
if key == "up" or (ctrl and key == "p"):
|
|
240
|
+
self._move_focus(-1)
|
|
241
|
+
return True
|
|
242
|
+
if key == "down" or (ctrl and key == "n"):
|
|
243
|
+
self._move_focus(1)
|
|
244
|
+
return True
|
|
245
|
+
if key == "pageup":
|
|
246
|
+
self._move_focus(-self._visible_count())
|
|
247
|
+
return True
|
|
248
|
+
if key == "pagedown":
|
|
249
|
+
self._move_focus(self._visible_count())
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
if ctrl and key == "a":
|
|
253
|
+
self._toggle_show_all_projects()
|
|
254
|
+
return True
|
|
255
|
+
if ctrl and key == "b":
|
|
256
|
+
self._toggle_only_current_branch()
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
if key == " " and not self._search_box.value:
|
|
260
|
+
self._show_preview = True
|
|
261
|
+
self._preview_scroll_offset = 0
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
return self._search_box.handle_key(key_event)
|
|
265
|
+
|
|
266
|
+
def _scroll_preview(self, delta: int) -> None:
|
|
267
|
+
new_offset = self._preview_scroll_offset + delta
|
|
268
|
+
if new_offset < 0:
|
|
269
|
+
new_offset = 0
|
|
270
|
+
self._preview_scroll_offset = new_offset
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
# Filtering
|
|
274
|
+
# ------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def _on_query_change(self, _query: str) -> None:
|
|
277
|
+
self._apply_filter()
|
|
278
|
+
|
|
279
|
+
def _toggle_show_all_projects(self) -> None:
|
|
280
|
+
self._show_all_projects = not self._show_all_projects
|
|
281
|
+
self._reload_entries()
|
|
282
|
+
|
|
283
|
+
def _toggle_only_current_branch(self) -> None:
|
|
284
|
+
self._only_current_branch = not self._only_current_branch
|
|
285
|
+
self._apply_filter()
|
|
286
|
+
|
|
287
|
+
def _reload_entries(self) -> None:
|
|
288
|
+
if self._show_all_projects:
|
|
289
|
+
entries = self._index.list_all_projects()
|
|
290
|
+
else:
|
|
291
|
+
entries = self._index.list_for_cwd(self._current_cwd)
|
|
292
|
+
if self._current_session_id:
|
|
293
|
+
entries = [e for e in entries if e.session_id != self._current_session_id]
|
|
294
|
+
self._all_entries = entries
|
|
295
|
+
self._apply_filter()
|
|
296
|
+
|
|
297
|
+
def _apply_filter(self) -> None:
|
|
298
|
+
query = self._search_box.value.strip()
|
|
299
|
+
candidates = self._all_entries
|
|
300
|
+
if self._only_current_branch and self._current_branch:
|
|
301
|
+
candidates = [e for e in candidates if e.git_branch == self._current_branch]
|
|
302
|
+
if not query:
|
|
303
|
+
self._filtered = list(candidates)
|
|
304
|
+
else:
|
|
305
|
+
scored: list[tuple[float, SessionEntry]] = []
|
|
306
|
+
for entry in candidates:
|
|
307
|
+
haystack = " ".join(part for part in (entry.title, entry.project_name, entry.git_branch or "") if part)
|
|
308
|
+
if entry.session_id.startswith(query):
|
|
309
|
+
scored.append((1_000_000.0, entry))
|
|
310
|
+
continue
|
|
311
|
+
score = fuzzy_match(query, haystack)
|
|
312
|
+
if score is not None:
|
|
313
|
+
scored.append((score, entry))
|
|
314
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
315
|
+
self._filtered = [e for _, e in scored]
|
|
316
|
+
|
|
317
|
+
self._focused_index = 0
|
|
318
|
+
self._visible_from = 0
|
|
319
|
+
|
|
320
|
+
# ------------------------------------------------------------------
|
|
321
|
+
# Focus / scrolling
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def _move_focus(self, delta: int) -> None:
|
|
325
|
+
n = len(self._filtered)
|
|
326
|
+
if n == 0:
|
|
327
|
+
return
|
|
328
|
+
new_idx = max(0, min(self._focused_index + delta, n - 1))
|
|
329
|
+
self._focused_index = new_idx
|
|
330
|
+
vc = self._visible_count()
|
|
331
|
+
if self._focused_index < self._visible_from:
|
|
332
|
+
self._visible_from = self._focused_index
|
|
333
|
+
elif self._focused_index >= self._visible_from + vc:
|
|
334
|
+
self._visible_from = self._focused_index - vc + 1
|
|
335
|
+
|
|
336
|
+
def _visible_count(self) -> int:
|
|
337
|
+
if self._console is None:
|
|
338
|
+
return 5
|
|
339
|
+
height = self._console.size.height
|
|
340
|
+
if height <= 0:
|
|
341
|
+
return 5
|
|
342
|
+
if self._entry_row is not None and self._entry_row > 0:
|
|
343
|
+
available = height - (self._entry_row - 1)
|
|
344
|
+
else:
|
|
345
|
+
available = height // 2
|
|
346
|
+
overhead = 6
|
|
347
|
+
return max(1, (available - overhead) // 2)
|
|
348
|
+
|
|
349
|
+
# ------------------------------------------------------------------
|
|
350
|
+
# Rendering — list mode (in-place)
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def render(self) -> RenderableType:
|
|
354
|
+
parts: list[RenderableType] = []
|
|
355
|
+
|
|
356
|
+
total = len(self._filtered)
|
|
357
|
+
focus_pos = (self._focused_index + 1) if total else 0
|
|
358
|
+
header = Text()
|
|
359
|
+
header.append(_("Resume Session"), style="bold cyan")
|
|
360
|
+
if total:
|
|
361
|
+
header.append(f" ({focus_pos} of {total})", style="dim")
|
|
362
|
+
parts.append(header)
|
|
363
|
+
parts.append(Text(""))
|
|
364
|
+
|
|
365
|
+
parts.append(self._search_box.render())
|
|
366
|
+
parts.append(Text(""))
|
|
367
|
+
|
|
368
|
+
if not self._filtered:
|
|
369
|
+
parts.append(Text(_("No sessions found"), style="dim"))
|
|
370
|
+
else:
|
|
371
|
+
parts.extend(self._render_visible_items())
|
|
372
|
+
|
|
373
|
+
parts.append(Text(""))
|
|
374
|
+
parts.append(self._render_footer())
|
|
375
|
+
|
|
376
|
+
return Group(*parts)
|
|
377
|
+
|
|
378
|
+
def _render_visible_items(self) -> list[RenderableType]:
|
|
379
|
+
out: list[RenderableType] = []
|
|
380
|
+
vc = self._visible_count()
|
|
381
|
+
visible = self._filtered[self._visible_from : self._visible_from + vc]
|
|
382
|
+
|
|
383
|
+
if self._visible_from > 0:
|
|
384
|
+
out.append(Text("↑", style="dim"))
|
|
385
|
+
last_project: str | None = None
|
|
386
|
+
if self._visible_from > 0:
|
|
387
|
+
prev_entry = self._filtered[self._visible_from - 1]
|
|
388
|
+
last_project = prev_entry.project_name
|
|
389
|
+
|
|
390
|
+
for i, entry in enumerate(visible):
|
|
391
|
+
abs_i = self._visible_from + i
|
|
392
|
+
is_focused = abs_i == self._focused_index
|
|
393
|
+
if entry.project_name and entry.project_name != last_project:
|
|
394
|
+
out.append(Text(entry.project_name, style="dim"))
|
|
395
|
+
last_project = entry.project_name
|
|
396
|
+
out.append(self._render_title_line(entry, is_focused))
|
|
397
|
+
out.append(self._render_subtitle_line(entry))
|
|
398
|
+
|
|
399
|
+
if self._visible_from + vc < len(self._filtered):
|
|
400
|
+
out.append(Text("↓", style="dim"))
|
|
401
|
+
|
|
402
|
+
return out
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _render_title_line(entry: SessionEntry, is_focused: bool) -> Text:
|
|
406
|
+
text = Text()
|
|
407
|
+
if is_focused:
|
|
408
|
+
text.append("❯ ", style="bold cyan")
|
|
409
|
+
else:
|
|
410
|
+
text.append(" ")
|
|
411
|
+
text.append(entry.title, style="bold" if is_focused else "")
|
|
412
|
+
return text
|
|
413
|
+
|
|
414
|
+
@staticmethod
|
|
415
|
+
def _render_subtitle_line(entry: SessionEntry) -> Text:
|
|
416
|
+
text = Text(" ", style="dim")
|
|
417
|
+
parts = [_format_relative_time(entry.mtime)]
|
|
418
|
+
if entry.git_branch:
|
|
419
|
+
parts.append(entry.git_branch)
|
|
420
|
+
parts.append(_format_size(entry.size_bytes))
|
|
421
|
+
text.append(" · ".join(parts), style="dim")
|
|
422
|
+
return text
|
|
423
|
+
|
|
424
|
+
def _render_footer(self) -> Text:
|
|
425
|
+
hints: list[tuple[str, str]] = []
|
|
426
|
+
if self._show_all_projects:
|
|
427
|
+
hints.append(("Ctrl+A", _("show current dir")))
|
|
428
|
+
else:
|
|
429
|
+
hints.append(("Ctrl+A", _("show all projects")))
|
|
430
|
+
if self._current_branch:
|
|
431
|
+
if self._only_current_branch:
|
|
432
|
+
hints.append(("Ctrl+B", _("show all branches")))
|
|
433
|
+
else:
|
|
434
|
+
hints.append(("Ctrl+B", _("only show current branch")))
|
|
435
|
+
hints.append(("Space", _("preview")))
|
|
436
|
+
hints.append(("", _("Type to search")))
|
|
437
|
+
hints.append(("Esc", _("cancel")))
|
|
438
|
+
|
|
439
|
+
text = Text()
|
|
440
|
+
for i, (key, label) in enumerate(hints):
|
|
441
|
+
if i > 0:
|
|
442
|
+
text.append(" · ", style="dim")
|
|
443
|
+
if key:
|
|
444
|
+
text.append(key, style="bold")
|
|
445
|
+
text.append(f" {label}", style="dim")
|
|
446
|
+
else:
|
|
447
|
+
text.append(label, style="dim")
|
|
448
|
+
return text
|
|
449
|
+
|
|
450
|
+
# ------------------------------------------------------------------
|
|
451
|
+
# Preview mode (alt-screen, full height, scrollable)
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
def _run_preview_loop(self, cap: RawInputCapture, screen: ScreenManager) -> None:
|
|
455
|
+
"""Show the focused session in the alternate screen until the user resumes / goes back.
|
|
456
|
+
|
|
457
|
+
Mouse-wheel events are received as ``wheel_up``/``wheel_down``
|
|
458
|
+
``KeyEvent``s thanks to SGR mouse tracking — so the preview
|
|
459
|
+
feels native even though the alt-screen has no real scrollback.
|
|
460
|
+
Nothing the preview draws lives in the main-buffer scrollback,
|
|
461
|
+
so pressing ``Esc`` to leave the picker leaves zero residue.
|
|
462
|
+
"""
|
|
463
|
+
if self._renderer is None or self._console is None:
|
|
464
|
+
self._show_preview = False
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
screen.enter_alternate_screen()
|
|
468
|
+
screen.enable_mouse_tracking()
|
|
469
|
+
try:
|
|
470
|
+
while self._show_preview and not self._done:
|
|
471
|
+
self._draw_preview_alt_screen()
|
|
472
|
+
key_event = cap.read_key(timeout=None)
|
|
473
|
+
if key_event is None:
|
|
474
|
+
continue
|
|
475
|
+
self.handle_key(key_event)
|
|
476
|
+
finally:
|
|
477
|
+
screen.disable_mouse_tracking()
|
|
478
|
+
screen.leave_alternate_screen()
|
|
479
|
+
|
|
480
|
+
def _draw_preview_alt_screen(self) -> None:
|
|
481
|
+
"""Repaint the alt-screen with the focused session preview."""
|
|
482
|
+
if self._console is None:
|
|
483
|
+
return
|
|
484
|
+
width = self._console.size.width
|
|
485
|
+
rows = self._console.size.height
|
|
486
|
+
entry = self._filtered[self._focused_index]
|
|
487
|
+
messages = self._load_messages_cached(entry)
|
|
488
|
+
|
|
489
|
+
header_lines = self._capture_lines(self._build_preview_header(entry, len(messages)), width)
|
|
490
|
+
|
|
491
|
+
body_height = max(1, rows - len(header_lines) - 1)
|
|
492
|
+
self._preview_body_height_last = body_height
|
|
493
|
+
|
|
494
|
+
body_lines = self._cached_session_lines(entry, messages, width)
|
|
495
|
+
total = len(body_lines)
|
|
496
|
+
|
|
497
|
+
if total <= body_height:
|
|
498
|
+
visible = body_lines
|
|
499
|
+
self._preview_scroll_offset = 0
|
|
500
|
+
above_count = 0
|
|
501
|
+
below_count = 0
|
|
502
|
+
else:
|
|
503
|
+
inner_height = max(1, body_height - 2)
|
|
504
|
+
max_offset = total - inner_height
|
|
505
|
+
if self._preview_scroll_offset > max_offset:
|
|
506
|
+
self._preview_scroll_offset = max_offset
|
|
507
|
+
end = total - self._preview_scroll_offset
|
|
508
|
+
start = end - inner_height
|
|
509
|
+
visible = body_lines[start:end]
|
|
510
|
+
above_count = start
|
|
511
|
+
below_count = total - end
|
|
512
|
+
|
|
513
|
+
body_block: list[str] = []
|
|
514
|
+
if total > body_height:
|
|
515
|
+
body_block.append(self._render_scroll_marker("up", above_count, width))
|
|
516
|
+
body_block.extend(visible)
|
|
517
|
+
body_block.append(self._render_scroll_marker("down", below_count, width))
|
|
518
|
+
else:
|
|
519
|
+
body_block.extend(visible)
|
|
520
|
+
|
|
521
|
+
if total > body_height:
|
|
522
|
+
window_start = total - self._preview_scroll_offset - len(visible) + 1
|
|
523
|
+
window_end = total - self._preview_scroll_offset
|
|
524
|
+
position = (window_start, window_end, total)
|
|
525
|
+
else:
|
|
526
|
+
position = None
|
|
527
|
+
footer_line = self._build_preview_footer_line(width, len(messages), position)
|
|
528
|
+
|
|
529
|
+
out = self._console.file
|
|
530
|
+
out.write("\x1b[H\x1b[2J")
|
|
531
|
+
for line in header_lines:
|
|
532
|
+
out.write(line)
|
|
533
|
+
out.write("\r\n")
|
|
534
|
+
for line in body_block:
|
|
535
|
+
out.write(line)
|
|
536
|
+
out.write("\r\n")
|
|
537
|
+
used = len(header_lines) + len(body_block)
|
|
538
|
+
pad = rows - used - 1
|
|
539
|
+
for _i in range(max(0, pad)):
|
|
540
|
+
out.write("\r\n")
|
|
541
|
+
out.write(f"\x1b[{rows};1H\x1b[2K{footer_line}")
|
|
542
|
+
out.flush()
|
|
543
|
+
|
|
544
|
+
def _render_scroll_marker(self, direction: str, count: int, width: int) -> str:
|
|
545
|
+
arrow = "↑" if direction == "up" else "↓"
|
|
546
|
+
if count <= 0:
|
|
547
|
+
text = Text(f"{arrow} ─", style="dim")
|
|
548
|
+
else:
|
|
549
|
+
text = Text(style="dim")
|
|
550
|
+
text.append(arrow, style="bold dim")
|
|
551
|
+
text.append(" ")
|
|
552
|
+
text.append(_("{n} more line{s}").format(n=count, s="" if count == 1 else "s"))
|
|
553
|
+
lines = self._capture_lines(text, width)
|
|
554
|
+
return lines[0] if lines else ""
|
|
555
|
+
|
|
556
|
+
def _build_preview_header(self, entry: SessionEntry, msg_count: int) -> RenderableType:
|
|
557
|
+
title = Text()
|
|
558
|
+
title.append(entry.title, style="bold cyan")
|
|
559
|
+
|
|
560
|
+
meta = Text(style="dim")
|
|
561
|
+
meta.append(_format_relative_time(entry.mtime))
|
|
562
|
+
if entry.git_branch:
|
|
563
|
+
meta.append(" · ")
|
|
564
|
+
meta.append(entry.git_branch)
|
|
565
|
+
meta.append(" · ")
|
|
566
|
+
meta.append(_("{n} message{s}").format(n=msg_count, s="" if msg_count == 1 else "s"))
|
|
567
|
+
meta.append(" · ")
|
|
568
|
+
meta.append(_format_size(entry.size_bytes))
|
|
569
|
+
return Group(title, meta, Text(""))
|
|
570
|
+
|
|
571
|
+
def _build_preview_footer_line(
|
|
572
|
+
self,
|
|
573
|
+
width: int,
|
|
574
|
+
msg_count: int,
|
|
575
|
+
position: tuple[int, int, int] | None = None,
|
|
576
|
+
) -> str:
|
|
577
|
+
text = Text()
|
|
578
|
+
text.append("Enter", style="bold")
|
|
579
|
+
text.append(" ")
|
|
580
|
+
text.append(_("resume"), style="dim")
|
|
581
|
+
text.append(" · ", style="dim")
|
|
582
|
+
text.append("Esc", style="bold")
|
|
583
|
+
text.append(" ")
|
|
584
|
+
text.append(_("back"), style="dim")
|
|
585
|
+
if msg_count > 0:
|
|
586
|
+
text.append(" · ", style="dim")
|
|
587
|
+
text.append("↑↓", style="bold")
|
|
588
|
+
text.append(" ")
|
|
589
|
+
text.append(_("scroll"), style="dim")
|
|
590
|
+
if position is not None:
|
|
591
|
+
start, end, total = position
|
|
592
|
+
text.append(" · ", style="dim")
|
|
593
|
+
text.append(f"{start}-{end}/{total}", style="dim")
|
|
594
|
+
lines = self._capture_lines(text, width)
|
|
595
|
+
return lines[0] if lines else ""
|
|
596
|
+
|
|
597
|
+
def _cached_session_lines(self, entry: SessionEntry, messages: list[Message], width: int) -> list[str]:
|
|
598
|
+
key = (entry.session_id, width)
|
|
599
|
+
cached = self._rendered_body_cache.get(key)
|
|
600
|
+
if cached is not None:
|
|
601
|
+
return cached
|
|
602
|
+
lines = self._build_session_lines(messages, width)
|
|
603
|
+
self._rendered_body_cache[key] = lines
|
|
604
|
+
return lines
|
|
605
|
+
|
|
606
|
+
def _build_session_lines(self, messages: list[Message], width: int) -> list[str]:
|
|
607
|
+
if not messages:
|
|
608
|
+
return [_("(empty session)")]
|
|
609
|
+
buf = io.StringIO()
|
|
610
|
+
sub = Console(
|
|
611
|
+
file=buf,
|
|
612
|
+
width=width,
|
|
613
|
+
force_terminal=True,
|
|
614
|
+
color_system="truecolor",
|
|
615
|
+
legacy_windows=False,
|
|
616
|
+
soft_wrap=False,
|
|
617
|
+
highlight=False,
|
|
618
|
+
)
|
|
619
|
+
if self._renderer is not None:
|
|
620
|
+
self._replay_via_renderer(sub, messages)
|
|
621
|
+
else:
|
|
622
|
+
self._fallback_render(sub, messages)
|
|
623
|
+
text = buf.getvalue()
|
|
624
|
+
lines = text.split("\n")
|
|
625
|
+
if lines and lines[-1] == "":
|
|
626
|
+
lines.pop()
|
|
627
|
+
return lines
|
|
628
|
+
|
|
629
|
+
def _replay_via_renderer(self, console: Console, messages: list[Message]) -> None:
|
|
630
|
+
"""Replay messages into ``console`` via :meth:`Renderer.replay_history`.
|
|
631
|
+
|
|
632
|
+
Verbose mode is forced *off* so the preview matches the compact
|
|
633
|
+
``--resume <id>`` look — tool calls collapse to one-line
|
|
634
|
+
summaries (``收到响应 (60 行)``) instead of dumping full
|
|
635
|
+
payloads. Tool details aren't useful for *deciding whether to
|
|
636
|
+
resume*; they'd just bury the conversational flow.
|
|
637
|
+
"""
|
|
638
|
+
renderer = self._renderer
|
|
639
|
+
assert renderer is not None
|
|
640
|
+
|
|
641
|
+
saved_console = renderer.console
|
|
642
|
+
saved_verbose = renderer._verbose
|
|
643
|
+
saved_text_flushed = renderer._text_flushed
|
|
644
|
+
saved_history = renderer._message_history
|
|
645
|
+
renderer.console = console
|
|
646
|
+
renderer._verbose = False
|
|
647
|
+
renderer._text_flushed = False
|
|
648
|
+
renderer._message_history = []
|
|
649
|
+
try:
|
|
650
|
+
renderer.replay_history(messages)
|
|
651
|
+
finally:
|
|
652
|
+
renderer.console = saved_console
|
|
653
|
+
renderer._verbose = saved_verbose
|
|
654
|
+
renderer._text_flushed = saved_text_flushed
|
|
655
|
+
renderer._message_history = saved_history
|
|
656
|
+
|
|
657
|
+
@staticmethod
|
|
658
|
+
def _fallback_render(console: Console, messages: list[Message]) -> None:
|
|
659
|
+
"""Minimal renderer used in tests / when no live renderer is provided."""
|
|
660
|
+
first = True
|
|
661
|
+
for msg in messages:
|
|
662
|
+
if not first:
|
|
663
|
+
console.print()
|
|
664
|
+
first = False
|
|
665
|
+
if msg.role == "user":
|
|
666
|
+
content = msg.content
|
|
667
|
+
if isinstance(content, str) and content.strip():
|
|
668
|
+
line = Text()
|
|
669
|
+
line.append("❯ ", style="bold cyan")
|
|
670
|
+
line.append(content)
|
|
671
|
+
console.print(line)
|
|
672
|
+
elif isinstance(content, list):
|
|
673
|
+
for block in content:
|
|
674
|
+
if isinstance(block, ToolResultBlock):
|
|
675
|
+
line = Text(" ⎿ ", style="dim")
|
|
676
|
+
preview = block.content.replace("\n", " ").strip()
|
|
677
|
+
if len(preview) > 200:
|
|
678
|
+
preview = preview[:200].rstrip() + "…"
|
|
679
|
+
line.append(preview, style="dim")
|
|
680
|
+
console.print(line)
|
|
681
|
+
else:
|
|
682
|
+
text = msg.get_text()
|
|
683
|
+
if text.strip():
|
|
684
|
+
console.print(Text(text))
|
|
685
|
+
|
|
686
|
+
def _capture_lines(self, renderable: RenderableType, width: int) -> list[str]:
|
|
687
|
+
buf = io.StringIO()
|
|
688
|
+
sub = Console(
|
|
689
|
+
file=buf,
|
|
690
|
+
width=width,
|
|
691
|
+
force_terminal=True,
|
|
692
|
+
color_system="truecolor",
|
|
693
|
+
legacy_windows=False,
|
|
694
|
+
soft_wrap=False,
|
|
695
|
+
highlight=False,
|
|
696
|
+
)
|
|
697
|
+
sub.print(renderable)
|
|
698
|
+
text = buf.getvalue()
|
|
699
|
+
lines = text.split("\n")
|
|
700
|
+
if lines and lines[-1] == "":
|
|
701
|
+
lines.pop()
|
|
702
|
+
return lines
|
|
703
|
+
|
|
704
|
+
def _load_messages_cached(self, entry: SessionEntry) -> list[Message]:
|
|
705
|
+
cached = self._messages_cache.get(entry.session_id)
|
|
706
|
+
if cached is not None:
|
|
707
|
+
return cached
|
|
708
|
+
try:
|
|
709
|
+
from iac_code.services.session_storage import SessionStorage
|
|
710
|
+
|
|
711
|
+
storage = SessionStorage()
|
|
712
|
+
msgs = storage.load(entry.cwd, entry.session_id)
|
|
713
|
+
except Exception:
|
|
714
|
+
msgs = []
|
|
715
|
+
self._messages_cache[entry.session_id] = msgs
|
|
716
|
+
return msgs
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ---------------------------------------------------------------------------
|
|
720
|
+
# Format helpers
|
|
721
|
+
# ---------------------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _format_relative_time(mtime: float) -> str:
|
|
725
|
+
delta = max(0.0, time.time() - mtime)
|
|
726
|
+
seconds = int(delta)
|
|
727
|
+
if seconds < 60:
|
|
728
|
+
return _("just now")
|
|
729
|
+
minutes = seconds // 60
|
|
730
|
+
if minutes < 60:
|
|
731
|
+
return _("{n} minute{s} ago").format(n=minutes, s="" if minutes == 1 else "s")
|
|
732
|
+
hours = minutes // 60
|
|
733
|
+
if hours < 24:
|
|
734
|
+
return _("{n} hour{s} ago").format(n=hours, s="" if hours == 1 else "s")
|
|
735
|
+
days = hours // 24
|
|
736
|
+
return _("{n} day{s} ago").format(n=days, s="" if days == 1 else "s")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _format_size(size_bytes: int) -> str:
|
|
740
|
+
if size_bytes < 1024:
|
|
741
|
+
return f"{size_bytes}B"
|
|
742
|
+
kb = size_bytes / 1024
|
|
743
|
+
if kb < 1024:
|
|
744
|
+
return f"{kb:.1f}KB"
|
|
745
|
+
mb = kb / 1024
|
|
746
|
+
if mb < 1024:
|
|
747
|
+
return f"{mb:.1f}MB"
|
|
748
|
+
gb = mb / 1024
|
|
749
|
+
return f"{gb:.1f}GB"
|