iac-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- iac_code-0.1.0.dist-info/top_level.txt +1 -0
iac_code/ui/renderer.py
ADDED
|
@@ -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
|