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.
Files changed (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. 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"