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