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,118 @@
1
+ """Persistent input history with navigation and prefix search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ class InputHistory:
9
+ """Stores and retrieves terminal input history from a plain text file.
10
+
11
+ File format: one entry per line, most-recently-appended at the end.
12
+
13
+ Attributes:
14
+ _entries: In-memory list of history entries (oldest first).
15
+ _nav_index: Current navigation position; -1 means not navigating.
16
+ _saved_input: The input text that was active when navigation started.
17
+ """
18
+
19
+ def __init__(self, history_file: str) -> None:
20
+ self._file = history_file
21
+ self._entries: list[str] = []
22
+ self._nav_index: int = -1
23
+ self._saved_input: str = ""
24
+ self._load()
25
+
26
+ # ------------------------------------------------------------------
27
+ # Persistence
28
+ # ------------------------------------------------------------------
29
+
30
+ def _load(self) -> None:
31
+ """Load entries from the history file if it exists."""
32
+ if not os.path.exists(self._file):
33
+ return
34
+ with open(self._file, encoding="utf-8") as f:
35
+ for line in f:
36
+ entry = line.rstrip("\n")
37
+ if entry:
38
+ self._entries.append(entry)
39
+
40
+ def _save(self) -> None:
41
+ """Persist all entries to the history file."""
42
+ with open(self._file, "w", encoding="utf-8") as f:
43
+ for entry in self._entries:
44
+ f.write(entry + "\n")
45
+
46
+ # ------------------------------------------------------------------
47
+ # Mutation
48
+ # ------------------------------------------------------------------
49
+
50
+ def append(self, entry: str) -> None:
51
+ """Append an entry, skipping empty strings and consecutive duplicates.
52
+
53
+ The new entry is persisted to disk immediately.
54
+ Navigation state is reset.
55
+ """
56
+ if not entry:
57
+ return
58
+ if self._entries and self._entries[-1] == entry:
59
+ return
60
+ self._entries.append(entry)
61
+ self._nav_index = -1
62
+ self._saved_input = ""
63
+ self._save()
64
+
65
+ # ------------------------------------------------------------------
66
+ # Search
67
+ # ------------------------------------------------------------------
68
+
69
+ def search(self, prefix: str) -> list[str]:
70
+ """Return entries whose text starts with *prefix*, most recent first."""
71
+ return [e for e in reversed(self._entries) if e.startswith(prefix)]
72
+
73
+ # ------------------------------------------------------------------
74
+ # Navigation
75
+ # ------------------------------------------------------------------
76
+
77
+ def navigate(self, direction: int, current_input: str = "") -> str | None:
78
+ """Navigate through history.
79
+
80
+ Args:
81
+ direction: -1 to go older, +1 to go newer.
82
+ current_input: The current buffer text; saved on the first call
83
+ so it can be restored when navigating back past the
84
+ newest entry.
85
+
86
+ Returns:
87
+ The history entry at the new position, or None when navigating
88
+ past the newest entry (caller should restore original input).
89
+ """
90
+ if not self._entries:
91
+ return None
92
+
93
+ n = len(self._entries)
94
+
95
+ if direction == -1:
96
+ # Going older
97
+ if self._nav_index == -1:
98
+ # First navigation — save current input
99
+ self._saved_input = current_input
100
+ self._nav_index = n - 1
101
+ else:
102
+ # Stay at oldest
103
+ if self._nav_index > 0:
104
+ self._nav_index -= 1
105
+ return self._entries[self._nav_index]
106
+
107
+ else:
108
+ # Going newer (direction == 1)
109
+ if self._nav_index == -1:
110
+ # Not navigating; nothing to do
111
+ return None
112
+ if self._nav_index < n - 1:
113
+ self._nav_index += 1
114
+ return self._entries[self._nav_index]
115
+ else:
116
+ # Past the newest — stop navigating, signal restore
117
+ self._nav_index = -1
118
+ return None
@@ -0,0 +1,41 @@
1
+ """Key event type definitions for terminal input handling."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class KeyEvent:
8
+ """Represents a single key press event from the terminal.
9
+
10
+ Attributes:
11
+ key: Normalized key name (e.g. "a", "up", "enter", "f1").
12
+ char: Raw character string associated with the key press.
13
+ ctrl: True if the Ctrl modifier was held.
14
+ alt: True if the Alt/Meta modifier was held.
15
+ shift: True if the Shift modifier was held.
16
+ """
17
+
18
+ key: str
19
+ char: str
20
+ ctrl: bool = False
21
+ alt: bool = False
22
+ shift: bool = False
23
+
24
+ @property
25
+ def key_id(self) -> str:
26
+ """Return a normalized string identifier for this key event.
27
+
28
+ Format: [ctrl+][alt+]<key>
29
+ Shift is NOT included as a prefix for printable characters because
30
+ the character itself (e.g. "A") already reflects the shift state.
31
+
32
+ Examples:
33
+ "a", "ctrl+r", "alt+p", "ctrl+alt+x", "up", "enter"
34
+ """
35
+ parts: list[str] = []
36
+ if self.ctrl:
37
+ parts.append("ctrl")
38
+ if self.alt:
39
+ parts.append("alt")
40
+ parts.append(self.key)
41
+ return "+".join(parts)
@@ -0,0 +1,507 @@
1
+ """Main REPL input component replacing prompt-toolkit's PromptSession."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import sys
7
+ import unicodedata
8
+ from typing import TYPE_CHECKING, Optional
9
+
10
+ from iac_code.ui.core.key_event import KeyEvent
11
+
12
+
13
+ def _display_width(s: str) -> int:
14
+ """Return the terminal display width of a string.
15
+
16
+ East Asian wide/fullwidth characters occupy 2 columns.
17
+ """
18
+ w = 0
19
+ for ch in s:
20
+ eaw = unicodedata.east_asian_width(ch)
21
+ w += 2 if eaw in ("W", "F") else 1
22
+ return w
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from typing import Callable
27
+
28
+ from iac_code.ui.core.input_history import InputHistory
29
+ from iac_code.ui.keybindings.manager import KeybindingManager
30
+ from iac_code.ui.suggestions.aggregator import SuggestionAggregator
31
+
32
+ # ANSI escape helpers
33
+ _COLOR_SELECTED = "\033[96m" # bright_cyan — matches logo accent color
34
+ _COLOR_DIM = "\033[38;2;128;128;128m" # gray (#808080)
35
+ _COLOR_GHOST = "\033[2m" # dim
36
+ _COLOR_RESET = "\033[0m"
37
+ _COLOR_BOLD = "\033[1m"
38
+ _COLOR_CYAN = "\033[36m"
39
+
40
+
41
+ class PromptInput:
42
+ """Interactive line-editor with inline rendering, ghost text, and suggestions.
43
+
44
+ The public entry-point is :meth:`get_input`, which runs a blocking
45
+ input loop in a thread executor so it does not block the asyncio event
46
+ loop. Individual key handling is exposed via :meth:`_handle_key` so
47
+ that tests can drive the component without a real terminal.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ keybinding_manager: "KeybindingManager",
53
+ suggestion_aggregator: "SuggestionAggregator | None" = None,
54
+ history: "InputHistory | None" = None,
55
+ console=None,
56
+ ) -> None:
57
+ self._km = keybinding_manager
58
+ self._aggregator = suggestion_aggregator
59
+ self._history = history
60
+ self._console = console
61
+
62
+ # Buffer and cursor
63
+ self._buffer: list[str] = []
64
+ self._cursor: int = 0
65
+
66
+ # Control flags
67
+ self._submitted: bool = False
68
+ self._cancelled: bool = False
69
+ self._esc_pressed: bool = False
70
+ self._text_changed: bool = False # set when buffer content changes
71
+ self._pending_action: "Callable[[], None] | None" = None
72
+
73
+ # Rendering state
74
+ self._prompt: str = ""
75
+ self._prev_suggestion_lines: int = 0 # how many suggestion lines were rendered last frame
76
+ self._prev_content_extra_lines: int = 0 # extra lines beyond the first (for multi-line text)
77
+
78
+ # ------------------------------------------------------------------
79
+ # Public helpers
80
+ # ------------------------------------------------------------------
81
+
82
+ def schedule_action(self, action: "Callable[[], None]") -> None:
83
+ """Schedule an action to run outside of raw mode, then resume input."""
84
+ self._pending_action = action
85
+
86
+ def _get_text(self) -> str:
87
+ """Return current buffer contents as a string."""
88
+ return "".join(self._buffer)
89
+
90
+ # ------------------------------------------------------------------
91
+ # Key handling
92
+ # ------------------------------------------------------------------
93
+
94
+ def _handle_key(self, key_event: KeyEvent) -> None:
95
+ """Process a single key event and update internal state."""
96
+ key = key_event.key
97
+ ctrl = key_event.ctrl
98
+
99
+ # 0. Bracket paste → insert all content (including newlines) into buffer
100
+ if key == "paste":
101
+ self._insert(key_event.char)
102
+ return
103
+
104
+ # 1. Esc+Enter → insert newline
105
+ if self._esc_pressed:
106
+ self._esc_pressed = False
107
+ if key == "enter":
108
+ self._insert("\n")
109
+ return
110
+
111
+ # 2. Escape alone → set flag; resolve through KeybindingManager
112
+ if key == "escape":
113
+ self._esc_pressed = True
114
+ if self._aggregator and self._aggregator.suggestions:
115
+ self._aggregator.dismiss()
116
+ else:
117
+ self._km.resolve(key_event)
118
+ return
119
+
120
+ # 3. Ctrl+C → clear buffer if non-empty, otherwise cancel
121
+ if ctrl and key == "c":
122
+ if self._buffer:
123
+ self._buffer.clear()
124
+ self._cursor = 0
125
+ self._text_changed = True
126
+ else:
127
+ self._cancelled = True
128
+ return
129
+
130
+ # 4. Enter — accept suggestion and submit immediately
131
+ if key == "enter":
132
+ if self._aggregator and self._aggregator.suggestions:
133
+ result = self._aggregator.accept_selected()
134
+ if result is not None:
135
+ completion, start, end = result
136
+ self._apply_completion(completion, start, end)
137
+ self._submitted = True
138
+ return
139
+
140
+ # 5. Tab → accept ghost text
141
+ if key == "tab":
142
+ if self._aggregator:
143
+ result = self._aggregator.accept_ghost_text()
144
+ if result is not None:
145
+ completion, start, end = result
146
+ self._apply_completion(completion, start, end)
147
+ return
148
+ return
149
+
150
+ # 6. KeybindingManager resolution (Ctrl+R, Ctrl+P, etc.)
151
+ if self._km.resolve(key_event):
152
+ return
153
+
154
+ # 7. Up/Down with active suggestions → move selection
155
+ if self._aggregator and self._aggregator.suggestions:
156
+ if key == "up" or (ctrl and key == "p"):
157
+ self._aggregator.move_selection(-1)
158
+ return
159
+ if key == "down" or (ctrl and key == "n"):
160
+ self._aggregator.move_selection(1)
161
+ return
162
+
163
+ # 8. Up/Down with history (no active suggestions)
164
+ if self._history:
165
+ if key == "up":
166
+ entry = self._history.navigate(-1, self._get_text())
167
+ if entry is not None:
168
+ self._set_text(entry)
169
+ return
170
+ if key == "down":
171
+ entry = self._history.navigate(1)
172
+ if entry is None:
173
+ self._set_text("")
174
+ else:
175
+ self._set_text(entry)
176
+ return
177
+
178
+ # 9. Line editing
179
+ if (ctrl and key == "a") or key == "home":
180
+ self._cursor = 0
181
+ return
182
+ if (ctrl and key == "e") or key == "end":
183
+ self._cursor = len(self._buffer)
184
+ return
185
+ if ctrl and key == "k":
186
+ del self._buffer[self._cursor :]
187
+ self._text_changed = True
188
+ return
189
+ if ctrl and key == "u":
190
+ del self._buffer[: self._cursor]
191
+ self._cursor = 0
192
+ self._text_changed = True
193
+ return
194
+ if ctrl and key == "w":
195
+ pos = self._cursor
196
+ while pos > 0 and self._buffer[pos - 1] == " ":
197
+ pos -= 1
198
+ while pos > 0 and self._buffer[pos - 1] != " ":
199
+ pos -= 1
200
+ del self._buffer[pos : self._cursor]
201
+ self._cursor = pos
202
+ self._text_changed = True
203
+ return
204
+ if key == "left":
205
+ if self._cursor > 0:
206
+ self._cursor -= 1
207
+ return
208
+ if key == "right":
209
+ if self._cursor < len(self._buffer):
210
+ self._cursor += 1
211
+ return
212
+ if key == "backspace":
213
+ if self._cursor > 0:
214
+ del self._buffer[self._cursor - 1]
215
+ self._cursor -= 1
216
+ self._text_changed = True
217
+ return
218
+ if key == "delete":
219
+ if self._cursor < len(self._buffer):
220
+ del self._buffer[self._cursor]
221
+ self._text_changed = True
222
+ return
223
+
224
+ # 10. Printable character insertion
225
+ char = key_event.char
226
+ if char and char.isprintable():
227
+ self._insert(char)
228
+
229
+ # ------------------------------------------------------------------
230
+ # Private helpers
231
+ # ------------------------------------------------------------------
232
+
233
+ def _insert(self, text: str) -> None:
234
+ """Insert *text* at the current cursor position."""
235
+ for ch in text:
236
+ self._buffer.insert(self._cursor, ch)
237
+ self._cursor += 1
238
+ self._text_changed = True
239
+
240
+ def _set_text(self, text: str) -> None:
241
+ """Replace the entire buffer with *text*, cursor at end."""
242
+ self._buffer = list(text)
243
+ self._cursor = len(self._buffer)
244
+ self._text_changed = True
245
+
246
+ def _apply_completion(self, completion: str, start: int, end: int) -> None:
247
+ """Replace the token range [start, end) with *completion*."""
248
+ del self._buffer[start:end]
249
+ insert_pos = start
250
+ for ch in completion:
251
+ self._buffer.insert(insert_pos, ch)
252
+ insert_pos += 1
253
+ self._cursor = insert_pos
254
+ self._text_changed = True
255
+
256
+ # ------------------------------------------------------------------
257
+ # Suggestion update (sync wrapper for async aggregator)
258
+ # ------------------------------------------------------------------
259
+
260
+ def _update_suggestions_sync(self) -> None:
261
+ """Update suggestions based on current buffer content."""
262
+ if not self._aggregator:
263
+ return
264
+ self._aggregator.update(self._get_text(), self._cursor)
265
+
266
+ # ------------------------------------------------------------------
267
+ # Inline rendering
268
+ # ------------------------------------------------------------------
269
+
270
+ def _render(self) -> None:
271
+ """Re-render the input line, ghost text, and suggestion overlay."""
272
+ out = sys.stdout
273
+ text = self._get_text()
274
+ lines = text.split("\n")
275
+ content_extra_lines = len(lines) - 1
276
+ cols = shutil.get_terminal_size().columns
277
+
278
+ # Move cursor up to the prompt line (first content line)
279
+ if self._prev_content_extra_lines > 0:
280
+ out.write(f"\033[{self._prev_content_extra_lines}A")
281
+
282
+ # Clear all previous content + suggestion lines from the prompt line down
283
+ total_prev = self._prev_content_extra_lines + self._prev_suggestion_lines
284
+ out.write("\r\033[K") # clear prompt line
285
+ if total_prev > 0:
286
+ out.write("\033[s") # save
287
+ for _ in range(total_prev):
288
+ out.write("\033[B\033[2K")
289
+ out.write("\033[u") # restore
290
+
291
+ # Render prompt + first line
292
+ out.write(f"{_COLOR_BOLD}{_COLOR_CYAN}{self._prompt}{_COLOR_RESET}")
293
+ out.write(lines[0])
294
+
295
+ # Render continuation lines
296
+ for i in range(1, len(lines)):
297
+ out.write(f"\n\r{lines[i]}")
298
+
299
+ # Ghost text (only for single-line input)
300
+ ghost = ""
301
+ if not content_extra_lines and self._aggregator:
302
+ ghost = self._aggregator.ghost_text
303
+ if ghost:
304
+ out.write(f"{_COLOR_GHOST}{ghost}{_COLOR_RESET}")
305
+
306
+ # Position cursor: find which line and column the cursor maps to
307
+ cursor_line = 0
308
+ cursor_col = 0
309
+ pos = 0
310
+ for i, line in enumerate(lines):
311
+ line_end = pos + len(line)
312
+ if self._cursor <= line_end:
313
+ cursor_line = i
314
+ cursor_col = self._cursor - pos
315
+ break
316
+ pos = line_end + 1 # +1 for the \n
317
+ else:
318
+ cursor_line = len(lines) - 1
319
+ cursor_col = len(lines[-1])
320
+
321
+ # Terminal cursor is currently at end of the last content line (+ ghost).
322
+ # Move up to cursor_line.
323
+ lines_up = content_extra_lines - cursor_line
324
+ if lines_up > 0:
325
+ out.write(f"\033[{lines_up}A")
326
+
327
+ # Move to correct column
328
+ target_col = _display_width(lines[cursor_line][:cursor_col])
329
+ if cursor_line == 0:
330
+ target_col += _display_width(self._prompt)
331
+ out.write("\r")
332
+ if target_col > 0:
333
+ out.write(f"\033[{target_col}C")
334
+
335
+ # Render suggestion overlay below all content lines
336
+ suggestion_lines = 0
337
+ if self._aggregator and self._aggregator.suggestions:
338
+ visible = self._aggregator.visible_suggestions
339
+ selected = self._aggregator.visible_selected_index
340
+
341
+ max_name_w = max(len(s.display_text) for s in visible)
342
+ name_col_w = min(max_name_w + 3, int(cols * 0.4))
343
+
344
+ total_new_suggestions = len(visible) + 1 # items + hint bar
345
+
346
+ # Move from cursor position to after last content line
347
+ lines_to_bottom = content_extra_lines - cursor_line
348
+ if lines_to_bottom > 0:
349
+ out.write(f"\033[{lines_to_bottom}B")
350
+
351
+ # Pre-allocate space to prevent terminal scroll from corrupting
352
+ # cursor positions. Writing \n at the bottom of the terminal causes
353
+ # scrolling which invalidates save/restore cursor positions.
354
+ for _ in range(total_new_suggestions):
355
+ out.write("\n")
356
+ out.write(f"\033[{total_new_suggestions}A")
357
+
358
+ for i, item in enumerate(visible):
359
+ out.write("\n\r\033[K")
360
+ is_sel = i == selected
361
+ padded = item.display_text + " " * max(0, name_col_w - len(item.display_text))
362
+ desc = item.description
363
+ desc_max = cols - name_col_w - 4
364
+ if len(desc) > desc_max:
365
+ desc = desc[: max(0, desc_max - 1)] + "…"
366
+ color = _COLOR_SELECTED if is_sel else _COLOR_DIM
367
+ out.write(f" {color}{padded}{desc}{_COLOR_RESET}")
368
+ suggestion_lines += 1
369
+
370
+ from iac_code.i18n import _
371
+
372
+ out.write("\n\r\033[K")
373
+ nav, confirm, fill, dismiss = _("Navigate"), _("Confirm"), _("Fill"), _("Dismiss")
374
+ scroll_hint = ""
375
+ if self._aggregator.has_more_above:
376
+ scroll_hint += "↑"
377
+ if self._aggregator.has_more_below:
378
+ scroll_hint += "↓"
379
+ if scroll_hint:
380
+ scroll_hint = f" {scroll_hint}"
381
+ out.write(f" {_COLOR_DIM}↑↓ {nav}{scroll_hint} Enter {confirm} Tab {fill} Esc {dismiss}{_COLOR_RESET}")
382
+ suggestion_lines += 1
383
+
384
+ # Move cursor back to its correct position using explicit movement
385
+ # instead of save/restore, which breaks when terminal scrolls
386
+ total_up = lines_to_bottom + suggestion_lines
387
+ if total_up > 0:
388
+ out.write(f"\033[{total_up}A")
389
+ out.write("\r")
390
+ if target_col > 0:
391
+ out.write(f"\033[{target_col}C")
392
+
393
+ self._prev_content_extra_lines = content_extra_lines
394
+ self._prev_suggestion_lines = suggestion_lines
395
+ out.flush()
396
+
397
+ def _clear_suggestions(self) -> None:
398
+ """Clear any rendered suggestion lines below input."""
399
+ if self._prev_suggestion_lines > 0:
400
+ out = sys.stdout
401
+ out.write("\033[s") # save cursor
402
+ # Move to last content line first
403
+ if self._prev_content_extra_lines > 0:
404
+ out.write(f"\033[{self._prev_content_extra_lines}B")
405
+ for _ in range(self._prev_suggestion_lines):
406
+ out.write("\n\033[2K")
407
+ out.write("\033[u") # restore cursor
408
+ out.flush()
409
+ self._prev_suggestion_lines = 0
410
+
411
+ # ------------------------------------------------------------------
412
+ # Public async entry-point
413
+ # ------------------------------------------------------------------
414
+
415
+ async def get_input(self, prompt: str = "❯ ") -> Optional[str]:
416
+ """Prompt the user for input and return it.
417
+
418
+ Runs the blocking input loop directly in the main thread because
419
+ termios operations on stdin require the main thread on macOS.
420
+ This blocks the event loop while waiting for input, which is
421
+ acceptable for a REPL — we must wait for user input before proceeding.
422
+
423
+ Returns the entered string, or None if the user pressed Ctrl+C or
424
+ Ctrl+D.
425
+ """
426
+ return self._input_loop(prompt)
427
+
428
+ def _input_loop(self, prompt: str) -> Optional[str]:
429
+ """Blocking input loop with inline rendering."""
430
+ from iac_code.ui.core.raw_input import RawInputCapture
431
+
432
+ # Reset state
433
+ self._buffer = []
434
+ self._cursor = 0
435
+ self._submitted = False
436
+ self._cancelled = False
437
+ self._esc_pressed = False
438
+ self._text_changed = False
439
+ self._pending_action = None
440
+ self._prompt = prompt
441
+ self._prev_suggestion_lines = 0
442
+ self._prev_content_extra_lines = 0
443
+
444
+ # Initial render (just prompt)
445
+ sys.stdout.write(f"{_COLOR_BOLD}{_COLOR_CYAN}{prompt}{_COLOR_RESET}")
446
+ sys.stdout.flush()
447
+
448
+ while not self._submitted and not self._cancelled:
449
+ with RawInputCapture() as cap:
450
+ while not self._submitted and not self._cancelled and self._pending_action is None:
451
+ event = cap.read_key()
452
+ if event is None:
453
+ continue
454
+ self._handle_key(event)
455
+ if not self._submitted and not self._cancelled and self._pending_action is None:
456
+ if self._text_changed:
457
+ self._update_suggestions_sync()
458
+ self._text_changed = False
459
+ self._render()
460
+
461
+ # Execute pending action outside raw mode (so console.print works)
462
+ if self._pending_action is not None:
463
+ action = self._pending_action
464
+ self._pending_action = None
465
+ # Clear prompt line so action output starts on a clean line
466
+ sys.stdout.write("\r\x1b[K")
467
+ sys.stdout.flush()
468
+ action()
469
+ # Re-render prompt after action output
470
+ sys.stdout.write(f"{_COLOR_BOLD}{_COLOR_CYAN}{self._prompt}{_COLOR_RESET}")
471
+ sys.stdout.write(self._get_text())
472
+ sys.stdout.flush()
473
+ self._prev_content_extra_lines = 0
474
+ self._prev_suggestion_lines = 0
475
+
476
+ # Clear suggestion overlay before returning
477
+ self._clear_suggestions()
478
+
479
+ # Re-render submitted content with background highlight
480
+ if self._submitted:
481
+ text = self._get_text()
482
+ lines = text.split("\n")
483
+ term_width = shutil.get_terminal_size().columns
484
+ _bg = "\033[48;5;236m"
485
+
486
+ # Move cursor up to prompt line if multi-line
487
+ if self._prev_content_extra_lines > 0:
488
+ sys.stdout.write(f"\033[{self._prev_content_extra_lines}A")
489
+
490
+ # Render first line with prompt
491
+ first_content = f"{prompt}{lines[0]}"
492
+ pad = max(0, term_width - _display_width(first_content))
493
+ sys.stdout.write(
494
+ f"\r{_bg}{_COLOR_BOLD}{_COLOR_CYAN}{prompt}{_COLOR_RESET}{_bg}{lines[0]}{' ' * pad}{_COLOR_RESET}"
495
+ )
496
+
497
+ # Render continuation lines
498
+ for i in range(1, len(lines)):
499
+ pad = max(0, term_width - _display_width(lines[i]))
500
+ sys.stdout.write(f"\n\r{_bg}{lines[i]}{' ' * pad}{_COLOR_RESET}")
501
+
502
+ sys.stdout.write("\n")
503
+ sys.stdout.flush()
504
+
505
+ if self._cancelled:
506
+ return None
507
+ return self._get_text()