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,319 @@
|
|
|
1
|
+
"""Enhanced selector component with TextOption and InputOption support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from rich.console import Group, RenderableType
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from iac_code.ui.components.search_box import SearchBox
|
|
13
|
+
from iac_code.ui.core.key_event import KeyEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TextOption:
|
|
18
|
+
"""A selectable text option."""
|
|
19
|
+
|
|
20
|
+
label: str
|
|
21
|
+
value: Any
|
|
22
|
+
description: str = ""
|
|
23
|
+
disabled: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class InputOption:
|
|
28
|
+
"""An option that opens an inline text input when selected."""
|
|
29
|
+
|
|
30
|
+
label: str
|
|
31
|
+
value: Any
|
|
32
|
+
placeholder: str = ""
|
|
33
|
+
initial_value: str = ""
|
|
34
|
+
on_change: Callable[[str], None] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
OptionType = TextOption | InputOption
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SelectLayout(Enum):
|
|
41
|
+
COMPACT = "compact"
|
|
42
|
+
EXPANDED = "expanded"
|
|
43
|
+
COMPACT_VERTICAL = "compact_vertical"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SelectState:
|
|
48
|
+
focused_index: int = 0
|
|
49
|
+
visible_from: int = 0
|
|
50
|
+
visible_to: int = 0
|
|
51
|
+
is_in_input: bool = False
|
|
52
|
+
input_values: dict[Any, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Select:
|
|
56
|
+
"""An enhanced selector component that supports TextOption and InputOption.
|
|
57
|
+
|
|
58
|
+
Navigation:
|
|
59
|
+
↑/↓/Ctrl+P/Ctrl+N move focus (skipping disabled options).
|
|
60
|
+
PageUp/PageDown moves by visible_count.
|
|
61
|
+
No wrapping at edges.
|
|
62
|
+
Enter selects TextOption or enters edit mode for InputOption.
|
|
63
|
+
Escape cancels (or exits edit mode if in one).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
options: list[OptionType],
|
|
69
|
+
default_value: Any = None,
|
|
70
|
+
layout: SelectLayout = SelectLayout.EXPANDED,
|
|
71
|
+
visible_count: int = 10,
|
|
72
|
+
keybinding_manager: object | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._options = options
|
|
75
|
+
self._layout = layout
|
|
76
|
+
self._visible_count = visible_count
|
|
77
|
+
self._keybinding_manager = keybinding_manager
|
|
78
|
+
|
|
79
|
+
self.state = SelectState()
|
|
80
|
+
|
|
81
|
+
# Callbacks set externally or by run()
|
|
82
|
+
self._on_select: Callable[[Any], None] | None = None
|
|
83
|
+
self._on_cancel: Callable[[], None] | None = None
|
|
84
|
+
self._done: bool = False
|
|
85
|
+
self._result: Any = None
|
|
86
|
+
|
|
87
|
+
# Active search box for InputOption editing
|
|
88
|
+
self._active_search_box: SearchBox | None = None
|
|
89
|
+
|
|
90
|
+
# Set initial focus based on default_value
|
|
91
|
+
if default_value is not None:
|
|
92
|
+
for i, opt in enumerate(options):
|
|
93
|
+
if opt.value == default_value:
|
|
94
|
+
self.state.focused_index = i
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
# Initialize viewport
|
|
98
|
+
self._update_viewport()
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Public API
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def run(self) -> Any | None:
|
|
105
|
+
"""Blocking mode: enter raw input and loop until selection or cancel."""
|
|
106
|
+
from rich.console import Console
|
|
107
|
+
|
|
108
|
+
from iac_code.ui.core.in_place_render import InPlaceRenderer
|
|
109
|
+
from iac_code.ui.core.raw_input import RawInputCapture
|
|
110
|
+
|
|
111
|
+
renderer = InPlaceRenderer(Console())
|
|
112
|
+
result_holder: list[Any] = []
|
|
113
|
+
cancelled = [False]
|
|
114
|
+
|
|
115
|
+
def on_select(value: Any) -> None:
|
|
116
|
+
result_holder.append(value)
|
|
117
|
+
self._done = True
|
|
118
|
+
|
|
119
|
+
def on_cancel() -> None:
|
|
120
|
+
cancelled[0] = True
|
|
121
|
+
self._done = True
|
|
122
|
+
|
|
123
|
+
self._on_select = on_select
|
|
124
|
+
self._on_cancel = on_cancel
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
with RawInputCapture() as cap:
|
|
128
|
+
while not self._done:
|
|
129
|
+
renderer.render(self.render())
|
|
130
|
+
key_event = cap.read_key(timeout=0.1)
|
|
131
|
+
if key_event is not None:
|
|
132
|
+
self.handle_key(key_event)
|
|
133
|
+
finally:
|
|
134
|
+
renderer.clear()
|
|
135
|
+
|
|
136
|
+
if cancelled[0]:
|
|
137
|
+
return None
|
|
138
|
+
return result_holder[0] if result_holder else None
|
|
139
|
+
|
|
140
|
+
def render(self) -> RenderableType:
|
|
141
|
+
"""Render the select component."""
|
|
142
|
+
lines: list[RenderableType] = []
|
|
143
|
+
visible_opts = self._options[self.state.visible_from : self.state.visible_to]
|
|
144
|
+
for i, opt in enumerate(visible_opts):
|
|
145
|
+
abs_i = self.state.visible_from + i
|
|
146
|
+
is_focused = abs_i == self.state.focused_index
|
|
147
|
+
lines.append(self._render_option(opt, is_focused, abs_i))
|
|
148
|
+
return Group(*lines)
|
|
149
|
+
|
|
150
|
+
def handle_key(self, key_event: KeyEvent) -> bool:
|
|
151
|
+
"""Handle a key event. Returns True if consumed."""
|
|
152
|
+
key = key_event.key
|
|
153
|
+
ctrl = key_event.ctrl
|
|
154
|
+
|
|
155
|
+
# If we're in input edit mode, delegate to search box
|
|
156
|
+
if self.state.is_in_input and self._active_search_box is not None:
|
|
157
|
+
if key == "escape":
|
|
158
|
+
# Exit edit mode without cancelling
|
|
159
|
+
self.state.is_in_input = False
|
|
160
|
+
self._active_search_box = None
|
|
161
|
+
return True
|
|
162
|
+
if key == "enter":
|
|
163
|
+
# Commit the input value
|
|
164
|
+
opt = self._options[self.state.focused_index]
|
|
165
|
+
self.state.input_values[opt.value] = self._active_search_box.value
|
|
166
|
+
self.state.is_in_input = False
|
|
167
|
+
if self._on_select is not None:
|
|
168
|
+
self._on_select(opt.value)
|
|
169
|
+
self._active_search_box = None
|
|
170
|
+
return True
|
|
171
|
+
return self._active_search_box.handle_key(key_event)
|
|
172
|
+
|
|
173
|
+
# Navigation
|
|
174
|
+
if key == "up" or (ctrl and key == "p"):
|
|
175
|
+
self._move_focus(-1)
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
if key == "down" or (ctrl and key == "n"):
|
|
179
|
+
self._move_focus(1)
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
if key == "pageup":
|
|
183
|
+
self._move_focus(-self._visible_count)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
if key == "pagedown":
|
|
187
|
+
self._move_focus(self._visible_count)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
# Selection / cancel
|
|
191
|
+
if key == "enter":
|
|
192
|
+
return self._handle_enter()
|
|
193
|
+
|
|
194
|
+
if key == "escape":
|
|
195
|
+
if self._on_cancel is not None:
|
|
196
|
+
self._on_cancel()
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
# Internal helpers
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def _move_focus(self, delta: int) -> None:
|
|
206
|
+
"""Move focus by delta steps, skipping disabled options, clamping at edges."""
|
|
207
|
+
n = len(self._options)
|
|
208
|
+
if n == 0:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
current = self.state.focused_index
|
|
212
|
+
step = 1 if delta > 0 else -1
|
|
213
|
+
remaining = abs(delta)
|
|
214
|
+
|
|
215
|
+
while remaining > 0:
|
|
216
|
+
next_idx = current + step
|
|
217
|
+
if next_idx < 0 or next_idx >= n:
|
|
218
|
+
break # No wrapping
|
|
219
|
+
current = next_idx
|
|
220
|
+
opt = self._options[current]
|
|
221
|
+
is_disabled = isinstance(opt, TextOption) and opt.disabled
|
|
222
|
+
if not is_disabled:
|
|
223
|
+
remaining -= 1
|
|
224
|
+
|
|
225
|
+
self.state.focused_index = current
|
|
226
|
+
self._update_viewport()
|
|
227
|
+
|
|
228
|
+
def _handle_enter(self) -> bool:
|
|
229
|
+
"""Handle enter key press."""
|
|
230
|
+
if not self._options:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
opt = self._options[self.state.focused_index]
|
|
234
|
+
|
|
235
|
+
# Don't allow selection of disabled options
|
|
236
|
+
if isinstance(opt, TextOption) and opt.disabled:
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
if isinstance(opt, InputOption):
|
|
240
|
+
# Enter edit mode
|
|
241
|
+
initial = self.state.input_values.get(opt.value, opt.initial_value)
|
|
242
|
+
on_change = opt.on_change
|
|
243
|
+
self._active_search_box = SearchBox(
|
|
244
|
+
placeholder=opt.placeholder,
|
|
245
|
+
initial_value=initial,
|
|
246
|
+
on_change=on_change,
|
|
247
|
+
)
|
|
248
|
+
self.state.is_in_input = True
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
# TextOption: select it
|
|
252
|
+
if self._on_select is not None:
|
|
253
|
+
self._on_select(opt.value)
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
def _update_viewport(self) -> None:
|
|
257
|
+
"""Update visible_from and visible_to so focused_index is visible."""
|
|
258
|
+
n = len(self._options)
|
|
259
|
+
count = min(self._visible_count, n)
|
|
260
|
+
|
|
261
|
+
if count == 0:
|
|
262
|
+
self.state.visible_from = 0
|
|
263
|
+
self.state.visible_to = 0
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# Clamp focused_index
|
|
267
|
+
fi = max(0, min(self.state.focused_index, n - 1))
|
|
268
|
+
|
|
269
|
+
vf = self.state.visible_from
|
|
270
|
+
vt = self.state.visible_to
|
|
271
|
+
|
|
272
|
+
# Initialise if not set
|
|
273
|
+
if vt == 0:
|
|
274
|
+
vt = count
|
|
275
|
+
|
|
276
|
+
# Scroll down
|
|
277
|
+
if fi >= vt:
|
|
278
|
+
vt = fi + 1
|
|
279
|
+
vf = vt - count
|
|
280
|
+
|
|
281
|
+
# Scroll up
|
|
282
|
+
if fi < vf:
|
|
283
|
+
vf = fi
|
|
284
|
+
vt = vf + count
|
|
285
|
+
|
|
286
|
+
# Clamp
|
|
287
|
+
vf = max(0, vf)
|
|
288
|
+
vt = min(n, vt)
|
|
289
|
+
|
|
290
|
+
self.state.visible_from = vf
|
|
291
|
+
self.state.visible_to = vt
|
|
292
|
+
|
|
293
|
+
def _render_option(self, opt: OptionType, is_focused: bool, index: int) -> Text:
|
|
294
|
+
"""Render a single option line."""
|
|
295
|
+
text = Text()
|
|
296
|
+
|
|
297
|
+
if is_focused:
|
|
298
|
+
text.append("❯ ", style="bold cyan")
|
|
299
|
+
else:
|
|
300
|
+
text.append(" ")
|
|
301
|
+
|
|
302
|
+
if isinstance(opt, TextOption):
|
|
303
|
+
style = "dim" if opt.disabled else ("bold" if is_focused else "")
|
|
304
|
+
text.append(opt.label, style=style)
|
|
305
|
+
if opt.description:
|
|
306
|
+
text.append(f" {opt.description}", style="dim")
|
|
307
|
+
elif isinstance(opt, InputOption):
|
|
308
|
+
text.append(opt.label, style="bold" if is_focused else "")
|
|
309
|
+
# Show current value if we have one
|
|
310
|
+
current_val = self.state.input_values.get(opt.value, opt.initial_value)
|
|
311
|
+
if self.state.is_in_input and is_focused and self._active_search_box is not None:
|
|
312
|
+
text.append(": ")
|
|
313
|
+
text.append_text(self._active_search_box.render())
|
|
314
|
+
elif current_val:
|
|
315
|
+
text.append(f": {current_val}", style="cyan")
|
|
316
|
+
elif opt.placeholder:
|
|
317
|
+
text.append(f": {opt.placeholder}", style="dim")
|
|
318
|
+
|
|
319
|
+
return text
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""StatusIcon component with coloured status symbols."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Status(Enum):
|
|
11
|
+
"""Enumeration of supported status values."""
|
|
12
|
+
|
|
13
|
+
SUCCESS = "success"
|
|
14
|
+
ERROR = "error"
|
|
15
|
+
WARNING = "warning"
|
|
16
|
+
INFO = "info"
|
|
17
|
+
PENDING = "pending"
|
|
18
|
+
RUNNING = "running"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_ICONS: dict[Status, tuple[str, str]] = {
|
|
22
|
+
Status.SUCCESS: ("✓", "green"),
|
|
23
|
+
Status.ERROR: ("✗", "red"),
|
|
24
|
+
Status.WARNING: ("⚠", "yellow"),
|
|
25
|
+
Status.INFO: ("●", "blue"),
|
|
26
|
+
Status.PENDING: ("○", "dim"),
|
|
27
|
+
Status.RUNNING: ("◐", "blue"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StatusIcon:
|
|
32
|
+
"""Renders a coloured status icon as Rich Text."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, status: Status) -> None:
|
|
35
|
+
self.status = status
|
|
36
|
+
|
|
37
|
+
def render(self) -> Text:
|
|
38
|
+
"""Return the status icon as Rich Text."""
|
|
39
|
+
icon, style = _ICONS[self.status]
|
|
40
|
+
text = Text()
|
|
41
|
+
text.append(icon, style=style)
|
|
42
|
+
return text
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Tabs component for switching between named content panes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable, cast
|
|
7
|
+
|
|
8
|
+
from rich.console import Group, RenderableType
|
|
9
|
+
from rich.rule import Rule
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from iac_code.ui.core.key_event import KeyEvent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Tab:
|
|
17
|
+
"""Definition of a single tab."""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
title: str
|
|
21
|
+
content: RenderableType | Callable[[], RenderableType]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Tabs:
|
|
25
|
+
"""A tab-bar component.
|
|
26
|
+
|
|
27
|
+
Renders as:
|
|
28
|
+
[Selected] | Other | Other
|
|
29
|
+
────────────────────────────
|
|
30
|
+
<content of selected tab>
|
|
31
|
+
|
|
32
|
+
Navigation:
|
|
33
|
+
← / → move between tabs without wrapping at edges.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
tabs: list[Tab],
|
|
39
|
+
default_tab: str | None = None,
|
|
40
|
+
on_tab_change: Callable[[str], None] | None = None,
|
|
41
|
+
keybinding_manager: object | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._tabs = tabs
|
|
44
|
+
self._on_tab_change = on_tab_change
|
|
45
|
+
self._keybinding_manager = keybinding_manager
|
|
46
|
+
|
|
47
|
+
if default_tab is not None:
|
|
48
|
+
self._selected = default_tab
|
|
49
|
+
elif tabs:
|
|
50
|
+
self._selected = tabs[0].id
|
|
51
|
+
else:
|
|
52
|
+
self._selected = ""
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Properties
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def selected_tab(self) -> str:
|
|
60
|
+
return self._selected
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Key handling
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def handle_key(self, key_event: KeyEvent) -> bool:
|
|
67
|
+
"""Handle left/right arrow keys to switch tabs.
|
|
68
|
+
|
|
69
|
+
Returns True if the key was consumed, False otherwise.
|
|
70
|
+
"""
|
|
71
|
+
key = key_event.key
|
|
72
|
+
|
|
73
|
+
if key not in ("left", "right"):
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
ids = [t.id for t in self._tabs]
|
|
77
|
+
if not ids:
|
|
78
|
+
return True # consumed even if nothing to do
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
idx = ids.index(self._selected)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
if key == "right":
|
|
86
|
+
new_idx = min(idx + 1, len(ids) - 1)
|
|
87
|
+
else:
|
|
88
|
+
new_idx = max(idx - 1, 0)
|
|
89
|
+
|
|
90
|
+
if new_idx != idx:
|
|
91
|
+
self._selected = ids[new_idx]
|
|
92
|
+
if self._on_tab_change is not None:
|
|
93
|
+
self._on_tab_change(self._selected)
|
|
94
|
+
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Rendering
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def render(self) -> RenderableType:
|
|
102
|
+
"""Render the tab bar, rule, and active tab content."""
|
|
103
|
+
tab_bar = self._render_tab_bar()
|
|
104
|
+
rule = Rule(style="dim")
|
|
105
|
+
content = self._get_content()
|
|
106
|
+
return Group(tab_bar, rule, content)
|
|
107
|
+
|
|
108
|
+
def _render_tab_bar(self) -> Text:
|
|
109
|
+
"""Build the tab header line."""
|
|
110
|
+
text = Text()
|
|
111
|
+
for i, tab in enumerate(self._tabs):
|
|
112
|
+
if i > 0:
|
|
113
|
+
text.append(" | ", style="dim")
|
|
114
|
+
if tab.id == self._selected:
|
|
115
|
+
text.append(f"[{tab.title}]", style="bold cyan")
|
|
116
|
+
else:
|
|
117
|
+
text.append(tab.title, style="dim")
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
def _get_content(self) -> RenderableType:
|
|
121
|
+
"""Return the content for the currently selected tab."""
|
|
122
|
+
for tab in self._tabs:
|
|
123
|
+
if tab.id == self._selected:
|
|
124
|
+
if callable(tab.content):
|
|
125
|
+
content_factory = cast(Callable[[], RenderableType], tab.content)
|
|
126
|
+
return content_factory()
|
|
127
|
+
return tab.content
|
|
128
|
+
return Text("")
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""In-place renderer for full-screen pickers and dialogs.
|
|
2
|
+
|
|
3
|
+
Renders a Rich renderable at the cursor's current position in the main
|
|
4
|
+
buffer (not the alternate screen), erasing the previous frame before
|
|
5
|
+
drawing each new one. Same teardown pattern as
|
|
6
|
+
``Renderer._quiet_stop_live`` — emits zero newlines past the bottom of
|
|
7
|
+
the render so nothing leaks into scrollback.
|
|
8
|
+
|
|
9
|
+
Why not the alternate screen / Rich ``Live(transient=True)``: both leak
|
|
10
|
+
the rendered frames into the main buffer's scrollback on some terminals,
|
|
11
|
+
which makes ``↑`` after a picker show every frame instead of pre-picker
|
|
12
|
+
history.
|
|
13
|
+
|
|
14
|
+
Why not bare ``console.print`` in a loop: that appends each frame below
|
|
15
|
+
the previous one (no erase), and under ``RawInputCapture`` (raw mode,
|
|
16
|
+
OPOST off) the kernel TTY no longer maps ``\\n`` → ``\\r\\n``, so each
|
|
17
|
+
line stair-steps right of where the previous one ended.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from rich.console import Console, RenderableType
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InPlaceRenderer:
|
|
26
|
+
"""Erase-and-redraw renderer for picker / dialog event loops.
|
|
27
|
+
|
|
28
|
+
Each :meth:`render` call rewinds over the previous frame using
|
|
29
|
+
``CR + erase-line + (UP + erase-line) × (h-1)``, then writes the new
|
|
30
|
+
content. :meth:`clear` runs the same erase to wipe the last frame on
|
|
31
|
+
exit. Safe under raw mode: bare ``\\n`` in Rich's output is
|
|
32
|
+
translated to ``\\r\\n`` so each line returns to column 0.
|
|
33
|
+
|
|
34
|
+
Optional ``cursor_to`` lets callers park the hardware cursor inside
|
|
35
|
+
the frame (e.g. inside a search box) after drawing — the renderer
|
|
36
|
+
walks the cursor back to the last row before the next erase, so the
|
|
37
|
+
erase math stays correct.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, console: Console) -> None:
|
|
41
|
+
self._console = console
|
|
42
|
+
self._last_height = 0
|
|
43
|
+
# Where the cursor currently sits within the last rendered frame
|
|
44
|
+
# (0-indexed from the top). After a plain :meth:`render` the
|
|
45
|
+
# cursor is on the bottom row; if ``cursor_to`` was passed, it
|
|
46
|
+
# may be parked higher up.
|
|
47
|
+
self._cursor_row = 0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def last_height(self) -> int:
|
|
51
|
+
return self._last_height
|
|
52
|
+
|
|
53
|
+
def render(
|
|
54
|
+
self,
|
|
55
|
+
renderable: RenderableType,
|
|
56
|
+
cursor_to: tuple[int, int] | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Erase the previous frame (if any) and draw the new one.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
renderable: Rich renderable to draw.
|
|
62
|
+
cursor_to: Optional ``(row, col)`` offset (both 0-indexed,
|
|
63
|
+
relative to the top-left of the rendered frame) where the
|
|
64
|
+
terminal cursor should land after drawing. Useful for
|
|
65
|
+
pickers that want the hardware cursor to sit inside their
|
|
66
|
+
search box rather than at the bottom of the frame.
|
|
67
|
+
"""
|
|
68
|
+
with self._console.capture() as capture:
|
|
69
|
+
self._console.print(renderable)
|
|
70
|
+
text = capture.get().replace("\r\n", "\n").replace("\n", "\r\n")
|
|
71
|
+
lines = text.split("\r\n")
|
|
72
|
+
if lines and lines[-1] == "":
|
|
73
|
+
lines.pop()
|
|
74
|
+
|
|
75
|
+
out = self._console.file
|
|
76
|
+
if self._last_height > 0:
|
|
77
|
+
self._erase_previous(out)
|
|
78
|
+
if lines:
|
|
79
|
+
out.write("\r\n".join(lines))
|
|
80
|
+
new_height = len(lines)
|
|
81
|
+
|
|
82
|
+
# Cursor is now on the last drawn row; place it elsewhere only if
|
|
83
|
+
# the caller asked for it.
|
|
84
|
+
self._cursor_row = max(0, new_height - 1)
|
|
85
|
+
if cursor_to is not None and new_height > 0:
|
|
86
|
+
target_row, target_col = cursor_to
|
|
87
|
+
target_row = max(0, min(target_row, new_height - 1))
|
|
88
|
+
target_col = max(0, target_col)
|
|
89
|
+
out.write("\r")
|
|
90
|
+
rows_up = (new_height - 1) - target_row
|
|
91
|
+
if rows_up > 0:
|
|
92
|
+
out.write(f"\x1b[{rows_up}A")
|
|
93
|
+
if target_col > 0:
|
|
94
|
+
out.write(f"\x1b[{target_col}C")
|
|
95
|
+
self._cursor_row = target_row
|
|
96
|
+
|
|
97
|
+
out.flush()
|
|
98
|
+
self._last_height = new_height
|
|
99
|
+
|
|
100
|
+
def clear(self) -> None:
|
|
101
|
+
"""Erase the last rendered frame.
|
|
102
|
+
|
|
103
|
+
Idempotent — calling :meth:`clear` twice is a no-op.
|
|
104
|
+
"""
|
|
105
|
+
if self._last_height <= 0:
|
|
106
|
+
return
|
|
107
|
+
out = self._console.file
|
|
108
|
+
try:
|
|
109
|
+
self._erase_previous(out)
|
|
110
|
+
out.flush()
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
self._last_height = 0
|
|
114
|
+
self._cursor_row = 0
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Internal helpers
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _erase_previous(self, out) -> None:
|
|
121
|
+
"""Walk the cursor back to the last row of the previous frame
|
|
122
|
+
(if it was parked higher by ``cursor_to``) and erase every row.
|
|
123
|
+
"""
|
|
124
|
+
rows_down = (self._last_height - 1) - self._cursor_row
|
|
125
|
+
if rows_down > 0:
|
|
126
|
+
out.write(f"\x1b[{rows_down}B")
|
|
127
|
+
out.write("\r\x1b[2K")
|
|
128
|
+
for _ in range(self._last_height - 1):
|
|
129
|
+
out.write("\x1b[A\x1b[2K")
|