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
iac_code/ui/repl.py ADDED
@@ -0,0 +1,772 @@
1
+ """Main REPL loop — integrates all UI subsystems.
2
+
3
+ InlineREPL wires together:
4
+ - PromptInput (line-editor + history + suggestions)
5
+ - KeybindingManager (Ctrl+R / Ctrl+P / Ctrl+F global shortcuts)
6
+ - SuggestionAggregator (CommandProvider, FileProvider, DirectoryProvider, ShellHistoryProvider)
7
+ - InputHistory (persistent across sessions)
8
+ - Dialog launchers (HistorySearch, QuickOpen, GlobalSearch)
9
+ - CommandRegistry + AgentLoop for processing input
10
+ - Renderer for streaming output
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import os
17
+ import signal
18
+ import sys
19
+ import time
20
+ from dataclasses import dataclass
21
+ from types import ModuleType
22
+
23
+ from loguru import logger
24
+ from rich.console import Console
25
+
26
+ from iac_code.agent.agent_loop import AgentLoop
27
+ from iac_code.agent.system_prompt import build_system_prompt
28
+ from iac_code.commands import create_default_registry
29
+ from iac_code.commands.registry import LocalCommand, PromptCommand
30
+ from iac_code.config import get_config_dir, get_history_path, load_credentials
31
+ from iac_code.i18n import _
32
+ from iac_code.memory.memory_manager import MemoryManager
33
+ from iac_code.providers.manager import ProviderManager
34
+ from iac_code.services.session_index import SessionIndex
35
+ from iac_code.services.session_storage import SessionStorage
36
+ from iac_code.state import AppStateStore
37
+ from iac_code.state.app_state import AppState
38
+ from iac_code.tasks.notification_queue import NotificationQueue
39
+ from iac_code.tasks.task_state import TaskManager
40
+ from iac_code.tools.base import ToolRegistry
41
+ from iac_code.ui.banner import render_welcome_banner
42
+ from iac_code.ui.core.input_history import InputHistory
43
+ from iac_code.ui.core.prompt_input import PromptInput
44
+ from iac_code.ui.keybindings.manager import KeyBinding, KeybindingManager
45
+ from iac_code.ui.renderer import Renderer
46
+ from iac_code.ui.suggestions.aggregator import SuggestionAggregator
47
+ from iac_code.ui.suggestions.command_provider import CommandProvider
48
+ from iac_code.ui.suggestions.directory_provider import DirectoryProvider
49
+ from iac_code.ui.suggestions.file_provider import FileProvider
50
+ from iac_code.ui.suggestions.shell_history_provider import ShellHistoryProvider
51
+ from iac_code.utils.background_housekeeping import start_background_housekeeping
52
+
53
+ termios: ModuleType | None
54
+ try:
55
+ import termios as _termios
56
+ except ImportError: # Windows
57
+ termios = None
58
+ else:
59
+ termios = _termios
60
+
61
+
62
+ class ExitREPLError(Exception):
63
+ """Raised by /exit command to break the REPL loop."""
64
+
65
+
66
+ @dataclass
67
+ class CommandContext:
68
+ """Context passed to command handlers."""
69
+
70
+ console: Console
71
+ store: AppStateStore
72
+ repl: "InlineREPL"
73
+
74
+
75
+ class InlineREPL:
76
+ """Inline terminal REPL integrating all subsystems."""
77
+
78
+ def __init__(self, model: str, resume_session_id: str | bool | None = None) -> None:
79
+ self.console = Console()
80
+ # Lock the working directory for the lifetime of this REPL. All session
81
+ # storage and project-partitioning lookups go through this — agents can
82
+ # `cd` mid-session via Bash, but those changes must not relocate the
83
+ # session file or split it across two project dirs.
84
+ self._original_cwd = os.getcwd()
85
+ self.store = AppStateStore(initial_state=AppState(model=model))
86
+ self.command_registry = create_default_registry()
87
+ self.tool_registry = ToolRegistry()
88
+ self.tool_registry.register_default_tools()
89
+ from iac_code.services.cloud_credentials import CloudCredentials
90
+ from iac_code.tools.cloud.registry import register_cloud_tools
91
+
92
+ register_cloud_tools(self.tool_registry, CloudCredentials())
93
+ self._current_model = model
94
+ from iac_code.config import load_active_provider_config
95
+
96
+ self._current_provider_config = load_active_provider_config()
97
+
98
+ # Backend: Provider + Session + Tasks + Memory
99
+ self._credentials = self._load_credentials()
100
+ self._provider_manager = ProviderManager(model=model, credentials=self._credentials)
101
+ self._session_storage = SessionStorage()
102
+ self.session_index = SessionIndex()
103
+ self._session_id = self._resolve_session_id(resume_session_id)
104
+ self._resume_messages = self._load_resume_messages(resume_session_id)
105
+ self._task_manager = TaskManager()
106
+ self._notification_queue = NotificationQueue()
107
+
108
+ memory_dir = str(get_config_dir() / "memory")
109
+ self._memory_manager = MemoryManager(memory_dir=memory_dir)
110
+
111
+ # Register new tools
112
+ from iac_code.agent.agent_tool import AgentTool
113
+ from iac_code.memory.memory_tools import ReadMemoryTool, WriteMemoryTool
114
+ from iac_code.tasks.task_tools import TaskGetTool, TaskListTool, TaskStopTool
115
+
116
+ memory_content = ""
117
+ if hasattr(self, "_memory_manager") and self._memory_manager:
118
+ memory_content = self._memory_manager.get_prompt_content()
119
+ self.tool_registry.register(
120
+ AgentTool(
121
+ task_manager=self._task_manager,
122
+ provider_manager=self._provider_manager,
123
+ tool_registry=self.tool_registry,
124
+ system_prompt=build_system_prompt(cwd=os.getcwd(), memory_content=memory_content),
125
+ notification_queue=self._notification_queue,
126
+ )
127
+ )
128
+ self.tool_registry.register(ReadMemoryTool(self._memory_manager))
129
+ self.tool_registry.register(WriteMemoryTool(self._memory_manager))
130
+ self.tool_registry.register(TaskListTool(self._task_manager))
131
+ self.tool_registry.register(TaskGetTool(self._task_manager))
132
+ self.tool_registry.register(TaskStopTool(self._task_manager))
133
+
134
+ # === Skill system initialization ===
135
+ from iac_code.skills.bundled import init_bundled_skills
136
+ from iac_code.skills.discovery import discover_all_skills, skill_to_command
137
+ from iac_code.skills.listing import build_skill_listing
138
+ from iac_code.skills.skill_tool import SkillTool
139
+
140
+ # 1. Initialize bundled skills (once)
141
+ init_bundled_skills()
142
+
143
+ # 2. Discover all skills and register to unified CommandRegistry
144
+ cwd = os.getcwd()
145
+ all_skills = discover_all_skills(cwd)
146
+ for skill in all_skills:
147
+ cmd = skill_to_command(skill)
148
+ existing = self.command_registry.get(cmd.name)
149
+ if existing is not None and not isinstance(existing, PromptCommand):
150
+ logger.warning(
151
+ "Skill '%s' (source=%s) skipped: conflicts with built-in command",
152
+ cmd.name,
153
+ cmd.source,
154
+ )
155
+ continue
156
+ self.command_registry.register(cmd)
157
+
158
+ # 3. Register SkillTool
159
+ self.tool_registry.register(
160
+ SkillTool(
161
+ command_registry=self.command_registry,
162
+ session_id=self._session_id,
163
+ cwd=cwd,
164
+ provider_manager=self._provider_manager,
165
+ tool_registry=self.tool_registry,
166
+ system_prompt=build_system_prompt(cwd=cwd, memory_content=memory_content),
167
+ )
168
+ )
169
+
170
+ # 4. Generate skill listing for system prompt
171
+ skill_commands = self.command_registry.get_model_invocable_skills()
172
+ self._skill_listing = build_skill_listing(skill_commands)
173
+
174
+ self._agent_loop = AgentLoop(
175
+ provider_manager=self._provider_manager,
176
+ system_prompt=build_system_prompt(
177
+ cwd=cwd, memory_content=memory_content, skill_listing=self._skill_listing
178
+ ),
179
+ tool_registry=self.tool_registry,
180
+ session_storage=self._session_storage,
181
+ session_id=self._session_id,
182
+ resume_messages=self._resume_messages or None,
183
+ cwd=self._original_cwd,
184
+ )
185
+ self.renderer = Renderer(
186
+ self.console,
187
+ self.tool_registry,
188
+ status_callback=self._status_text,
189
+ app_state_store=self.store,
190
+ )
191
+
192
+ # Keybinding manager
193
+ self._keybinding_manager = KeybindingManager()
194
+
195
+ # Input history
196
+ self._history = InputHistory(str(get_history_path()))
197
+
198
+ # Suggestion aggregator with all 4 providers
199
+ cwd = os.getcwd()
200
+ self._suggestion_aggregator = SuggestionAggregator(
201
+ [
202
+ CommandProvider(self.command_registry),
203
+ FileProvider(cwd),
204
+ DirectoryProvider(cwd),
205
+ ShellHistoryProvider(),
206
+ ]
207
+ )
208
+
209
+ # PromptInput
210
+ self._prompt_input = PromptInput(
211
+ keybinding_manager=self._keybinding_manager,
212
+ suggestion_aggregator=self._suggestion_aggregator,
213
+ history=self._history,
214
+ console=self.console,
215
+ )
216
+
217
+ self.store.subscribe(self._on_state_change)
218
+
219
+ # ------------------------------------------------------------------
220
+ # Public entry-point
221
+ # ------------------------------------------------------------------
222
+
223
+ async def run(self, initial_prompt: str | None = None) -> None:
224
+ """Run the REPL until the user exits.
225
+
226
+ Args:
227
+ initial_prompt: If provided, automatically process this as the first
228
+ user input (e.g. from piped stdin).
229
+ """
230
+ # Capture session start time for duration calculation
231
+ self._started_monotonic = time.monotonic()
232
+
233
+ state = self.store.get_state()
234
+ self.console.print(render_welcome_banner(state.model, state.cwd, session_id=self._session_id))
235
+ if self._resume_messages:
236
+ self.renderer.replay_history(self._resume_messages)
237
+ self.console.print() # blank line before first new user turn
238
+ start_background_housekeeping()
239
+ self._register_global_keybindings()
240
+
241
+ # Clear IEXTEN for the whole session so macOS/BSD can't latch Ctrl+O
242
+ # onto VDISCARD. VDISCARD toggles tty-wide output discard on a single
243
+ # keystroke, so an ill-timed Ctrl+O between our raw-input contexts
244
+ # (cooked gap) would silently swallow every subsequent render until
245
+ # pressed again — looking exactly like the "stuck after multiple
246
+ # ctrl+o" symptom. Disabling IEXTEN disables VDISCARD entirely;
247
+ # RawInputCapture's setraw() preserves c_cc across enter/exit.
248
+ saved_termios = None
249
+ if termios is not None:
250
+ try:
251
+ fd = sys.stdin.fileno()
252
+ saved_termios = termios.tcgetattr(fd)
253
+ mode = termios.tcgetattr(fd)
254
+ mode[3] = mode[3] & ~termios.IEXTEN
255
+ termios.tcsetattr(fd, termios.TCSANOW, mode)
256
+ except (termios.error, OSError, ValueError):
257
+ saved_termios = None
258
+
259
+ # Install a custom SIGINT handler that replaces asyncio's default.
260
+ # asyncio's default handler tracks a global _interrupt_count that is
261
+ # never reset — after one Ctrl+C, subsequent presses raise
262
+ # KeyboardInterrupt instead of cancelling the task. Our handler
263
+ # always cancels the main task, allowing the REPL to recover via
264
+ # uncancel() and continue.
265
+ loop = asyncio.get_event_loop()
266
+ main_task = asyncio.current_task()
267
+
268
+ def _on_sigint() -> None:
269
+ if main_task and not main_task.done():
270
+ main_task.cancel()
271
+
272
+ _has_sigint_handler = False
273
+ try:
274
+ loop.add_signal_handler(signal.SIGINT, _on_sigint)
275
+ _has_sigint_handler = True
276
+ except (NotImplementedError, OSError):
277
+ pass # Windows or restricted environment
278
+
279
+ first_turn = True
280
+ last_ctrl_c_time: float = 0.0
281
+
282
+ try:
283
+ while True:
284
+ try:
285
+ # Check for background agent notifications
286
+ while self._notification_queue.has_pending():
287
+ notification = self._notification_queue.dequeue()
288
+ if notification:
289
+ self.renderer.print_system_message(
290
+ f"Agent '{notification.task_id}' completed: {notification.message}"
291
+ )
292
+
293
+ # Blank line between turns
294
+ if not first_turn:
295
+ self.console.print()
296
+ first_turn = False
297
+
298
+ # Use initial_prompt for the first turn if provided
299
+ if initial_prompt is not None:
300
+ user_input = initial_prompt
301
+ initial_prompt = None
302
+ self.console.print(f"[bold cyan]❯[/bold cyan] {user_input}")
303
+ else:
304
+ user_input = await self._prompt_input.get_input()
305
+ if user_input is None: # Ctrl+C with empty input
306
+ now = time.monotonic()
307
+ if now - last_ctrl_c_time < 1.5:
308
+ # Double Ctrl+C within 1.5s → exit
309
+ break
310
+ last_ctrl_c_time = now
311
+ self.console.print(f"[dim]{_('Press Ctrl+C again to exit.')}[/dim]")
312
+ continue
313
+ last_ctrl_c_time = 0.0 # Reset on valid input
314
+ user_input = user_input.strip()
315
+ if not user_input:
316
+ continue
317
+ self._history.append(user_input)
318
+
319
+ if self.command_registry.is_command(user_input):
320
+ await self._handle_command(user_input)
321
+ self._clear_cancel_state()
322
+ continue
323
+ await self._handle_chat(user_input)
324
+ self._clear_cancel_state()
325
+ except (KeyboardInterrupt, asyncio.CancelledError):
326
+ self._clear_cancel_state()
327
+ self.console.print(f"\n[dim]{_('Interrupted.')}[/dim]")
328
+ continue
329
+ except ExitREPLError:
330
+ break
331
+ except EOFError:
332
+ break
333
+ except OSError:
334
+ # Terminal fd became invalid (e.g. after double Ctrl+C during response)
335
+ break
336
+ finally:
337
+ # Persist a tail-readable last-prompt entry so the /resume picker
338
+ # can show what the user was last doing without parsing the whole
339
+ # JSONL. Best-effort — failures must not block shutdown.
340
+ self._write_last_prompt_meta()
341
+ # Emit session exit event and gracefully shutdown telemetry
342
+ from iac_code.services.telemetry import graceful_shutdown, log_event
343
+ from iac_code.services.telemetry.names import Events
344
+
345
+ log_event(
346
+ Events.SESSION_EXITED,
347
+ {
348
+ "reason": "normal",
349
+ "duration_s": int(time.monotonic() - self._started_monotonic),
350
+ },
351
+ )
352
+ graceful_shutdown()
353
+
354
+ if _has_sigint_handler:
355
+ loop.remove_signal_handler(signal.SIGINT)
356
+ if saved_termios is not None and termios is not None:
357
+ try:
358
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, saved_termios)
359
+ except (termios.error, OSError, ValueError):
360
+ pass
361
+
362
+ from rich.text import Text
363
+
364
+ self.console.print(f"[dim]{_('Goodbye!')}[/dim]")
365
+ self.console.print(Text(_("Resume this session with:"), style="dim"))
366
+ self.console.print(Text(f"iac-code --resume {self._session_id}", style="dim"))
367
+
368
+ async def run_once(self, prompt: str) -> None:
369
+ """Process a single prompt and exit (non-interactive mode)."""
370
+ if self.command_registry.is_command(prompt):
371
+ await self._handle_command(prompt)
372
+ else:
373
+ await self._handle_chat(prompt)
374
+
375
+ # ------------------------------------------------------------------
376
+ # Keybinding registration
377
+ # ------------------------------------------------------------------
378
+
379
+ def _register_global_keybindings(self) -> None:
380
+ km = self._keybinding_manager
381
+ km.push_context("global")
382
+ km.register(KeyBinding("ctrl+r", "open_history_search", "global", self._open_history_search))
383
+ km.register(KeyBinding("ctrl+p", "open_quick_open", "global", self._open_quick_open))
384
+ km.register(KeyBinding("ctrl+f", "open_global_search", "global", self._open_global_search))
385
+ km.register(KeyBinding("ctrl+o", "expand_last_turn", "global", self._expand_last_turn))
386
+
387
+ # ------------------------------------------------------------------
388
+ # Dialog launchers
389
+ # ------------------------------------------------------------------
390
+
391
+ def _open_history_search(self) -> bool:
392
+ from iac_code.ui.dialogs.history_search import HistorySearch
393
+
394
+ messages = self.store.get_state().messages
395
+ dialog = HistorySearch(
396
+ messages=messages,
397
+ on_select=self._insert_text,
398
+ on_cancel=lambda: None,
399
+ keybinding_manager=self._keybinding_manager,
400
+ )
401
+ dialog.run()
402
+ return True
403
+
404
+ def _open_quick_open(self) -> bool:
405
+ from iac_code.ui.dialogs.quick_open import QuickOpen
406
+
407
+ dialog = QuickOpen(
408
+ root_dir=os.getcwd(),
409
+ on_select=self._insert_text,
410
+ on_cancel=lambda: None,
411
+ keybinding_manager=self._keybinding_manager,
412
+ )
413
+ dialog.run()
414
+ return True
415
+
416
+ def _open_global_search(self) -> bool:
417
+ from iac_code.ui.dialogs.global_search import GlobalSearch
418
+
419
+ dialog = GlobalSearch(
420
+ root_dir=os.getcwd(),
421
+ on_select=self._insert_text,
422
+ on_cancel=lambda: None,
423
+ keybinding_manager=self._keybinding_manager,
424
+ )
425
+ dialog.run()
426
+ return True
427
+
428
+ def _insert_text(self, text: str) -> None:
429
+ """Insert text into the prompt input buffer (future enhancement)."""
430
+ pass # Will be enhanced when PromptInput supports external text insertion
431
+
432
+ def _expand_last_turn(self) -> bool:
433
+ """Keybinding handler: open the verbose transcript view."""
434
+ self._prompt_input.schedule_action(self.renderer.show_transcript)
435
+ return True
436
+
437
+ # ------------------------------------------------------------------
438
+ # Command handling
439
+ # ------------------------------------------------------------------
440
+
441
+ async def _handle_command(self, user_input: str) -> None:
442
+ """Dispatch a slash command and print the result."""
443
+ name, args = self.command_registry.parse(user_input)
444
+ cmd = self.command_registry.get(name)
445
+ if cmd is None:
446
+ self.renderer.print_system_message(
447
+ _("Unknown command: /{name}. Type /help for available commands.").format(name=name),
448
+ style="red",
449
+ )
450
+ return
451
+
452
+ if isinstance(cmd, PromptCommand):
453
+ # Skill command: process via unified path
454
+ from iac_code.skills.processor import process_prompt_command
455
+
456
+ args_str = " ".join(args) if args else ""
457
+ try:
458
+ result = await process_prompt_command(cmd, args_str)
459
+ if result.is_fork:
460
+ await self._handle_chat(result.prompt_content)
461
+ else:
462
+ # Inline mode: inject messages and continue agent loop
463
+ for msg in result.new_messages:
464
+ self._agent_loop.context_manager.add_raw_message(msg)
465
+ if result.context_modifier:
466
+ self._agent_loop._apply_context_modifier(result.context_modifier)
467
+ # Stream the agent's response to the injected skill prompt
468
+ await self._handle_chat_continue()
469
+ except Exception as exc:
470
+ self.renderer.print_system_message(
471
+ _("Command error: {error}").format(error=exc),
472
+ style="red",
473
+ )
474
+ elif isinstance(cmd, LocalCommand):
475
+ context = CommandContext(console=self.console, store=self.store, repl=self)
476
+ if cmd.handler is None:
477
+ self.renderer.print_system_message(
478
+ _("Command has no handler: {name}").format(name=cmd.name),
479
+ style="red",
480
+ )
481
+ return
482
+ try:
483
+ handler_call = cmd.handler(
484
+ context=context,
485
+ args=args,
486
+ registry=self.command_registry,
487
+ store=self.store,
488
+ )
489
+ if cmd.progress_label:
490
+ self.store.set_state(is_busy=True)
491
+ try:
492
+ result = await self.renderer.run_with_spinner(handler_call, cmd.progress_label)
493
+ finally:
494
+ self.store.set_state(is_busy=False)
495
+ else:
496
+ result = await handler_call
497
+ if result:
498
+ self.renderer.print_command_result(user_input, result)
499
+ except ExitREPLError:
500
+ raise
501
+ except Exception as exc:
502
+ self.renderer.print_system_message(
503
+ _("Command error: {error}").format(error=exc),
504
+ style="red",
505
+ )
506
+
507
+ # ------------------------------------------------------------------
508
+ # Chat handling
509
+ # ------------------------------------------------------------------
510
+
511
+ async def _handle_chat_continue(self) -> None:
512
+ """Continue the agent loop after injecting messages (e.g., skill prompt).
513
+
514
+ Unlike _handle_chat, this doesn't add a new user message — the messages
515
+ were already injected into the context.
516
+ """
517
+ self.store.set_state(is_busy=True)
518
+ try:
519
+ events = self._agent_loop.run_streaming("")
520
+ elapsed = await self.renderer.run_streaming_output(
521
+ events,
522
+ permission_handler=self.renderer.prompt_permission,
523
+ )
524
+ if elapsed >= 1.0:
525
+ self._agent_loop.stamp_last_turn_elapsed(elapsed)
526
+ finally:
527
+ self.store.set_state(is_busy=False)
528
+
529
+ async def _handle_chat(self, user_input: str) -> None:
530
+ """Send the user message to the agent loop and stream output."""
531
+ self.store.set_state(is_busy=True)
532
+ self.renderer.record_user_turn(user_input)
533
+ try:
534
+ events = self._agent_loop.run_streaming(user_input)
535
+ elapsed = await self.renderer.run_streaming_output(
536
+ events,
537
+ permission_handler=self.renderer.prompt_permission,
538
+ )
539
+ if elapsed >= 1.0:
540
+ self._agent_loop.stamp_last_turn_elapsed(elapsed)
541
+ finally:
542
+ self.store.set_state(is_busy=False)
543
+
544
+ @staticmethod
545
+ def _clear_cancel_state() -> None:
546
+ """Reset residual cancellation state on the current task.
547
+
548
+ When the renderer internally catches CancelledError (e.g. from
549
+ Ctrl+C during streaming), the task's ``_num_cancels_requested``
550
+ counter stays positive even though the error was handled. This
551
+ can interfere with subsequent ``await`` calls. Calling
552
+ ``uncancel()`` drains the counter back to zero.
553
+ """
554
+ task = asyncio.current_task()
555
+ if task:
556
+ while task.cancelling():
557
+ task.uncancel()
558
+
559
+ # ------------------------------------------------------------------
560
+ # State change callback
561
+ # ------------------------------------------------------------------
562
+
563
+ def _on_state_change(self, state: AppState) -> None:
564
+ """React to state changes — reinitialize provider when any provider config changes."""
565
+ from iac_code.config import load_active_provider_config
566
+
567
+ current_config = load_active_provider_config()
568
+ if state.model != self._current_model or current_config != self._current_provider_config:
569
+ self._reinitialize_provider(state.model)
570
+
571
+ def _reinitialize_provider(self, new_model: str) -> None:
572
+ """Apply a provider/model switch in place.
573
+
574
+ Mutates the single shared ProviderManager so AgentTool / SkillTool
575
+ — which captured this manager at registration — pick up the change
576
+ without re-registration. Then notifies the AgentLoop so its
577
+ ContextManager refreshes the tokenizer/context-window config and
578
+ the system prompt for any memory/skill updates. Recreating the
579
+ loop would discard conversation history.
580
+ """
581
+ from iac_code.config import load_active_provider_config
582
+
583
+ self._current_model = new_model
584
+ self._current_provider_config = load_active_provider_config()
585
+ self._credentials = self._load_credentials()
586
+ self._provider_manager.reconfigure(new_model, self._credentials)
587
+ memory_content = ""
588
+ if hasattr(self, "_memory_manager") and self._memory_manager:
589
+ memory_content = self._memory_manager.get_prompt_content()
590
+ skill_listing = getattr(self, "_skill_listing", "")
591
+ new_system_prompt = build_system_prompt(
592
+ cwd=os.getcwd(), memory_content=memory_content, skill_listing=skill_listing
593
+ )
594
+ self._agent_loop.set_provider(self._provider_manager, system_prompt=new_system_prompt)
595
+
596
+ # ------------------------------------------------------------------
597
+ # Helpers
598
+ # ------------------------------------------------------------------
599
+
600
+ @staticmethod
601
+ def _load_credentials() -> dict[str, str]:
602
+ """Load API credentials (delegates to config.load_credentials with env overlay)."""
603
+ return load_credentials()
604
+
605
+ def _resolve_session_id(self, resume: str | bool | None) -> str:
606
+ """Resolve session ID for resume or create new.
607
+
608
+ For ``--continue`` and ``--resume <id>``, sessions belonging to a
609
+ *different* working directory are rejected with a helpful error
610
+ instructing the user to cd into the right project first — matches
611
+ our project-partitioned storage layout.
612
+ """
613
+ import uuid
614
+
615
+ if resume is True:
616
+ latest = self._session_storage.get_latest_session_anywhere()
617
+ if latest is None:
618
+ return str(uuid.uuid4())
619
+ cwd, sid = latest
620
+ if cwd and cwd != self._original_cwd:
621
+ raise ValueError(self._cross_project_message(cwd, sid))
622
+ return sid
623
+ elif isinstance(resume, str) and resume:
624
+ if self._session_storage.exists(self._original_cwd, resume):
625
+ return resume
626
+ located = self._session_storage.find_session_anywhere(resume)
627
+ if located is None:
628
+ raise ValueError(_("Session not found: {session_id}").format(session_id=resume))
629
+ cwd, _path = located
630
+ if cwd and cwd != self._original_cwd:
631
+ raise ValueError(self._cross_project_message(cwd, resume))
632
+ return resume
633
+ return str(uuid.uuid4())
634
+
635
+ def _load_resume_messages(self, resume: str | bool | None) -> list:
636
+ """Load and repair saved messages when resuming a session."""
637
+ if resume is None:
638
+ return []
639
+ messages = self._session_storage.load(self._original_cwd, self._session_id)
640
+ return self._session_storage.repair_interrupted(messages)
641
+
642
+ @staticmethod
643
+ def _cross_project_message(cwd: str, session_id: str) -> str:
644
+ import shlex
645
+
646
+ cmd = f"cd {shlex.quote(cwd)} && iac-code --resume {session_id}"
647
+ return _("This session belongs to a different directory.\nTo resume, run:\n {cmd}").format(cmd=cmd)
648
+
649
+ @property
650
+ def session_id(self) -> str:
651
+ return self._session_id
652
+
653
+ # ------------------------------------------------------------------
654
+ # Session swap (used by /resume command)
655
+ # ------------------------------------------------------------------
656
+
657
+ def swap_session(self, new_session_id: str) -> None:
658
+ """Replace the active session in-place (same project only)."""
659
+ new_messages = self._session_storage.load(self._original_cwd, new_session_id)
660
+ new_messages = self._session_storage.repair_interrupted(new_messages)
661
+ self._agent_loop.replace_session(new_session_id, new_messages or None)
662
+ self._session_id = new_session_id
663
+
664
+ # Clear screen + scrollback, redraw banner, replay history.
665
+ self.console.file.write("\033[H\033[2J\033[3J")
666
+ self.console.file.flush()
667
+ state = self.store.get_state()
668
+ self.console.print(render_welcome_banner(state.model, state.cwd, session_id=new_session_id))
669
+ if new_messages:
670
+ self.renderer.replay_history(new_messages)
671
+ self.console.print()
672
+
673
+ async def swap_or_announce_session(self, entry) -> None:
674
+ """Hot-swap if same project; otherwise print the resume command."""
675
+ if entry.cwd and entry.cwd == self._original_cwd:
676
+ self.swap_session(entry.session_id)
677
+ return
678
+ await self._announce_cross_project(entry)
679
+
680
+ async def _announce_cross_project(self, entry) -> None:
681
+ import shlex
682
+
683
+ cmd = f"cd {shlex.quote(entry.cwd)} && iac-code --resume {entry.session_id}"
684
+ msg_lines = [
685
+ "",
686
+ _("This conversation is from a different directory."),
687
+ "",
688
+ _("To resume, run:"),
689
+ f" {cmd}",
690
+ ]
691
+ if self._copy_to_clipboard(cmd):
692
+ msg_lines.append("")
693
+ msg_lines.append(_("(Command copied to clipboard)"))
694
+ self.renderer.print_system_message("\n".join(msg_lines))
695
+
696
+ @staticmethod
697
+ def _copy_to_clipboard(text: str) -> bool:
698
+ """Best-effort clipboard copy. Returns True on success."""
699
+ import subprocess
700
+
701
+ candidates: list[list[str]] = []
702
+ if sys.platform == "darwin":
703
+ candidates.append(["pbcopy"])
704
+ elif sys.platform.startswith("linux"):
705
+ candidates.append(["wl-copy"])
706
+ candidates.append(["xclip", "-selection", "clipboard"])
707
+ elif sys.platform.startswith("win"):
708
+ candidates.append(["clip"])
709
+ for cmd in candidates:
710
+ try:
711
+ proc = subprocess.run(cmd, input=text, text=True, timeout=2.0, check=False)
712
+ if proc.returncode == 0:
713
+ return True
714
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
715
+ continue
716
+ return False
717
+
718
+ # ------------------------------------------------------------------
719
+ # last-prompt persistence
720
+ # ------------------------------------------------------------------
721
+
722
+ def _write_last_prompt_meta(self) -> None:
723
+ """Append a ``last-prompt`` lite-meta row to the session file.
724
+
725
+ Reads back from the in-memory context manager rather than the file
726
+ so we don't double-parse. Silently no-ops if there's no usable
727
+ text or the write fails.
728
+ """
729
+ try:
730
+ messages = self._agent_loop.context_manager.get_messages()
731
+ except Exception:
732
+ return
733
+ text = self._extract_last_user_text(messages)
734
+ if not text:
735
+ return
736
+ flat = text.replace("\n", " ").strip()
737
+ if len(flat) > 200:
738
+ flat = flat[:200].rstrip() + "…"
739
+ try:
740
+ self._session_storage.append_meta(
741
+ self._original_cwd,
742
+ self._session_id,
743
+ {"type": "last-prompt", "last_prompt": flat},
744
+ )
745
+ except Exception:
746
+ pass
747
+
748
+ @staticmethod
749
+ def _extract_last_user_text(messages: list) -> str:
750
+ """Walk messages from newest to oldest, return first plain user text."""
751
+ from iac_code.agent.message import TextBlock
752
+
753
+ for msg in reversed(messages):
754
+ if msg.role != "user":
755
+ continue
756
+ content = msg.content
757
+ if isinstance(content, str):
758
+ if content.strip():
759
+ return content
760
+ continue
761
+ if isinstance(content, list):
762
+ texts = [block.text for block in content if isinstance(block, TextBlock) and block.text]
763
+ if texts:
764
+ return " ".join(texts)
765
+ return ""
766
+
767
+ # ------------------------------------------------------------------
768
+ # Renderer callback
769
+ # ------------------------------------------------------------------
770
+
771
+ def _status_text(self) -> str:
772
+ return self.store.get_state().model