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,302 @@
1
+ """Raw terminal input capture using terminal raw mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sys
8
+ import termios
9
+ import time
10
+ import tty
11
+ from typing import Optional
12
+
13
+ from iac_code.ui.core.key_event import KeyEvent
14
+
15
+ _CURSOR_REPORT_RE = re.compile(rb"\x1b\[(\d+);(\d+)R")
16
+
17
+ # SGR-encoded mouse event: ``\x1b[<button;col;row{M|m}``. Only the
18
+ # leading ``[<button;col;row`` portion is matched against the bytes
19
+ # *after* the ESC byte, since we strip the ESC before parsing escape
20
+ # sequences.
21
+ _MOUSE_SGR_RE = re.compile(r"\[<(\d+);(\d+);(\d+)([Mm])")
22
+
23
+
24
+ def query_cursor_row(fd: int, timeout: float = 0.1) -> int | None:
25
+ """Send Device Status Report 6 and parse the cursor's 1-indexed row.
26
+
27
+ The terminal must already be in raw mode — under cooked mode the
28
+ response (``\\x1b[<row>;<col>R``) wouldn't be readable until a
29
+ newline. Returns ``None`` if the terminal doesn't reply within
30
+ ``timeout``.
31
+ """
32
+ import select
33
+
34
+ try:
35
+ os.write(fd, b"\x1b[6n")
36
+ except OSError:
37
+ return None
38
+ buf = b""
39
+ deadline = time.monotonic() + timeout
40
+ while True:
41
+ remaining = deadline - time.monotonic()
42
+ if remaining <= 0:
43
+ break
44
+ ready, _, _ = select.select([fd], [], [], remaining)
45
+ if not ready:
46
+ break
47
+ try:
48
+ chunk = os.read(fd, 32)
49
+ except OSError:
50
+ break
51
+ if not chunk:
52
+ break
53
+ buf += chunk
54
+ if b"R" in buf:
55
+ break
56
+ m = _CURSOR_REPORT_RE.search(buf)
57
+ if m is None:
58
+ return None
59
+ try:
60
+ return int(m.group(1))
61
+ except ValueError:
62
+ return None
63
+
64
+
65
+ # Mapping from escape sequence (after initial ESC byte) to key name
66
+ _ESCAPE_SEQUENCES: dict[str, str] = {
67
+ "[A": "up",
68
+ "[B": "down",
69
+ "[C": "right",
70
+ "[D": "left",
71
+ "[H": "home",
72
+ "[F": "end",
73
+ "[3~": "delete",
74
+ "[5~": "pageup",
75
+ "[6~": "pagedown",
76
+ "OP": "f1",
77
+ "OQ": "f2",
78
+ "OR": "f3",
79
+ "OS": "f4",
80
+ }
81
+
82
+
83
+ class RawInputCapture:
84
+ """Context manager that puts the terminal into raw mode for key-by-key input.
85
+
86
+ Usage:
87
+ with RawInputCapture() as cap:
88
+ event = cap.read_key(timeout=1.0)
89
+ """
90
+
91
+ def __init__(self, fd: int | None = None) -> None:
92
+ self._fd = fd if fd is not None else sys.stdin.fileno()
93
+ self._old_settings: Optional[list] = None
94
+
95
+ def __enter__(self) -> "RawInputCapture":
96
+ try:
97
+ self._old_settings = termios.tcgetattr(self._fd)
98
+ tty.setraw(self._fd)
99
+ # Enable bracket paste mode so we can distinguish pasted text from typed input
100
+ os.write(self._fd, b"\033[?2004h")
101
+ except OSError:
102
+ # File descriptor may be invalid after interruption (e.g. double Ctrl+C)
103
+ self._old_settings = None
104
+ raise
105
+ return self
106
+
107
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
108
+ try:
109
+ # Disable bracket paste mode
110
+ os.write(self._fd, b"\033[?2004l")
111
+ except OSError:
112
+ pass
113
+ if self._old_settings is not None:
114
+ try:
115
+ termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_settings)
116
+ except OSError:
117
+ pass
118
+
119
+ def read_key(self, timeout: Optional[float] = None) -> Optional[KeyEvent]:
120
+ """Read a single key press and return the corresponding KeyEvent.
121
+
122
+ Args:
123
+ timeout: Maximum seconds to wait. None means block indefinitely.
124
+ Returns None if no key is available within the timeout.
125
+
126
+ Returns:
127
+ A KeyEvent, or None on timeout.
128
+ """
129
+ import select
130
+
131
+ if timeout is not None:
132
+ ready, _, _ = select.select([self._fd], [], [], timeout)
133
+ if not ready:
134
+ return None
135
+
136
+ first = os.read(self._fd, 1)
137
+ if not first:
138
+ return None
139
+
140
+ b = first[0]
141
+
142
+ # Escape — may begin a multi-byte sequence
143
+ if b == 27:
144
+ ready, _, _ = select.select([self._fd], [], [], 0.05)
145
+
146
+ if not ready:
147
+ # Standalone ESC
148
+ return self._byte_to_key_event(27)
149
+
150
+ # 64 bytes is enough for any reasonable single sequence
151
+ # including SGR mouse events at large coordinates.
152
+ rest = os.read(self._fd, 64)
153
+
154
+ # Bracket paste start: ESC [200~ — check raw bytes before decoding
155
+ # to avoid splitting multi-byte UTF-8 characters
156
+ if rest.startswith(b"[200~"):
157
+ pasted = self._read_bracketed_paste(rest[5:])
158
+ return KeyEvent(key="paste", char=pasted)
159
+
160
+ seq = rest.decode("utf-8", errors="replace")
161
+ return self._parse_escape_sequence(seq)
162
+
163
+ # Multi-byte UTF-8 character (Chinese, etc.)
164
+ if b >= 0x80:
165
+ return self._read_utf8_char(first)
166
+
167
+ return self._byte_to_key_event(b)
168
+
169
+ def _read_bracketed_paste(self, initial: bytes) -> str:
170
+ """Read pasted content until the bracket paste end sequence ESC [201~.
171
+
172
+ Works entirely with raw bytes to avoid splitting multi-byte UTF-8
173
+ characters during intermediate reads, and only decodes once all
174
+ content has been collected.
175
+
176
+ Args:
177
+ initial: Any leftover bytes already read after the start marker.
178
+
179
+ Returns:
180
+ The pasted text with the end marker stripped.
181
+ """
182
+ import select as _select
183
+
184
+ buf = initial
185
+ end_marker = b"\033[201~"
186
+
187
+ while end_marker not in buf:
188
+ ready, _, _ = _select.select([self._fd], [], [], 1.0)
189
+ if not ready:
190
+ break
191
+ chunk = os.read(self._fd, 4096)
192
+ if not chunk:
193
+ break
194
+ buf += chunk
195
+
196
+ idx = buf.find(end_marker)
197
+ if idx >= 0:
198
+ buf = buf[:idx]
199
+
200
+ text = buf.decode("utf-8", errors="replace")
201
+ # Normalize \r\n and \r to \n
202
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
203
+ return text
204
+
205
+ def _read_utf8_char(self, first_byte: bytes) -> KeyEvent:
206
+ """Read remaining bytes of a multi-byte UTF-8 character."""
207
+ b = first_byte[0]
208
+ # Determine expected byte count from leading byte
209
+ if b < 0xC0:
210
+ # Continuation byte alone — shouldn't happen
211
+ return KeyEvent(key="unknown", char="", ctrl=False, alt=False, shift=False)
212
+ elif b < 0xE0:
213
+ remaining = 1
214
+ elif b < 0xF0:
215
+ remaining = 2
216
+ else:
217
+ remaining = 3
218
+
219
+ data = first_byte
220
+ for _ in range(remaining):
221
+ extra = os.read(self._fd, 1)
222
+ if not extra:
223
+ break
224
+ data += extra
225
+
226
+ try:
227
+ char = data.decode("utf-8")
228
+ except UnicodeDecodeError:
229
+ return KeyEvent(key="unknown", char="", ctrl=False, alt=False, shift=False)
230
+
231
+ return KeyEvent(key=char, char=char, ctrl=False, alt=False, shift=False)
232
+
233
+ @staticmethod
234
+ def _byte_to_key_event(b: int) -> KeyEvent:
235
+ """Convert a single byte value to a KeyEvent.
236
+
237
+ Args:
238
+ b: Integer byte value (0-255).
239
+
240
+ Returns:
241
+ The corresponding KeyEvent.
242
+ """
243
+ if b in (13, 10):
244
+ return KeyEvent(key="enter", char=chr(b))
245
+
246
+ if b == 9:
247
+ return KeyEvent(key="tab", char="\t")
248
+
249
+ if b == 127:
250
+ return KeyEvent(key="backspace", char=chr(127))
251
+
252
+ if b == 27:
253
+ return KeyEvent(key="escape", char="\x1b")
254
+
255
+ # Ctrl+a through ctrl+z (bytes 1-26, excluding 9, 10, 13)
256
+ if 1 <= b <= 26:
257
+ letter = chr(ord("a") + b - 1)
258
+ return KeyEvent(key=letter, char=chr(b), ctrl=True)
259
+
260
+ # Printable ASCII: 32-126
261
+ if 32 <= b <= 126:
262
+ char = chr(b)
263
+ shift = char.isupper()
264
+ return KeyEvent(key=char, char=char, shift=shift)
265
+
266
+ # Fallback
267
+ return KeyEvent(key="unknown", char=chr(b) if b < 256 else "")
268
+
269
+ @staticmethod
270
+ def _parse_escape_sequence(seq: str) -> KeyEvent:
271
+ """Parse the bytes following an ESC byte into a KeyEvent.
272
+
273
+ Args:
274
+ seq: String of characters that came after the ESC byte.
275
+
276
+ Returns:
277
+ The corresponding KeyEvent.
278
+ """
279
+ if seq in _ESCAPE_SEQUENCES:
280
+ return KeyEvent(key=_ESCAPE_SEQUENCES[seq], char="")
281
+
282
+ # SGR mouse event — only wheel up/down are useful here. The
283
+ # ``rest`` buffer may contain multiple back-to-back wheel events
284
+ # when the user spins the wheel quickly; ``re.match`` picks up
285
+ # the first one and the trailing bytes are dropped (each tick
286
+ # is small, losing a few during a fast spin is fine).
287
+ m = _MOUSE_SGR_RE.match(seq)
288
+ if m is not None:
289
+ button = int(m.group(1))
290
+ if button == 64:
291
+ return KeyEvent(key="wheel_up", char="")
292
+ if button == 65:
293
+ return KeyEvent(key="wheel_down", char="")
294
+ # Other mouse events (clicks, motion) — pass through as a
295
+ # generic ``mouse`` event so callers can ignore them.
296
+ return KeyEvent(key="mouse", char="")
297
+
298
+ # Single printable char → alt+char
299
+ if len(seq) == 1 and 32 <= ord(seq) <= 126:
300
+ return KeyEvent(key=seq, char=seq, alt=True)
301
+
302
+ return KeyEvent(key="unknown", char="")
@@ -0,0 +1,80 @@
1
+ """Screen management using the alternate screen buffer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+
9
+
10
+ class ScreenManager:
11
+ """Wraps a Rich Console to provide alternate-screen and clear/render helpers.
12
+
13
+ The alternate screen buffer allows the UI to restore the terminal to its
14
+ previous state when the application exits.
15
+ """
16
+
17
+ def __init__(self, console: Console) -> None:
18
+ self._console = console
19
+
20
+ def enter_alternate_screen(self) -> None:
21
+ """Switch to the terminal alternate screen buffer and move cursor home."""
22
+ self._console.file.write("\033[?1049h")
23
+ self._console.file.write("\033[H")
24
+ self._console.file.flush()
25
+
26
+ def leave_alternate_screen(self) -> None:
27
+ """Switch back from the alternate screen buffer to the normal screen."""
28
+ self._console.file.write("\033[?1049l")
29
+ self._console.file.flush()
30
+
31
+ def enable_mouse_tracking(self) -> None:
32
+ """Turn on basic + SGR-encoded mouse event reporting.
33
+
34
+ ``?1000h`` enables button (incl. wheel) press/release events;
35
+ ``?1006h`` switches the encoding to the SGR form
36
+ ``\\x1b[<button;col;row{M|m}`` so coordinates aren't bounded by
37
+ a single byte and event parsing is unambiguous. Used by the
38
+ ``/resume`` preview to translate scroll-wheel ticks into
39
+ ``wheel_up``/``wheel_down`` ``KeyEvent``s while the alternate
40
+ screen is active.
41
+ """
42
+ self._console.file.write("\033[?1000h\033[?1006h")
43
+ self._console.file.flush()
44
+
45
+ def disable_mouse_tracking(self) -> None:
46
+ """Restore the terminal's pre-tracking mouse settings."""
47
+ self._console.file.write("\033[?1006l\033[?1000l")
48
+ self._console.file.flush()
49
+
50
+ def clear(self) -> None:
51
+ """Move cursor to top-left and erase the entire screen."""
52
+ self._console.file.write("\033[H\033[2J")
53
+ self._console.file.flush()
54
+
55
+ def clear_and_render(self, renderable: Any) -> None:
56
+ """Clear the screen and then render a Rich renderable.
57
+
58
+ Captures Rich's output and rewrites bare ``\\n`` as ``\\r\\n`` so the
59
+ render is correct under raw mode too — :class:`RawInputCapture`
60
+ clears OPOST, which would otherwise leave each line indented to the
61
+ end column of the previous one.
62
+
63
+ Args:
64
+ renderable: Any object that Rich Console can print.
65
+ """
66
+ with self._console.capture() as capture:
67
+ self._console.print(renderable)
68
+ text = capture.get().replace("\r\n", "\n").replace("\n", "\r\n")
69
+ self._console.file.write("\033[H\033[2J")
70
+ self._console.file.write(text)
71
+ self._console.file.flush()
72
+
73
+ def get_size(self) -> tuple[int, int]:
74
+ """Return the current terminal dimensions.
75
+
76
+ Returns:
77
+ A tuple of (cols, rows).
78
+ """
79
+ size = self._console.size
80
+ return size.width, size.height
File without changes
@@ -0,0 +1,178 @@
1
+ """GlobalSearch dialog — search file contents with ripgrep/grep (Ctrl+F)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from typing import Callable
9
+
10
+ from rich.panel import Panel
11
+ from rich.syntax import Syntax
12
+ from rich.text import Text
13
+
14
+ from iac_code.i18n import _
15
+ from iac_code.ui.components.fuzzy_picker import FuzzyPicker, PickerItem
16
+
17
+
18
+ class GlobalSearch:
19
+ """Dialog for searching file contents using ripgrep (or grep fallback).
20
+
21
+ Selecting a result returns ``"file_path:line_number"`` and inserts
22
+ ``@relative_path:line_number`` into the input.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ root_dir: str,
28
+ on_select: Callable[[str], None],
29
+ on_cancel: Callable[[], None],
30
+ keybinding_manager: object | None = None,
31
+ ) -> None:
32
+ self._root_dir = os.path.abspath(root_dir)
33
+ self._on_select = on_select
34
+ self._on_cancel = on_cancel
35
+ self._km = keybinding_manager
36
+
37
+ # ------------------------------------------------------------------
38
+ # Public API
39
+ # ------------------------------------------------------------------
40
+
41
+ def run(self) -> str | None:
42
+ """Open the global-search picker and return ``file:line`` or None."""
43
+ result_holder: list[str] = []
44
+
45
+ def _on_select(item: PickerItem) -> None:
46
+ key = item.key # "abs_path:lineno"
47
+ result_holder.append(key)
48
+ self._on_select(f"@{item.display}")
49
+
50
+ def _on_cancel() -> None:
51
+ self._on_cancel()
52
+
53
+ def _empty_message() -> str:
54
+ return _("No matching content")
55
+
56
+ picker = FuzzyPicker(
57
+ items=self._search,
58
+ on_select=_on_select,
59
+ on_cancel=_on_cancel,
60
+ title=_("Search in Files"),
61
+ placeholder=_("Type to search content..."),
62
+ empty_message=_("Enter search query"),
63
+ render_preview=self._render_preview,
64
+ debounce_ms=300,
65
+ keybinding_manager=self._km,
66
+ )
67
+ picker.run()
68
+
69
+ return result_holder[0] if result_holder else None
70
+
71
+ # ------------------------------------------------------------------
72
+ # Internal helpers
73
+ # ------------------------------------------------------------------
74
+
75
+ def _search(self, query: str) -> list[PickerItem]:
76
+ """Run ripgrep (or grep) and return PickerItems for each match."""
77
+ if not query:
78
+ return []
79
+
80
+ try:
81
+ output = self._run_search(query)
82
+ except Exception:
83
+ return []
84
+
85
+ return self._parse_results(output)
86
+
87
+ def _run_search(self, query: str) -> str:
88
+ """Execute ripgrep or grep and return stdout."""
89
+ if shutil.which("rg"):
90
+ cmd = [
91
+ "rg",
92
+ "--line-number",
93
+ "--no-heading",
94
+ "--color=never",
95
+ "--max-count=100",
96
+ query,
97
+ self._root_dir,
98
+ ]
99
+ else:
100
+ cmd = [
101
+ "grep",
102
+ "-rn",
103
+ "--include=*",
104
+ "--color=never",
105
+ query,
106
+ self._root_dir,
107
+ ]
108
+
109
+ result = subprocess.run(
110
+ cmd,
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=10,
114
+ )
115
+ return result.stdout
116
+
117
+ def _parse_results(self, output: str) -> list[PickerItem]:
118
+ """Parse ``filepath:linenum:matched_line`` output into PickerItems."""
119
+ items: list[PickerItem] = []
120
+ seen: set[str] = set()
121
+
122
+ for line in output.splitlines():
123
+ parts = line.split(":", 2)
124
+ if len(parts) < 3:
125
+ continue
126
+ file_path, lineno_str, matched_text = parts[0], parts[1], parts[2]
127
+ if not lineno_str.isdigit():
128
+ continue
129
+
130
+ key = f"{file_path}:{lineno_str}"
131
+ if key in seen:
132
+ continue
133
+ seen.add(key)
134
+
135
+ rel_path = os.path.relpath(file_path, self._root_dir)
136
+ display = f"{rel_path}:{lineno_str} {matched_text.strip()}"
137
+
138
+ items.append(
139
+ PickerItem(
140
+ key=key,
141
+ display=display,
142
+ metadata={"file_path": file_path, "lineno": int(lineno_str), "text": matched_text},
143
+ filter_text=display,
144
+ )
145
+ )
146
+
147
+ return items
148
+
149
+ def _render_preview(self, item: PickerItem) -> Panel:
150
+ """Render matched line ±5 lines with syntax highlighting."""
151
+ meta = item.metadata
152
+ if not isinstance(meta, dict):
153
+ return Panel(Text(""), border_style="dim")
154
+
155
+ file_path: str = meta["file_path"]
156
+ lineno: int = meta["lineno"]
157
+ ext = os.path.splitext(file_path)[1].lstrip(".")
158
+
159
+ start = max(1, lineno - 5)
160
+ end = lineno + 5
161
+
162
+ try:
163
+ with open(file_path, encoding="utf-8", errors="replace") as fh:
164
+ all_lines = fh.readlines()
165
+ snippet_lines = all_lines[start - 1 : end]
166
+ content = "".join(snippet_lines)
167
+ except OSError:
168
+ content = ""
169
+
170
+ syntax = Syntax(
171
+ content,
172
+ ext or "text",
173
+ line_numbers=True,
174
+ start_line=start,
175
+ highlight_lines={lineno},
176
+ )
177
+ rel_path = os.path.relpath(file_path, self._root_dir)
178
+ return Panel(syntax, title=f"{rel_path}:{lineno}", border_style="dim")
@@ -0,0 +1,100 @@
1
+ """HistorySearch dialog — search conversation history with Ctrl+R."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ from rich.panel import Panel
8
+
9
+ from iac_code.i18n import _
10
+ from iac_code.ui.components.fuzzy_picker import FuzzyPicker, PickerItem
11
+
12
+
13
+ class HistorySearch:
14
+ """Dialog for searching conversation history.
15
+
16
+ Shows user messages most-recent-first; selecting one returns the full text.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ messages: list[dict[str, Any]],
22
+ on_select: Callable[[str], None],
23
+ on_cancel: Callable[[], None],
24
+ keybinding_manager: object | None = None,
25
+ ) -> None:
26
+ self._messages = messages
27
+ self._on_select = on_select
28
+ self._on_cancel = on_cancel
29
+ self._km = keybinding_manager
30
+ self._result: str | None = None
31
+
32
+ # ------------------------------------------------------------------
33
+ # Public API
34
+ # ------------------------------------------------------------------
35
+
36
+ def run(self) -> str | None:
37
+ """Open the history search picker and return selected message text or None."""
38
+ items = self._build_items()
39
+ result_holder: list[str] = []
40
+ cancelled = [False]
41
+
42
+ def _on_select(item: PickerItem) -> None:
43
+ content: str = item.metadata
44
+ result_holder.append(content)
45
+ self._on_select(content)
46
+
47
+ def _on_cancel() -> None:
48
+ cancelled[0] = True
49
+ self._on_cancel()
50
+
51
+ picker = FuzzyPicker(
52
+ items=items,
53
+ on_select=_on_select,
54
+ on_cancel=_on_cancel,
55
+ title=_("Search Conversation History"),
56
+ placeholder=_("Type to search..."),
57
+ empty_message=_("No conversation history"),
58
+ render_preview=self._render_preview,
59
+ keybinding_manager=self._km,
60
+ )
61
+ picker.run()
62
+
63
+ return result_holder[0] if result_holder else None
64
+
65
+ # ------------------------------------------------------------------
66
+ # Internal helpers
67
+ # ------------------------------------------------------------------
68
+
69
+ def _build_items(self) -> list[PickerItem]:
70
+ """Extract user messages, most recent first, as PickerItems."""
71
+ items: list[PickerItem] = []
72
+ user_messages = [m for m in self._messages if m.get("role") == "user"]
73
+ # Most recent first
74
+ for i, msg in enumerate(reversed(user_messages)):
75
+ content = msg.get("content", "")
76
+ if isinstance(content, list):
77
+ # Handle structured content blocks
78
+ text_parts = [block.get("text", "") if isinstance(block, dict) else str(block) for block in content]
79
+ content = " ".join(text_parts)
80
+ content = str(content)
81
+ items.append(
82
+ PickerItem(
83
+ key=f"history-{i}",
84
+ display=content[:80],
85
+ metadata=content,
86
+ filter_text=content,
87
+ )
88
+ )
89
+ return items
90
+
91
+ def _render_preview(self, item: PickerItem) -> Panel:
92
+ """Render full message text in a panel."""
93
+ from rich.text import Text
94
+
95
+ content: str = item.metadata
96
+ return Panel(
97
+ Text(content),
98
+ title=_("Message Preview"),
99
+ border_style="dim",
100
+ )