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,1535 @@
1
+ """Rich-based rendering engine.
2
+
3
+ Consumes StreamEvent from AgentLoop and renders via Rich Console + Live.
4
+ During a streaming turn, all output (text, tool calls, tool results) is
5
+ buffered and rendered in a single Live context. Pressing Ctrl+O toggles
6
+ between compact (default) and verbose (expanded) views — the entire turn
7
+ is re-rendered on toggle.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import copy
14
+ import os
15
+ import sys
16
+ import termios
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable
20
+
21
+ from rich._loop import loop_first
22
+ from rich.console import Console, ConsoleOptions, Group, RenderResult
23
+ from rich.live import Live
24
+ from rich.markdown import ListItem, Markdown
25
+ from rich.rule import Rule
26
+ from rich.segment import Segment
27
+ from rich.table import Table
28
+ from rich.text import Text
29
+
30
+ from iac_code.i18n import _
31
+ from iac_code.services.telemetry import add_metric, log_event
32
+ from iac_code.services.telemetry.names import Events, Metrics
33
+ from iac_code.tools.cloud.types import translate_status
34
+ from iac_code.types.stream_events import (
35
+ CompactionEvent,
36
+ ErrorEvent,
37
+ MessageEndEvent,
38
+ MessageStartEvent,
39
+ PermissionRequestEvent,
40
+ StackInstancesProgressEvent,
41
+ StackProgressEvent,
42
+ StreamEvent,
43
+ SubAgentToolEvent,
44
+ TaskNotificationEvent,
45
+ TextDeltaEvent,
46
+ ThinkingDeltaEvent,
47
+ TombstoneEvent,
48
+ ToolInputDeltaEvent,
49
+ ToolResultEvent,
50
+ ToolUseEndEvent,
51
+ ToolUseStartEvent,
52
+ )
53
+ from iac_code.ui.components.select import OptionType, Select, SelectLayout, TextOption
54
+ from iac_code.ui.spinner import ShimmerSpinner
55
+
56
+ if TYPE_CHECKING:
57
+ from iac_code.state.app_state import AppStateStore
58
+ from iac_code.tools.base import ToolRegistry
59
+
60
+
61
+ class _DashListItem(ListItem):
62
+ """ListItem that uses ``-`` instead of ``•`` for unordered bullets."""
63
+
64
+ def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
65
+ render_options = options.update(width=options.max_width - 3)
66
+ lines = console.render_lines(self.elements, render_options, style=self.style)
67
+ bullet_style = console.get_style("markdown.item.bullet", default="none")
68
+ bullet = Segment(" - ", bullet_style)
69
+ padding = Segment(" " * 3, bullet_style)
70
+ new_line = Segment("\n")
71
+ for first, line in loop_first(lines):
72
+ yield bullet if first else padding
73
+ yield from line
74
+ yield new_line
75
+
76
+
77
+ class _DashMarkdown(Markdown):
78
+ """Markdown subclass that renders unordered list bullets as ``-``."""
79
+
80
+ elements = {**Markdown.elements, "list_item_open": _DashListItem}
81
+
82
+
83
+ class _CropTop:
84
+ """Rich renderable that crops content from the **top** to fit *max_height*.
85
+
86
+ The inner renderable is fully rendered first (preserving all styling such
87
+ as code-block highlighting), then only the bottom *max_height* lines are
88
+ emitted. This prevents Rich ``Live`` from pushing overflow into the
89
+ terminal scrollback buffer — the root cause of duplicate-content bugs.
90
+ """
91
+
92
+ def __init__(self, inner, max_height: int) -> None:
93
+ self._inner = inner
94
+ self._max_height = max_height
95
+
96
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
97
+ render_options = options.update(height=None)
98
+ lines = console.render_lines(self._inner, render_options, pad=False)
99
+ if len(lines) > self._max_height:
100
+ lines = lines[-self._max_height :]
101
+ new_line = Segment("\n")
102
+ for line in lines:
103
+ yield from line
104
+ yield new_line
105
+
106
+
107
+ # ── Turn-buffer data structures ─────────────────────────────────────
108
+
109
+
110
+ @dataclass
111
+ class _SubAgentChild:
112
+ """A child tool call made by a sub-agent."""
113
+
114
+ tool_name: str
115
+ tool_input: dict
116
+ is_done: bool = False
117
+ is_error: bool = False
118
+
119
+
120
+ @dataclass
121
+ class _ToolCallRecord:
122
+ """One tool invocation (use + optional result)."""
123
+
124
+ tool_name: str
125
+ tool_input: dict
126
+ partial_input: str = ""
127
+ result: str | None = None
128
+ is_error: bool = False
129
+ done: bool = False
130
+ children: list[_SubAgentChild] | None = None
131
+ start_time: float = 0.0
132
+ progress_renderable: Any = None # For stack progress display
133
+
134
+
135
+ @dataclass
136
+ class _Segment:
137
+ """One segment of turn output — markdown text, a tool call, or a
138
+ collapsed thinking-summary line."""
139
+
140
+ kind: str # "text" | "tool" | "thinking_summary"
141
+ text: str = ""
142
+ tool: _ToolCallRecord | None = None
143
+ elapsed_seconds: float = 0.0 # for thinking_summary only
144
+
145
+
146
+ @dataclass
147
+ class RenderedTurn:
148
+ """One complete turn of rendered content."""
149
+
150
+ role: str # "user" | "assistant"
151
+ segments: list[_Segment] = field(default_factory=list)
152
+ timestamp: float = 0.0
153
+ text: str = "" # For user turns, the raw input text
154
+
155
+
156
+ class Renderer:
157
+ """Bridge between stream events and terminal output."""
158
+
159
+ def __init__(
160
+ self,
161
+ console: Console,
162
+ tool_registry: "ToolRegistry",
163
+ status_callback: Callable[[], str] | None = None,
164
+ app_state_store: "AppStateStore | None" = None,
165
+ ) -> None:
166
+ self.console = console
167
+ self._tool_registry = tool_registry
168
+ self._status_callback = status_callback
169
+ self._verbose = False
170
+ self._text_flushed = False # tracks whether current text block was partially flushed
171
+ self._message_history: list[RenderedTurn] = []
172
+ # Set by _key_listener after the transcript view closes mid-stream so
173
+ # the main event loop discards the stale Live/refresh_task and
174
+ # rebuilds them before rendering the next event.
175
+ self._stream_invalidated = False
176
+ # Optional AppStateStore so permission prompts can consult/update the
177
+ # shared LRU cache at AppState.always_allow_rules. None in pure-unit
178
+ # contexts where no session state is wired up.
179
+ self._app_state_store = app_state_store
180
+
181
+ # ── Footer (shown inside Live during streaming) ─────────────────
182
+
183
+ def _build_footer(self) -> Group:
184
+ """Build the persistent footer: separator + disabled input + status."""
185
+ status_text = self._status_callback() if self._status_callback else ""
186
+ status = Text(status_text, style="dim", justify="right")
187
+ return Group(
188
+ Rule(style="dim"),
189
+ Text("❯ ", style="dim"),
190
+ status,
191
+ )
192
+
193
+ def _with_footer(self, content) -> Group:
194
+ """Wrap content with the persistent footer below it."""
195
+ return Group(content, self._build_footer())
196
+
197
+ # ── Static output (goes to scrollback) ──────────────────────────
198
+
199
+ def print_user_message(self, text: str) -> None:
200
+ t = Text()
201
+ t.append("❯ ", style="bold cyan")
202
+ t.append(text)
203
+ self.console.print(t)
204
+
205
+ def print_command_result(self, command: str, result: str) -> None:
206
+ t = Text()
207
+ t.append(" └ ", style="dim")
208
+ t.append(result)
209
+ self.console.print(t)
210
+
211
+ def print_system_message(self, text: str, style: str = "yellow") -> None:
212
+ self.console.print(Text(text, style=style))
213
+
214
+ async def run_with_spinner(self, awaitable: Awaitable[Any], label: str) -> Any:
215
+ """Show a transient spinner with ``label`` while ``awaitable`` runs.
216
+
217
+ Used by slow local commands (e.g. /compact) so the UI doesn't look
218
+ frozen during long async work. Returns the awaitable's result; any
219
+ exception raised by the awaitable propagates after the spinner is
220
+ torn down.
221
+ """
222
+ spinner = ShimmerSpinner(status=f"{label}...")
223
+ live = Live(
224
+ self._with_footer(spinner.render()),
225
+ console=self.console,
226
+ refresh_per_second=20,
227
+ transient=True,
228
+ vertical_overflow="visible",
229
+ )
230
+
231
+ async def _refresh() -> None:
232
+ try:
233
+ while True:
234
+ await asyncio.sleep(0.05)
235
+ live.update(self._with_footer(spinner.render()))
236
+ except asyncio.CancelledError:
237
+ pass
238
+
239
+ live.start()
240
+ refresh_task = asyncio.create_task(_refresh())
241
+ try:
242
+ return await awaitable
243
+ finally:
244
+ await self._stop_refresh(refresh_task)
245
+ self._quiet_stop_live(live)
246
+
247
+ def record_user_turn(self, text: str) -> None:
248
+ """Record a user turn into message history."""
249
+ self._message_history.append(RenderedTurn(role="user", text=text, timestamp=time.monotonic()))
250
+
251
+ @property
252
+ def message_history(self) -> list[RenderedTurn]:
253
+ """All rendered turns in the conversation."""
254
+ return self._message_history
255
+
256
+ def _quiet_stop_live(self, target_live: Live | None) -> None:
257
+ """Stop a Rich Live without scrolling its last render into scrollback.
258
+
259
+ Rich's Live.stop() with ``transient=True`` runs this sequence:
260
+ 1. final refresh — renders content in place
261
+ 2. ``self.console.line()`` — writes a ``\\n``
262
+ 3. ``restore_cursor`` — CR + (UP + ERASE_LINE) × height
263
+
264
+ Step 2 is the bug for us: when Live sits on the terminal's last row
265
+ (which is always — our Live is pinned just above the input footer),
266
+ that ``\\n`` scrolls the top row of the Live out of the viewport
267
+ before step 3 can erase it. The evicted row is now in scrollback
268
+ forever. Every stop leaks exactly one row — the header of whatever
269
+ Live was showing, e.g. ``● 探索(...)`` — and they stack on repeat.
270
+
271
+ We do the essential teardown ourselves and skip the ``line()``
272
+ scroll altogether: stop the refresh thread, acquire Live's lock so
273
+ we don't race a concurrent auto-refresh, erase the rendered area
274
+ with plain ANSI (position-cursor pattern — works from the end of
275
+ the render), pop the render hook and restore stdio.
276
+ """
277
+ if target_live is None or not getattr(target_live, "_started", False):
278
+ return
279
+ thread = getattr(target_live, "_refresh_thread", None)
280
+ if thread is not None:
281
+ try:
282
+ thread.stop()
283
+ thread.join(timeout=0.2)
284
+ except Exception:
285
+ pass
286
+ target_live._refresh_thread = None
287
+
288
+ lock = getattr(target_live, "_lock", None)
289
+ acquired = False
290
+ if lock is not None:
291
+ try:
292
+ lock.acquire()
293
+ acquired = True
294
+ except Exception:
295
+ pass
296
+ try:
297
+ render = getattr(target_live, "_live_render", None)
298
+ if render is not None:
299
+ shape = getattr(render, "_shape", None)
300
+ if shape is not None:
301
+ _, height = shape
302
+ if height > 0:
303
+ out = target_live.console.file
304
+ try:
305
+ # Cursor is at the end of the last rendered line.
306
+ # ``CR + ERASE`` clears that line, then each
307
+ # ``UP + ERASE`` walks up one row and clears it.
308
+ # Emits zero newlines, so no scroll, no leak.
309
+ out.write("\r\x1b[2K")
310
+ for _ in range(height - 1):
311
+ out.write("\x1b[A\x1b[2K")
312
+ out.flush()
313
+ except Exception:
314
+ pass
315
+ render._shape = None
316
+ target_live._started = False
317
+ finally:
318
+ if acquired and lock is not None:
319
+ try:
320
+ lock.release()
321
+ except Exception:
322
+ pass
323
+
324
+ for cleanup in (
325
+ lambda: target_live._disable_redirect_io(),
326
+ lambda: target_live.console.pop_render_hook(),
327
+ lambda: target_live.console.clear_live(),
328
+ lambda: target_live.console.show_cursor(True),
329
+ ):
330
+ try:
331
+ cleanup()
332
+ except Exception:
333
+ pass
334
+
335
+ def _render_turn_segments(self, segments: list[_Segment]) -> None:
336
+ """Re-render all segments of a turn to console (used by expand toggle)."""
337
+ has_content = False
338
+ text_flushed = False
339
+ for seg in segments:
340
+ if seg.kind == "text" and seg.text:
341
+ if has_content:
342
+ self.console.print()
343
+ for part in self._render_text_block(seg.text, continuation=text_flushed):
344
+ self.console.print(part)
345
+ text_flushed = True
346
+ has_content = True
347
+ elif seg.kind == "thinking_summary":
348
+ if has_content:
349
+ self.console.print()
350
+ label = _("Thought for {seconds:.1f}s").format(seconds=seg.elapsed_seconds)
351
+ self.console.print(Text(f"▌ {label}", style="dim"))
352
+ has_content = True
353
+ text_flushed = False
354
+ elif seg.kind == "tool" and seg.tool:
355
+ if has_content:
356
+ self.console.print()
357
+ self.console.print(self._render_tool_header(seg.tool))
358
+ result_line = self._render_tool_result(seg.tool)
359
+ if result_line:
360
+ self.console.print(result_line)
361
+ has_content = True
362
+ text_flushed = False
363
+ # Show expand hint only when at least one tool actually has richer
364
+ # verbose content to reveal.
365
+ if not self._verbose and self._any_segment_has_verbose(segments):
366
+ self.console.print(Text(" " + _("(ctrl+o to expand)"), style="dim"))
367
+
368
+ def show_transcript(self, current_segments: "list[_Segment] | None" = None) -> None:
369
+ """Open the transcript view in the alternate screen.
370
+
371
+ ``current_segments`` are the live, un-archived segments of the in-
372
+ progress assistant turn (if any); passing them lets the view show a
373
+ running agent's child-tool list before it has been flushed to
374
+ ``_message_history``.
375
+ """
376
+ from iac_code.ui.transcript_view import TranscriptView
377
+
378
+ TranscriptView(self, current_segments=current_segments).run()
379
+
380
+ # ── Render helpers ──────────────────────────────────────────────
381
+
382
+ def _render_stack_progress(self, event: StackProgressEvent) -> Group:
383
+ """Render stack progress as a Rich Group (title + table)."""
384
+ stack_status_display = translate_status(event.status)
385
+ title = Text(
386
+ f"Stack: {event.stack_name}({event.stack_id}) [{stack_status_display}] {event.progress_percentage:.0f}%",
387
+ no_wrap=True,
388
+ )
389
+ table = Table(
390
+ show_header=True,
391
+ border_style="dim",
392
+ )
393
+ table.add_column(_("Resource"))
394
+ table.add_column(_("Type"))
395
+ table.add_column(_("Status"))
396
+ for r in event.resources:
397
+ status_icon = r.get("status_icon", "") if isinstance(r, dict) else ""
398
+ status = r.get("status", "") if isinstance(r, dict) else ""
399
+ table.add_row(
400
+ r.get("name", ""),
401
+ r.get("resource_type", ""),
402
+ f"{status_icon} {translate_status(status)}",
403
+ )
404
+ return Group(title, table)
405
+
406
+ def _render_instances_progress(self, event: StackInstancesProgressEvent) -> Group:
407
+ """Render stack instances progress as a Rich Group (title + table)."""
408
+ title = Text(
409
+ f"StackGroup: {event.stack_group_name} [{event.status}] {event.progress_percentage}%",
410
+ no_wrap=True,
411
+ )
412
+ table = Table(
413
+ show_header=True,
414
+ border_style="dim",
415
+ )
416
+ table.add_column(_("Account ID"))
417
+ table.add_column(_("Region"))
418
+ table.add_column(_("Status"))
419
+ for i in event.instances:
420
+ status_icon = i.get("status_icon", "")
421
+ status = i.get("status", "")
422
+ table.add_row(
423
+ i.get("account_id", ""),
424
+ i.get("region_id", ""),
425
+ f"{status_icon} {status}",
426
+ )
427
+ return Group(title, table)
428
+
429
+ def _has_verbose_content(self, rec: _ToolCallRecord) -> bool:
430
+ """True if rendering this tool in verbose mode would differ from compact.
431
+
432
+ Used to decide whether to show the ``(ctrl+o 展开)`` hint — pointless
433
+ when the tool has nothing extra to reveal (e.g. the skill-load tool).
434
+ """
435
+ if not rec.done:
436
+ return False
437
+ # Agent tools show their child tool tree in verbose only.
438
+ if rec.children:
439
+ return True
440
+ tool = self._tool_registry.get(rec.tool_name)
441
+ if tool is None:
442
+ return False
443
+ if tool.render_tool_use_message(rec.tool_input, verbose=False) != tool.render_tool_use_message(
444
+ rec.tool_input, verbose=True
445
+ ):
446
+ return True
447
+ result = rec.result or ""
448
+ return tool.render_tool_result_message(
449
+ result, is_error=rec.is_error, verbose=False
450
+ ) != tool.render_tool_result_message(result, is_error=rec.is_error, verbose=True)
451
+
452
+ def _any_segment_has_verbose(self, segments: list[_Segment]) -> bool:
453
+ """True if any tool segment has content that differs between modes."""
454
+ return any(s.kind == "tool" and s.tool and self._has_verbose_content(s.tool) for s in segments)
455
+
456
+ def _render_tool_header(self, rec: _ToolCallRecord) -> Text:
457
+ """Render ``● ToolName(detail)`` line with optional child tool tree."""
458
+ tool = self._tool_registry.get(rec.tool_name)
459
+ tool_name = tool.user_facing_name(rec.tool_input) if tool else rec.tool_name
460
+ detail = tool.render_tool_use_message(rec.tool_input, verbose=self._verbose) if tool else None
461
+
462
+ line = Text()
463
+ if not rec.done:
464
+ phase = time.monotonic() % 1.0
465
+ dot_style = "bold white" if phase < 0.5 else "dim white"
466
+ line.append("● ", style=dot_style)
467
+ elif rec.is_error:
468
+ line.append("● ", style="bold red")
469
+ else:
470
+ line.append("● ", style="bold green")
471
+ line.append(tool_name, style="bold")
472
+ if detail:
473
+ line.append(f"({detail})")
474
+
475
+ # Render sub-agent child tool tree
476
+ if rec.children:
477
+ if rec.done:
478
+ # Completed: show summary line
479
+ elapsed = ""
480
+ if rec.start_time > 0:
481
+ from iac_code.ui.spinner import _format_elapsed
482
+
483
+ elapsed = f" · {_format_elapsed(time.monotonic() - rec.start_time)}"
484
+ child_count = len(rec.children)
485
+ # Try to extract token info from result
486
+ token_info = ""
487
+ if rec.result:
488
+ import re
489
+
490
+ match = re.search(r"(\d+) tokens", rec.result)
491
+ if match:
492
+ tokens = int(match.group(1))
493
+ token_info = f" · {tokens / 1000:.1f}k tokens" if tokens >= 1000 else f" · {tokens} tokens"
494
+ done_text = _("Done ({child_count} tool uses{token_info}{elapsed})").format(
495
+ child_count=child_count,
496
+ token_info=token_info,
497
+ elapsed=elapsed,
498
+ )
499
+ line.append(f"\n └ {done_text}", style="dim")
500
+ else:
501
+ # In-progress: show recent child tools with tree connectors
502
+ max_visible = 3 if not self._verbose else len(rec.children)
503
+ visible = rec.children[-max_visible:]
504
+ hidden_count = len(rec.children) - len(visible)
505
+
506
+ for i, child in enumerate(visible):
507
+ tool_obj = self._tool_registry.get(child.tool_name)
508
+ child_display = tool_obj.user_facing_name(child.tool_input) if tool_obj else child.tool_name
509
+ child_detail = ""
510
+ if tool_obj:
511
+ d = tool_obj.render_tool_use_message(child.tool_input, verbose=self._verbose)
512
+ if d:
513
+ child_detail = f"({d})"
514
+ if i == 0:
515
+ line.append("\n └ ", style="dim")
516
+ else:
517
+ line.append("\n ", style="dim")
518
+ line.append(child_display, style="bold")
519
+ if child_detail:
520
+ line.append(child_detail, style="dim")
521
+
522
+ if hidden_count > 0:
523
+ line.append(
524
+ "\n " + _("+ {count} more tool uses (ctrl+o to expand)").format(count=hidden_count),
525
+ style="dim",
526
+ )
527
+
528
+ return line
529
+
530
+ def _render_tool_result(self, rec: _ToolCallRecord) -> Text | None:
531
+ """Render `` ⎿ result`` line (compact or verbose)."""
532
+ if not rec.done:
533
+ return None
534
+
535
+ # For agent tools with children, the summary is already in the header
536
+ if rec.children and not self._verbose:
537
+ return None
538
+
539
+ tool = self._tool_registry.get(rec.tool_name)
540
+ result_text = None
541
+ if tool:
542
+ result_text = tool.render_tool_result_message(
543
+ rec.result or "", is_error=rec.is_error, verbose=self._verbose
544
+ )
545
+ if result_text is None and rec.result:
546
+ result_text = rec.result
547
+
548
+ if not result_text:
549
+ return None
550
+
551
+ line = Text()
552
+ line.append(" ⎿ ", style="dim")
553
+ if rec.is_error:
554
+ line.append(str(result_text), style="red")
555
+ else:
556
+ line.append(str(result_text))
557
+ return line
558
+
559
+ def _render_text_block(self, text: str, continuation: bool = False) -> list[Any]:
560
+ """Render a text block with ``✦`` bullet prefix and indented content.
561
+
562
+ Uses a 2-column grid so the bullet sits on the same line as the first
563
+ line of text, and all subsequent lines are indented to align.
564
+
565
+ When *continuation* is True the bullet is replaced with blank space,
566
+ keeping indentation aligned with a preceding flushed block.
567
+ """
568
+ tbl = Table.grid(padding=0)
569
+ tbl.add_column(width=2, no_wrap=True)
570
+ tbl.add_column()
571
+ bullet = Text(" ") if continuation else Text("✦ ", style="bold white")
572
+ tbl.add_row(bullet, _DashMarkdown(text))
573
+ return [tbl]
574
+
575
+ @staticmethod
576
+ def _find_safe_split_pos(text: str) -> tuple[int, bool]:
577
+ """Find the last ``\\n\\n`` that is **outside** a fenced code block.
578
+
579
+ Returns ``(position, currently_in_fence)``. *position* is -1 when no
580
+ safe split point exists.
581
+ """
582
+ in_fence = False
583
+ last_safe = -1
584
+ i = 0
585
+ while i < len(text):
586
+ if text[i : i + 3] == "```":
587
+ in_fence = not in_fence
588
+ i += 3
589
+ # skip optional info-string on opening fence
590
+ while i < len(text) and text[i] != "\n":
591
+ i += 1
592
+ continue
593
+ if not in_fence and text[i : i + 2] == "\n\n":
594
+ last_safe = i
595
+ i += 1
596
+ return last_safe, in_fence
597
+
598
+ def _render_segments(
599
+ self,
600
+ segments: list[_Segment],
601
+ spinner: ShimmerSpinner | None,
602
+ text_buffer: str,
603
+ task_spinner: ShimmerSpinner | None = None,
604
+ *,
605
+ thinking_buffer: str = "",
606
+ ) -> Group | _CropTop:
607
+ """Render all buffered segments + current spinner into a Group."""
608
+ parts: list[Any] = []
609
+ has_content = False
610
+
611
+ for seg in segments:
612
+ if seg.kind == "text":
613
+ if has_content:
614
+ parts.append(Text()) # blank line between segments
615
+ parts.extend(self._render_text_block(seg.text))
616
+ has_content = True
617
+ elif seg.kind == "thinking_summary":
618
+ if has_content:
619
+ parts.append(Text())
620
+ label = _("Thought for {seconds:.1f}s").format(seconds=seg.elapsed_seconds)
621
+ parts.append(Text(f"▌ {label}", style="dim"))
622
+ has_content = True
623
+ elif seg.kind == "tool" and seg.tool:
624
+ if has_content:
625
+ parts.append(Text()) # blank line between segments
626
+ parts.append(self._render_tool_header(seg.tool))
627
+ if seg.tool.progress_renderable is not None and not seg.tool.done:
628
+ parts.append(seg.tool.progress_renderable)
629
+ result_line = self._render_tool_result(seg.tool)
630
+ if result_line:
631
+ parts.append(result_line)
632
+ has_content = True
633
+
634
+ if thinking_buffer:
635
+ if has_content:
636
+ parts.append(Text())
637
+ parts.append(self._render_thinking_quote(thinking_buffer))
638
+ has_content = True
639
+
640
+ # Streaming text that hasn't been finalized yet
641
+ if text_buffer:
642
+ if has_content:
643
+ parts.append(Text()) # blank line before ✦ block
644
+ parts.extend(self._render_text_block(text_buffer, continuation=self._text_flushed))
645
+
646
+ # Current spinner (thinking)
647
+ if spinner:
648
+ parts.append(spinner.render())
649
+
650
+ # Verbose-mode hint — only when some tool actually has more to show.
651
+ if not self._verbose and self._any_segment_has_verbose(segments):
652
+ parts.append(Text(" " + _("(ctrl+o to expand)"), style="dim"))
653
+
654
+ # Task-level spinner with elapsed time (always shown while processing)
655
+ if task_spinner:
656
+ parts.append(Text()) # blank line separator
657
+ parts.append(task_spinner.render())
658
+
659
+ group = Group(*parts) if parts else Group(Text(""))
660
+
661
+ # Wrap in _CropTop so Live never pushes content into the terminal
662
+ # scrollback buffer. The full markdown is rendered first (preserving
663
+ # code-block styling), then only the bottom N lines are kept.
664
+ terminal_height = self.console.height or 24
665
+ max_height = max(terminal_height - 8, 5) # room for footer
666
+ return _CropTop(group, max_height)
667
+
668
+ def _render_thinking_quote(self, text: str) -> _CropTop:
669
+ """Render the live thinking buffer as a dim quote block, height-cropped."""
670
+ lines = text.splitlines() or [""]
671
+ rendered = Group(*[Text(f"▌ {line}", style="dim") for line in lines])
672
+ terminal_height = self.console.height or 24
673
+ max_height = min(6, max(terminal_height // 4, 3))
674
+ return _CropTop(rendered, max_height)
675
+
676
+ # ── Dynamic streaming output ────────────────────────────────────
677
+
678
+ async def run_streaming_output(
679
+ self,
680
+ events: AsyncGenerator[StreamEvent, None],
681
+ permission_handler: Callable[[PermissionRequestEvent], Awaitable[bool]],
682
+ ) -> float:
683
+ """Consume the event stream and render everything."""
684
+ self.console.print() # blank line between user input and agent response
685
+ live: Live | None = None
686
+ spinner: ShimmerSpinner | None = None
687
+ task_spinner: ShimmerSpinner | None = None
688
+ refresh_task: asyncio.Task | None = None
689
+ key_task: asyncio.Task | None = None
690
+ text_buffer = ""
691
+ thinking_buffer: str = ""
692
+ thinking_start_time: float | None = None
693
+ segments: list[_Segment] = []
694
+ turn_start_time: float = time.monotonic()
695
+
696
+ # Save terminal settings before any background task modifies them
697
+ # so we can unconditionally restore on exit.
698
+ _saved_termios = None
699
+ try:
700
+ _saved_termios = termios.tcgetattr(sys.stdin.fileno())
701
+ except (termios.error, OSError, ValueError):
702
+ pass
703
+
704
+ def _finalize_thinking() -> None:
705
+ nonlocal thinking_buffer, thinking_start_time
706
+ if thinking_start_time is not None and thinking_buffer.strip():
707
+ elapsed = time.monotonic() - thinking_start_time
708
+ segments.append(_Segment(kind="thinking_summary", elapsed_seconds=elapsed))
709
+ thinking_buffer = ""
710
+ thinking_start_time = None
711
+
712
+ def _update_live():
713
+ if live:
714
+ content = self._render_segments(
715
+ segments, spinner, text_buffer, task_spinner, thinking_buffer=thinking_buffer
716
+ )
717
+ live.update(self._with_footer(content))
718
+
719
+ def _ensure_live():
720
+ nonlocal live
721
+ if live is None:
722
+ live = Live(
723
+ self._with_footer(Group(Text(""))),
724
+ console=self.console,
725
+ refresh_per_second=20,
726
+ transient=True,
727
+ vertical_overflow="visible",
728
+ )
729
+ live.start()
730
+
731
+ async def _rebuild_after_transcript():
732
+ """Rebuild Live + refresh task immediately after the transcript
733
+ view closes, so the user sees the current segments right away
734
+ instead of waiting for the next stream event to unblock the
735
+ main loop.
736
+ """
737
+ nonlocal refresh_task, live
738
+ await self._stop_refresh(refresh_task)
739
+ refresh_task = None
740
+ if live is not None:
741
+ self._quiet_stop_live(live)
742
+ live = None
743
+ _ensure_live()
744
+ _update_live()
745
+ if live is not None:
746
+ refresh_task = asyncio.create_task(
747
+ self._refresh_loop(
748
+ live,
749
+ segments,
750
+ spinner,
751
+ lambda: text_buffer,
752
+ lambda: task_spinner,
753
+ lambda: thinking_buffer,
754
+ )
755
+ )
756
+ # Already handled; don't let the main-loop reset block redo it.
757
+ self._stream_invalidated = False
758
+
759
+ # Map tool_use_id → _ToolCallRecord for partial-input accumulation
760
+ tool_records: dict[str, _ToolCallRecord] = {}
761
+
762
+ try:
763
+ async for event in events:
764
+ # After a mid-stream transcript view, tear down the stale
765
+ # Live and its background tasks before handling the next
766
+ # event — the alt-screen sequence left them pointing at a
767
+ # now-invalid render context.
768
+ if self._stream_invalidated:
769
+ self._stream_invalidated = False
770
+ await self._stop_refresh(refresh_task)
771
+ refresh_task = None
772
+ await self._stop_refresh(key_task)
773
+ key_task = None
774
+ if live is not None:
775
+ try:
776
+ self._quiet_stop_live(live)
777
+ except Exception:
778
+ pass
779
+ live = None
780
+ # Proactively rebuild Live + both helpers so the task
781
+ # spinner keeps animating and Ctrl+O stays responsive
782
+ # from the very next frame. Without this the UI would
783
+ # appear frozen until an event handler happened to
784
+ # reach a branch that recreates them (MessageStart,
785
+ # ToolUseStart, …).
786
+ _ensure_live()
787
+ # Paint the current segments immediately — otherwise the
788
+ # new Live starts empty and stays empty for up to 50ms
789
+ # (one refresh tick), which is very visible when a sub-
790
+ # agent with many children is mid-flight.
791
+ _update_live()
792
+ if live is not None:
793
+ refresh_task = asyncio.create_task(
794
+ self._refresh_loop(
795
+ live,
796
+ segments,
797
+ spinner,
798
+ lambda: text_buffer,
799
+ lambda: task_spinner,
800
+ lambda: thinking_buffer,
801
+ )
802
+ )
803
+
804
+ # Ensure Ctrl+O is always being listened for during streaming.
805
+ # Several event handlers (sub-agent activity, stack progress,
806
+ # compaction, …) stop/skip recreating the key task, which
807
+ # would swallow Ctrl+O until the next MessageStart or
808
+ # ToolUseStart rebuilt it.
809
+ if live is not None and (key_task is None or key_task.done()):
810
+ key_task = asyncio.create_task(
811
+ self._key_listener(
812
+ live,
813
+ segments,
814
+ spinner,
815
+ lambda: text_buffer,
816
+ _rebuild_after_transcript,
817
+ )
818
+ )
819
+
820
+ # ── Message start ───────────────────────────────
821
+ if isinstance(event, MessageStartEvent):
822
+ self._text_flushed = False # new message = new text block
823
+ if task_spinner is None:
824
+ task_spinner = ShimmerSpinner()
825
+ _ensure_live()
826
+ # Always ensure refresh loop is running for spinner animation
827
+ if refresh_task is None or refresh_task.done():
828
+ refresh_task = asyncio.create_task(
829
+ self._refresh_loop(
830
+ live,
831
+ segments,
832
+ spinner,
833
+ lambda: text_buffer,
834
+ lambda: task_spinner,
835
+ lambda: thinking_buffer,
836
+ )
837
+ )
838
+ if key_task is None or key_task.done():
839
+ key_task = asyncio.create_task(
840
+ self._key_listener(
841
+ live,
842
+ segments,
843
+ spinner,
844
+ lambda: text_buffer,
845
+ _rebuild_after_transcript,
846
+ )
847
+ )
848
+
849
+ # ── Thinking delta ─────────────────────────────
850
+ elif isinstance(event, ThinkingDeltaEvent):
851
+ if thinking_start_time is None:
852
+ thinking_start_time = time.monotonic()
853
+ thinking_buffer += event.text
854
+ spinner = None
855
+ _ensure_live()
856
+ _update_live()
857
+
858
+ # ── Text delta ──────────────────────────────────
859
+ elif isinstance(event, TextDeltaEvent):
860
+ _finalize_thinking()
861
+ spinner = None # stop spinner when text starts
862
+ text_buffer += event.text
863
+ _ensure_live()
864
+
865
+ # Flush completed text to scrollback when it exceeds
866
+ # half the terminal height, preventing Live overflow
867
+ # that causes duplicate content on scroll-up.
868
+ terminal_height = self.console.height or 24
869
+ if text_buffer.count("\n") + 1 > terminal_height // 2:
870
+ split_pos, in_fence = self._find_safe_split_pos(text_buffer)
871
+ flush_text: str | None = None
872
+ if split_pos > 0:
873
+ # Split at safe paragraph break outside code blocks
874
+ flush_text = text_buffer[:split_pos]
875
+ text_buffer = text_buffer[split_pos + 2 :]
876
+ elif not in_fence:
877
+ # No paragraph break but not inside a fence —
878
+ # flush entire buffer to prevent overflow
879
+ flush_text = text_buffer
880
+ text_buffer = ""
881
+ # else: inside a code fence — cannot split safely
882
+
883
+ if flush_text is not None:
884
+ await self._stop_refresh(refresh_task)
885
+ refresh_task = None
886
+ await self._stop_refresh(key_task)
887
+ key_task = None
888
+ if live:
889
+ self._quiet_stop_live(live)
890
+ live = None
891
+ # Archive + print the flushed text so it also
892
+ # appears in the transcript view. Printing it
893
+ # directly via console.print used to skip the
894
+ # message history, which is why the detail page
895
+ # showed only a bare ✦ bullet for any turn
896
+ # whose response was flushed mid-stream.
897
+ self._print_segments_to_scrollback([], flush_text)
898
+ _ensure_live()
899
+ refresh_task = asyncio.create_task(
900
+ self._refresh_loop(
901
+ live,
902
+ segments,
903
+ spinner,
904
+ lambda: text_buffer,
905
+ lambda: task_spinner,
906
+ lambda: thinking_buffer,
907
+ )
908
+ )
909
+ if key_task is None or key_task.done():
910
+ key_task = asyncio.create_task(
911
+ self._key_listener(
912
+ live,
913
+ segments,
914
+ spinner,
915
+ lambda: text_buffer,
916
+ _rebuild_after_transcript,
917
+ )
918
+ )
919
+
920
+ _update_live()
921
+
922
+ # ── Tool use start ──────────────────────────────
923
+ elif isinstance(event, ToolUseStartEvent):
924
+ _finalize_thinking()
925
+ # Finalize any pending text into a segment
926
+ if text_buffer:
927
+ segments.append(_Segment(kind="text", text=text_buffer))
928
+ text_buffer = ""
929
+
930
+ # Flush completed segments (text + done tools) to scrollback
931
+ # to prevent Live content from growing beyond terminal height
932
+ completed = [
933
+ s for s in segments if s.kind == "text" or (s.kind == "tool" and s.tool and s.tool.done)
934
+ ]
935
+ if completed:
936
+ remaining = [s for s in segments if s not in completed]
937
+ await self._stop_refresh(refresh_task)
938
+ refresh_task = None
939
+ await self._stop_refresh(key_task)
940
+ key_task = None
941
+ if live:
942
+ self._quiet_stop_live(live)
943
+ live = None
944
+ self._print_segments_to_scrollback(completed, "")
945
+ segments.clear()
946
+ segments.extend(remaining)
947
+ self._text_flushed = False # text block done before tool
948
+
949
+ rec = _ToolCallRecord(
950
+ tool_name=event.name,
951
+ tool_input={},
952
+ start_time=time.monotonic(),
953
+ )
954
+ tool_records[event.tool_use_id] = rec
955
+ segments.append(_Segment(kind="tool", tool=rec))
956
+
957
+ # No separate spinner — the ● dot animates itself
958
+ spinner = None
959
+ _ensure_live()
960
+ # Restart refresh with updated refs
961
+ await self._stop_refresh(refresh_task)
962
+ refresh_task = asyncio.create_task(
963
+ self._refresh_loop(
964
+ live, segments, spinner, lambda: text_buffer, lambda: task_spinner, lambda: thinking_buffer
965
+ )
966
+ )
967
+ if key_task is None or key_task.done():
968
+ key_task = asyncio.create_task(
969
+ self._key_listener(
970
+ live,
971
+ segments,
972
+ spinner,
973
+ lambda: text_buffer,
974
+ _rebuild_after_transcript,
975
+ )
976
+ )
977
+
978
+ # ── Tool input delta ────────────────────────────
979
+ elif isinstance(event, ToolInputDeltaEvent):
980
+ rec = tool_records.get(event.tool_use_id)
981
+ if rec:
982
+ rec.partial_input += event.partial_json
983
+ _update_live()
984
+
985
+ # ── Tool use end ────────────────────────────────
986
+ elif isinstance(event, ToolUseEndEvent):
987
+ rec = tool_records.get(event.tool_use_id)
988
+ if rec:
989
+ rec.tool_input = event.input
990
+ _update_live()
991
+
992
+ # ── Tool result ─────────────────────────────────
993
+ elif isinstance(event, ToolResultEvent):
994
+ rec = tool_records.get(event.tool_use_id)
995
+ if rec is None:
996
+ # Fallback: match by tool_name for any unfinished tool
997
+ for r in tool_records.values():
998
+ if r.tool_name == event.tool_name and not r.done:
999
+ rec = r
1000
+ break
1001
+ if rec:
1002
+ rec.result = event.result
1003
+ rec.is_error = event.is_error
1004
+ rec.done = True
1005
+ spinner = None
1006
+ _ensure_live()
1007
+ _update_live()
1008
+
1009
+ # If all tools are done, finalize deferred turn
1010
+ all_tools_done = all(s.tool.done for s in segments if s.kind == "tool" and s.tool)
1011
+ if all_tools_done and segments:
1012
+ await self._stop_refresh(refresh_task)
1013
+ refresh_task = None
1014
+ await self._stop_refresh(key_task)
1015
+ key_task = None
1016
+ if live:
1017
+ self._quiet_stop_live(live)
1018
+ live = None
1019
+ self._print_segments_to_scrollback(segments, "")
1020
+ segments.clear()
1021
+ tool_records.clear()
1022
+
1023
+ # ── Sub-agent tool activity ──────────────────────
1024
+ elif isinstance(event, SubAgentToolEvent):
1025
+ rec = tool_records.get(event.parent_tool_use_id)
1026
+ if rec:
1027
+ if rec.children is None:
1028
+ rec.children = []
1029
+ if event.is_done:
1030
+ # Mark existing child as done
1031
+ for child in rec.children:
1032
+ if child.tool_name == event.child_tool_name and not child.is_done:
1033
+ child.is_done = True
1034
+ child.is_error = event.is_error
1035
+ break
1036
+ else:
1037
+ # New child tool started
1038
+ rec.children.append(
1039
+ _SubAgentChild(
1040
+ tool_name=event.child_tool_name,
1041
+ tool_input=event.child_tool_input,
1042
+ )
1043
+ )
1044
+ _ensure_live()
1045
+ # Ensure refresh loop is running to animate child updates
1046
+ if refresh_task is None or refresh_task.done():
1047
+ refresh_task = asyncio.create_task(
1048
+ self._refresh_loop(
1049
+ live,
1050
+ segments,
1051
+ spinner,
1052
+ lambda: text_buffer,
1053
+ lambda: task_spinner,
1054
+ lambda: thinking_buffer,
1055
+ )
1056
+ )
1057
+ _update_live()
1058
+
1059
+ # ── Stack progress ─────────────────────────────
1060
+ elif isinstance(event, StackProgressEvent):
1061
+ for rec in tool_records.values():
1062
+ if rec.tool_name == "ros_stack" and not rec.done:
1063
+ rec.progress_renderable = self._render_stack_progress(event)
1064
+ break
1065
+ _ensure_live()
1066
+ _update_live()
1067
+
1068
+ # ── Stack instances progress ──────────────────
1069
+ elif isinstance(event, StackInstancesProgressEvent):
1070
+ for rec in tool_records.values():
1071
+ if rec.tool_name == "ros_stack_instances" and not rec.done:
1072
+ rec.progress_renderable = self._render_instances_progress(event)
1073
+ break
1074
+ _ensure_live()
1075
+ _update_live()
1076
+
1077
+ # ── Permission request ──────────────────────────
1078
+ elif isinstance(event, PermissionRequestEvent):
1079
+ # Must stop Live to interact with user
1080
+ await self._stop_refresh(refresh_task)
1081
+ refresh_task = None
1082
+ await self._stop_refresh(key_task)
1083
+ key_task = None
1084
+ if live:
1085
+ self._quiet_stop_live(live)
1086
+ live = None
1087
+ spinner = None
1088
+ # Print current state to scrollback
1089
+ self._print_segments_to_scrollback(segments, text_buffer)
1090
+ segments.clear()
1091
+ text_buffer = ""
1092
+ # Handle permission
1093
+ allowed = await permission_handler(event)
1094
+ if event.response_future is not None:
1095
+ if allowed:
1096
+ log_event(
1097
+ Events.TOOL_USE_GRANTED_IN_PROMPT,
1098
+ {
1099
+ "tool_name": event.tool_name,
1100
+ "scope": "once",
1101
+ },
1102
+ )
1103
+ else:
1104
+ log_event(
1105
+ Events.TOOL_USE_REJECTED_IN_PROMPT,
1106
+ {
1107
+ "tool_name": event.tool_name,
1108
+ },
1109
+ )
1110
+ add_metric(
1111
+ Metrics.TOOL_USE_COUNT,
1112
+ 1,
1113
+ {
1114
+ "tool_name": event.tool_name,
1115
+ "outcome": "denied",
1116
+ },
1117
+ )
1118
+ event.response_future.set_result(allowed)
1119
+
1120
+ # ── Compaction ────────────────────────────────
1121
+ elif isinstance(event, CompactionEvent):
1122
+ compact_msg = _("Context auto-compacted: {original} → {compacted} tokens").format(
1123
+ original=event.original_tokens,
1124
+ compacted=event.compacted_tokens,
1125
+ )
1126
+ segments.append(_Segment(kind="text", text=f"*{compact_msg}*"))
1127
+ _update_live()
1128
+
1129
+ # ── Tombstone ──────────────────────────────────
1130
+ elif isinstance(event, TombstoneEvent):
1131
+ # Discard all rendered content for the orphaned message
1132
+ segments.clear()
1133
+ text_buffer = ""
1134
+ tool_records.clear()
1135
+ spinner = None
1136
+ if live:
1137
+ self._quiet_stop_live(live)
1138
+ live = None
1139
+ await self._stop_refresh(refresh_task)
1140
+ refresh_task = None
1141
+ await self._stop_refresh(key_task)
1142
+ key_task = None
1143
+
1144
+ # ── Task notification ──────────────────────────
1145
+ elif isinstance(event, TaskNotificationEvent):
1146
+ style_map = {
1147
+ "completed": "green",
1148
+ "failed": "red",
1149
+ "stopped": "yellow",
1150
+ }
1151
+ style = style_map.get(event.status, "dim")
1152
+ notice = Text()
1153
+ notice.append(f"[{event.status}] ", style=f"bold {style}")
1154
+ notice.append(event.description)
1155
+ if event.result:
1156
+ notice.append(f": {event.result}")
1157
+ if event.error:
1158
+ notice.append(f" (error: {event.error})", style="red")
1159
+ self.console.print(notice)
1160
+
1161
+ # ── Error ──────────────────────────────────────
1162
+ elif isinstance(event, ErrorEvent):
1163
+ self.console.print(Text(event.error, style="bold red"))
1164
+
1165
+ # ── Message end ─────────────────────────────────
1166
+ elif isinstance(event, MessageEndEvent):
1167
+ _finalize_thinking()
1168
+ # Finalize remaining text
1169
+ if text_buffer:
1170
+ segments.append(_Segment(kind="text", text=text_buffer))
1171
+ text_buffer = ""
1172
+ spinner = None
1173
+
1174
+ # Check if there are unfinished tool calls (e.g. agent tools
1175
+ # that will produce SubAgentToolEvents during execution)
1176
+ has_pending_tools = any(s.kind == "tool" and s.tool and not s.tool.done for s in segments)
1177
+
1178
+ if has_pending_tools:
1179
+ # Keep Live, segments, and tool_records alive —
1180
+ # SubAgentToolEvent and ToolResultEvent will arrive next.
1181
+ # The turn is finalized in ToolResultEvent when all tools done.
1182
+ pass
1183
+ else:
1184
+ # No pending tools — finalize turn normally
1185
+ await self._stop_refresh(refresh_task)
1186
+ refresh_task = None
1187
+ await self._stop_refresh(key_task)
1188
+ key_task = None
1189
+ if live:
1190
+ self._quiet_stop_live(live)
1191
+ live = None
1192
+ self._print_segments_to_scrollback(segments, "")
1193
+ segments.clear()
1194
+ tool_records.clear()
1195
+ # DON'T stop task_spinner — it persists across turns
1196
+ # DON'T break — there may be more events after tool execution
1197
+
1198
+ except (asyncio.CancelledError, KeyboardInterrupt):
1199
+ self.console.print(Text(_("Operation cancelled."), style="yellow"))
1200
+ except Exception as e:
1201
+ error_msg = str(e)
1202
+ if "No key found" in error_msg or "api_key" in error_msg.lower() or "API key" in error_msg.lower():
1203
+ self.print_system_message(
1204
+ _("No API key configured.") + "\n" + _("Please run /auth to set up your LLM provider and API key."),
1205
+ style="yellow",
1206
+ )
1207
+ else:
1208
+ self.print_system_message(_("Error: {error}").format(error=error_msg), style="red")
1209
+ finally:
1210
+ task_spinner = None
1211
+ # Restore terminal settings first, before awaiting tasks, so that
1212
+ # even if a second Ctrl+C aborts the cleanup the terminal is sane.
1213
+ if _saved_termios is not None:
1214
+ try:
1215
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, _saved_termios)
1216
+ except (termios.error, OSError, ValueError):
1217
+ pass
1218
+ # Stop background tasks. Wrap each await separately so that a
1219
+ # pending CancelledError (from task cancellation edge cases)
1220
+ # does not skip the remaining cleanup.
1221
+ for _bg_task in (refresh_task, key_task):
1222
+ try:
1223
+ await self._stop_refresh(_bg_task)
1224
+ except asyncio.CancelledError:
1225
+ if _bg_task and not _bg_task.done():
1226
+ _bg_task.cancel()
1227
+ if live:
1228
+ self._quiet_stop_live(live)
1229
+ live = None
1230
+ # Print any remaining segments
1231
+ if segments:
1232
+ self._print_segments_to_scrollback(segments, text_buffer)
1233
+ segments.clear()
1234
+ # Print completion message with random verb and duration
1235
+ from iac_code.ui.spinner import _format_elapsed, random_completion_verb
1236
+
1237
+ elapsed = time.monotonic() - turn_start_time
1238
+ if elapsed >= 1.0:
1239
+ self.console.print() # blank line before completion
1240
+ verb = random_completion_verb()
1241
+ self.console.print(Text(f"✻ {verb} {_format_elapsed(elapsed)}", style="dim italic"))
1242
+
1243
+ return elapsed
1244
+
1245
+ # ── Permission prompting ────────────────────────────────────────
1246
+
1247
+ async def prompt_permission(self, event: PermissionRequestEvent) -> bool:
1248
+ """Inline permission prompt — arrow-key selector aligned with ACP."""
1249
+ from iac_code.state.app_state import lookup_permission, record_permission
1250
+
1251
+ tool_name = event.tool_name
1252
+ cache = self._app_state_store.get_state().always_allow_rules if self._app_state_store is not None else None
1253
+
1254
+ # Short-circuit on cached sticky decisions — no prompt, no input read.
1255
+ cached = lookup_permission(cache, tool_name)
1256
+ if cached == "always_allow":
1257
+ return True
1258
+ if cached == "always_deny":
1259
+ return False
1260
+
1261
+ tool = self._tool_registry.get(tool_name)
1262
+ tool_display = tool.user_facing_name(event.tool_input) if tool else tool_name
1263
+ detail = None
1264
+ if tool:
1265
+ detail = tool.render_tool_use_message(event.tool_input)
1266
+
1267
+ # Tool-use header.
1268
+ line = Text()
1269
+ line.append("● ", style="bold")
1270
+ line.append(tool_display, style="bold")
1271
+ if detail:
1272
+ line.append(f" ({detail})")
1273
+ self.console.print(line)
1274
+
1275
+ # Arrow-key selector aligned with ACP's four PermissionOption kinds.
1276
+ self.console.print(Text(_("Allow this action?"), style="bold"))
1277
+
1278
+ options: list[OptionType] = [
1279
+ TextOption(label=_("Yes, allow once"), value="allow_once"),
1280
+ TextOption(label=_("Yes, allow always for this tool"), value="always_allow"),
1281
+ TextOption(label=_("No, reject once"), value="reject_once", description=f"({_('default')})"),
1282
+ TextOption(label=_("No, always reject this tool"), value="always_deny"),
1283
+ ]
1284
+
1285
+ select = Select(
1286
+ options=options,
1287
+ default_value="reject_once",
1288
+ layout=SelectLayout.EXPANDED,
1289
+ visible_count=4,
1290
+ )
1291
+
1292
+ loop = asyncio.get_event_loop()
1293
+ result = await loop.run_in_executor(None, select.run)
1294
+
1295
+ if result is None:
1296
+ return False
1297
+
1298
+ if result == "allow_once":
1299
+ return True
1300
+ if result == "always_allow":
1301
+ record_permission(cache, tool_name, "always_allow")
1302
+ return True
1303
+ if result == "always_deny":
1304
+ record_permission(cache, tool_name, "always_deny")
1305
+ return False
1306
+ return False
1307
+
1308
+ # ── Scrollback finalization ──────────────────────────────────────
1309
+
1310
+ def _print_segments_to_scrollback(self, segments: list[_Segment], trailing_text: str) -> None:
1311
+ """Print finalized segments to terminal scrollback."""
1312
+ archived = copy.deepcopy(segments)
1313
+ if trailing_text:
1314
+ archived.append(_Segment(kind="text", text=trailing_text))
1315
+ if not archived:
1316
+ return
1317
+
1318
+ if self._message_history and self._message_history[-1].role == "assistant":
1319
+ self._message_history[-1].segments.extend(archived)
1320
+ else:
1321
+ self._message_history.append(RenderedTurn(role="assistant", segments=archived, timestamp=time.monotonic()))
1322
+
1323
+ has_content = False
1324
+ for seg in segments:
1325
+ if seg.kind == "text" and seg.text:
1326
+ if has_content:
1327
+ self.console.print()
1328
+ for part in self._render_text_block(seg.text, continuation=self._text_flushed):
1329
+ self.console.print(part)
1330
+ self._text_flushed = True
1331
+ has_content = True
1332
+ elif seg.kind == "thinking_summary":
1333
+ if has_content:
1334
+ self.console.print()
1335
+ label = _("Thought for {seconds:.1f}s").format(seconds=seg.elapsed_seconds)
1336
+ self.console.print(Text(f"▌ {label}", style="dim"))
1337
+ has_content = True
1338
+ self._text_flushed = False
1339
+ elif seg.kind == "tool" and seg.tool:
1340
+ if has_content:
1341
+ self.console.print()
1342
+ self.console.print(self._render_tool_header(seg.tool))
1343
+ result_line = self._render_tool_result(seg.tool)
1344
+ if result_line:
1345
+ self.console.print(result_line)
1346
+ has_content = True
1347
+ self._text_flushed = False
1348
+ if trailing_text:
1349
+ if has_content:
1350
+ self.console.print()
1351
+ for part in self._render_text_block(trailing_text, continuation=self._text_flushed):
1352
+ self.console.print(part)
1353
+ self._text_flushed = True
1354
+
1355
+ if not self._verbose and self._any_segment_has_verbose(segments):
1356
+ self.console.print(Text(" " + _("(ctrl+o to expand)"), style="dim"))
1357
+
1358
+ def replay_history(self, messages: list) -> None:
1359
+ """Replay saved Message objects to scrollback with 1:1 visual fidelity."""
1360
+ from iac_code.agent.message import TextBlock, ToolResultBlock, ToolUseBlock
1361
+
1362
+ # Build a lookup of tool_use_id → ToolResultBlock from all user messages
1363
+ tool_results: dict[str, ToolResultBlock] = {}
1364
+ for msg in messages:
1365
+ if msg.role == "user" and isinstance(msg.content, list):
1366
+ for block in msg.content:
1367
+ if isinstance(block, ToolResultBlock):
1368
+ tool_results[block.tool_use_id] = block
1369
+
1370
+ first_turn = True
1371
+ for msg in messages:
1372
+ if msg.role == "user":
1373
+ is_tool_result_only = isinstance(msg.content, list) and all(
1374
+ isinstance(b, ToolResultBlock) for b in msg.content
1375
+ )
1376
+ if is_tool_result_only:
1377
+ continue
1378
+ if not first_turn:
1379
+ self.console.print()
1380
+ first_turn = False
1381
+ if isinstance(msg.content, str):
1382
+ self.print_user_message(msg.content)
1383
+ else:
1384
+ text = msg.get_text()
1385
+ if text:
1386
+ self.print_user_message(text)
1387
+ self.console.print() # blank line between user input and agent response
1388
+ elif msg.role == "assistant":
1389
+ segments: list[_Segment] = []
1390
+ if isinstance(msg.content, str):
1391
+ segments.append(_Segment(kind="text", text=msg.content))
1392
+ elif isinstance(msg.content, list):
1393
+ for block in msg.content:
1394
+ if isinstance(block, TextBlock):
1395
+ segments.append(_Segment(kind="text", text=block.text))
1396
+ elif isinstance(block, ToolUseBlock):
1397
+ result = tool_results.get(block.id)
1398
+ rec = _ToolCallRecord(
1399
+ tool_name=block.name,
1400
+ tool_input=block.input,
1401
+ result=result.content if result else None,
1402
+ is_error=result.is_error if result else False,
1403
+ done=True,
1404
+ )
1405
+ segments.append(_Segment(kind="tool", tool=rec))
1406
+ if segments:
1407
+ self._text_flushed = False
1408
+ self._print_segments_to_scrollback(segments, "")
1409
+ if msg.elapsed_seconds >= 1.0:
1410
+ from iac_code.ui.spinner import _format_elapsed, random_completion_verb
1411
+
1412
+ self.console.print()
1413
+ self.console.print(
1414
+ Text(f"✻ {random_completion_verb()} {_format_elapsed(msg.elapsed_seconds)}", style="dim italic")
1415
+ )
1416
+
1417
+ # ── Background tasks ────────────────────────────────────────────
1418
+
1419
+ async def _refresh_loop(
1420
+ self,
1421
+ live: Live,
1422
+ segments: list[_Segment],
1423
+ spinner: ShimmerSpinner | None,
1424
+ get_text: Callable[[], str],
1425
+ get_task_spinner: Callable[[], ShimmerSpinner | None] | None = None,
1426
+ get_thinking: Callable[[], str] | None = None,
1427
+ ) -> None:
1428
+ """Background task: update Live with spinner frames at ~20fps."""
1429
+ try:
1430
+ while True:
1431
+ await asyncio.sleep(0.05)
1432
+ ts = get_task_spinner() if get_task_spinner else None
1433
+ tb = get_thinking() if get_thinking else ""
1434
+ content = self._render_segments(segments, spinner, get_text(), ts, thinking_buffer=tb)
1435
+ live.update(self._with_footer(content))
1436
+ except asyncio.CancelledError:
1437
+ pass
1438
+
1439
+ async def _key_listener(
1440
+ self,
1441
+ live: Live,
1442
+ segments: list[_Segment],
1443
+ spinner: ShimmerSpinner | None,
1444
+ get_text: Callable[[], str],
1445
+ on_transcript_done: Callable[[], Awaitable[None]] | None = None,
1446
+ ) -> None:
1447
+ """Background task: listen for Ctrl+O to toggle verbose mode.
1448
+
1449
+ Uses loop.add_reader for proper asyncio integration and clears
1450
+ IEXTEN to prevent macOS from intercepting Ctrl+O as VDISCARD.
1451
+ """
1452
+ fd = sys.stdin.fileno()
1453
+ try:
1454
+ old_settings = termios.tcgetattr(fd)
1455
+ except termios.error:
1456
+ return # not a TTY
1457
+
1458
+ loop = asyncio.get_running_loop()
1459
+ queue: asyncio.Queue[int] = asyncio.Queue()
1460
+
1461
+ def _on_readable() -> None:
1462
+ try:
1463
+ data = os.read(fd, 64)
1464
+ for b in data:
1465
+ queue.put_nowait(b)
1466
+ except OSError:
1467
+ pass
1468
+
1469
+ try:
1470
+ loop.add_reader(fd, _on_readable)
1471
+ except OSError:
1472
+ # macOS kqueue cannot register certain fds (e.g. /dev/tty
1473
+ # reopened after piped stdin). Silently disable key listener.
1474
+ return
1475
+
1476
+ try:
1477
+ # cbreak mode + clear IEXTEN so Ctrl+O (VDISCARD) reaches us
1478
+ mode = termios.tcgetattr(fd)
1479
+ mode[3] = mode[3] & ~(termios.ECHO | termios.ICANON | termios.IEXTEN)
1480
+ mode[6][termios.VMIN] = 1
1481
+ mode[6][termios.VTIME] = 0
1482
+ termios.tcsetattr(fd, termios.TCSANOW, mode)
1483
+
1484
+ show_transcript_after = False
1485
+ while True:
1486
+ ch = await queue.get()
1487
+ if ch == 0x0F: # Ctrl+O — break out and open transcript view
1488
+ show_transcript_after = True
1489
+ break
1490
+ if ch == 0x1B: # Escape — interrupt
1491
+ break
1492
+ except asyncio.CancelledError:
1493
+ return
1494
+ finally:
1495
+ try:
1496
+ loop.remove_reader(fd)
1497
+ except Exception:
1498
+ pass
1499
+ try:
1500
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1501
+ except termios.error:
1502
+ pass
1503
+
1504
+ if show_transcript_after:
1505
+ # Stop the Live region so the alt-screen view starts from a
1506
+ # clean main screen. Use the quiet variant so we don't leak a
1507
+ # stray line of Live content into scrollback on every cycle.
1508
+ if live is not None:
1509
+ self._quiet_stop_live(live)
1510
+ # Pass the live segments so the transcript shows the currently
1511
+ # running tool call tree (e.g. a sub-agent mid-flight) that
1512
+ # hasn't been flushed into _message_history yet.
1513
+ self.show_transcript(current_segments=list(segments))
1514
+ # Rebuild Live from within this task so the user sees the
1515
+ # streaming state the instant the transcript closes — waiting
1516
+ # for the outer loop's next event would leave the screen blank
1517
+ # for however long the LLM stays silent after we resume.
1518
+ if on_transcript_done is not None:
1519
+ try:
1520
+ await on_transcript_done()
1521
+ except Exception:
1522
+ # Fall back to the slower main-loop reset path if the
1523
+ # immediate rebuild failed for any reason.
1524
+ self._stream_invalidated = True
1525
+ else:
1526
+ self._stream_invalidated = True
1527
+
1528
+ async def _stop_refresh(self, task: asyncio.Task | None) -> None:
1529
+ """Cancel a background task."""
1530
+ if task and not task.done():
1531
+ task.cancel()
1532
+ try:
1533
+ await task
1534
+ except asyncio.CancelledError:
1535
+ pass