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,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
|
+
)
|