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