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,308 @@
|
|
|
1
|
+
"""Fuzzy search selection component."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, cast
|
|
7
|
+
|
|
8
|
+
from rich.console import Group, RenderableType
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from iac_code.i18n import _
|
|
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 PickerItem:
|
|
18
|
+
"""An item that can be displayed and selected in the FuzzyPicker."""
|
|
19
|
+
|
|
20
|
+
key: str
|
|
21
|
+
display: str
|
|
22
|
+
description: str = ""
|
|
23
|
+
metadata: Any = None
|
|
24
|
+
filter_text: str = ""
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if not self.filter_text:
|
|
28
|
+
self.filter_text = self.display
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fuzzy_match(query: str, text: str) -> float | None:
|
|
32
|
+
"""Subsequence matching with scoring.
|
|
33
|
+
|
|
34
|
+
Returns None if no match.
|
|
35
|
+
Scoring:
|
|
36
|
+
+1 per matched character
|
|
37
|
+
+0.5 * consecutive_run for consecutive matches
|
|
38
|
+
+2.0 for prefix match
|
|
39
|
+
+1.5 for word boundary match
|
|
40
|
+
"""
|
|
41
|
+
if not query:
|
|
42
|
+
return 0.0
|
|
43
|
+
|
|
44
|
+
query_lower = query.lower()
|
|
45
|
+
text_lower = text.lower()
|
|
46
|
+
|
|
47
|
+
# Quick rejection: all query chars must exist in text
|
|
48
|
+
for ch in query_lower:
|
|
49
|
+
if ch not in text_lower:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
# Greedy subsequence matching with scoring
|
|
53
|
+
score = 0.0
|
|
54
|
+
ti = 0 # text index
|
|
55
|
+
qi = 0 # query index
|
|
56
|
+
consecutive = 0
|
|
57
|
+
last_ti = -1
|
|
58
|
+
prefix_bonus_given = False
|
|
59
|
+
|
|
60
|
+
while qi < len(query_lower) and ti < len(text_lower):
|
|
61
|
+
if text_lower[ti] == query_lower[qi]:
|
|
62
|
+
score += 1.0
|
|
63
|
+
|
|
64
|
+
# Prefix bonus: first query char matches at position 0
|
|
65
|
+
if ti == 0 and qi == 0 and not prefix_bonus_given:
|
|
66
|
+
score += 2.0
|
|
67
|
+
prefix_bonus_given = True
|
|
68
|
+
|
|
69
|
+
# Word boundary bonus: text char is at start or preceded by space/separator
|
|
70
|
+
if ti == 0 or text_lower[ti - 1] in (" ", "_", "-", "/", "."):
|
|
71
|
+
score += 1.5
|
|
72
|
+
|
|
73
|
+
# Consecutive bonus
|
|
74
|
+
if last_ti == ti - 1:
|
|
75
|
+
consecutive += 1
|
|
76
|
+
score += 0.5 * consecutive
|
|
77
|
+
else:
|
|
78
|
+
consecutive = 0
|
|
79
|
+
|
|
80
|
+
last_ti = ti
|
|
81
|
+
qi += 1
|
|
82
|
+
ti += 1
|
|
83
|
+
|
|
84
|
+
if qi < len(query_lower):
|
|
85
|
+
# Did not match all query characters
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
return score
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FuzzyPicker:
|
|
92
|
+
"""A fuzzy-search selection component.
|
|
93
|
+
|
|
94
|
+
Combines a SearchBox with a filtered, scrollable list of items.
|
|
95
|
+
Items can be a static list (filtered in-memory via fuzzy_match) or
|
|
96
|
+
a callable that returns items dynamically (for async/server search).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
items: list[PickerItem] | Callable[[str], list[PickerItem]],
|
|
102
|
+
on_select: Callable[[PickerItem], None],
|
|
103
|
+
on_cancel: Callable[[], None] | None = None,
|
|
104
|
+
title: str = "",
|
|
105
|
+
placeholder: str = "",
|
|
106
|
+
render_preview: Callable[[PickerItem], RenderableType] | None = None,
|
|
107
|
+
visible_count: int = 10,
|
|
108
|
+
debounce_ms: int = 0,
|
|
109
|
+
empty_message: str = "",
|
|
110
|
+
tab_action: Callable[[], None] | None = None,
|
|
111
|
+
keybinding_manager: object | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self._items = items
|
|
114
|
+
self._on_select = on_select
|
|
115
|
+
self._on_cancel = on_cancel
|
|
116
|
+
self._title = title
|
|
117
|
+
self._render_preview = render_preview
|
|
118
|
+
self._visible_count = visible_count
|
|
119
|
+
self._debounce_ms = debounce_ms
|
|
120
|
+
self._empty_message = empty_message or _("No matches found")
|
|
121
|
+
self._tab_action = tab_action
|
|
122
|
+
self._km = keybinding_manager
|
|
123
|
+
|
|
124
|
+
self._search_box = SearchBox(
|
|
125
|
+
placeholder=placeholder,
|
|
126
|
+
on_change=self._on_query_change,
|
|
127
|
+
)
|
|
128
|
+
self._filtered_items: list[PickerItem] = []
|
|
129
|
+
self._focused_index: int = 0
|
|
130
|
+
self._visible_from: int = 0
|
|
131
|
+
self._done: bool = False
|
|
132
|
+
self._result: PickerItem | None = None
|
|
133
|
+
|
|
134
|
+
# Initial population
|
|
135
|
+
self._update_filter("")
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Public API
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def run(self) -> PickerItem | None:
|
|
142
|
+
"""Blocking mode: run an event loop and return selected item or None."""
|
|
143
|
+
from rich.cells import cell_len
|
|
144
|
+
from rich.console import Console
|
|
145
|
+
|
|
146
|
+
from iac_code.ui.core.in_place_render import InPlaceRenderer
|
|
147
|
+
from iac_code.ui.core.raw_input import RawInputCapture
|
|
148
|
+
|
|
149
|
+
renderer = InPlaceRenderer(Console())
|
|
150
|
+
self._done = False
|
|
151
|
+
self._result = None
|
|
152
|
+
|
|
153
|
+
def cursor_pos() -> tuple[int, int]:
|
|
154
|
+
# Search box is the first rendered row; ``"> "`` is 2 cells.
|
|
155
|
+
sb = self._search_box
|
|
156
|
+
col = 2 if not sb.value else 2 + cell_len(sb.value[: sb.cursor])
|
|
157
|
+
return (0, col)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
with RawInputCapture() as cap:
|
|
161
|
+
while not self._done:
|
|
162
|
+
renderer.render(self.render(), cursor_to=cursor_pos())
|
|
163
|
+
key_event = cap.read_key(timeout=0.1)
|
|
164
|
+
if key_event is not None:
|
|
165
|
+
self.handle_key(key_event)
|
|
166
|
+
finally:
|
|
167
|
+
renderer.clear()
|
|
168
|
+
|
|
169
|
+
return self._result
|
|
170
|
+
|
|
171
|
+
def handle_key(self, key_event: KeyEvent) -> bool:
|
|
172
|
+
"""Handle a key event. Returns True if consumed."""
|
|
173
|
+
key = key_event.key
|
|
174
|
+
ctrl = key_event.ctrl
|
|
175
|
+
|
|
176
|
+
if key == "up" or (ctrl and key == "p"):
|
|
177
|
+
self._move_focus(-1)
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
if key == "down" or (ctrl and key == "n"):
|
|
181
|
+
self._move_focus(1)
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
if key == "pageup":
|
|
185
|
+
self._move_focus(-self._visible_count)
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
if key == "pagedown":
|
|
189
|
+
self._move_focus(self._visible_count)
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
if key == "enter":
|
|
193
|
+
if self._filtered_items:
|
|
194
|
+
item = self._filtered_items[self._focused_index]
|
|
195
|
+
self._result = item
|
|
196
|
+
self._done = True
|
|
197
|
+
self._on_select(item)
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
if key == "escape":
|
|
201
|
+
self._done = True
|
|
202
|
+
if self._on_cancel is not None:
|
|
203
|
+
self._on_cancel()
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
if key == "tab" and self._tab_action is not None:
|
|
207
|
+
self._tab_action()
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
# Delegate to search box
|
|
211
|
+
consumed = self._search_box.handle_key(key_event)
|
|
212
|
+
return consumed
|
|
213
|
+
|
|
214
|
+
def render(self) -> RenderableType:
|
|
215
|
+
"""Render search box + item list + match count + optional preview."""
|
|
216
|
+
parts: list[RenderableType] = []
|
|
217
|
+
|
|
218
|
+
# Search box
|
|
219
|
+
parts.append(self._search_box.render())
|
|
220
|
+
|
|
221
|
+
# Item list
|
|
222
|
+
if not self._filtered_items:
|
|
223
|
+
parts.append(Text(self._empty_message, style="dim"))
|
|
224
|
+
else:
|
|
225
|
+
visible = self._filtered_items[self._visible_from : self._visible_from + self._visible_count]
|
|
226
|
+
for i, item in enumerate(visible):
|
|
227
|
+
abs_i = self._visible_from + i
|
|
228
|
+
is_focused = abs_i == self._focused_index
|
|
229
|
+
parts.append(self._render_item(item, is_focused))
|
|
230
|
+
|
|
231
|
+
# Match count
|
|
232
|
+
parts.append(Text(self._get_match_count_text(), style="dim"))
|
|
233
|
+
|
|
234
|
+
# Preview panel
|
|
235
|
+
if (
|
|
236
|
+
self._render_preview is not None
|
|
237
|
+
and self._filtered_items
|
|
238
|
+
and 0 <= self._focused_index < len(self._filtered_items)
|
|
239
|
+
):
|
|
240
|
+
preview = self._render_preview(self._filtered_items[self._focused_index])
|
|
241
|
+
parts.append(preview)
|
|
242
|
+
|
|
243
|
+
return Group(*parts)
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Internal helpers
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def _on_query_change(self, query: str) -> None:
|
|
250
|
+
self._update_filter(query)
|
|
251
|
+
|
|
252
|
+
def _update_filter(self, query: str) -> None:
|
|
253
|
+
"""Update _filtered_items based on query. Reset focus to 0."""
|
|
254
|
+
if callable(self._items):
|
|
255
|
+
# Dynamic: call the function directly
|
|
256
|
+
item_factory = cast(Callable[[str], list[PickerItem]], self._items)
|
|
257
|
+
self._filtered_items = item_factory(query)
|
|
258
|
+
else:
|
|
259
|
+
# Static list: apply fuzzy matching
|
|
260
|
+
static_items = self._items
|
|
261
|
+
if not query:
|
|
262
|
+
self._filtered_items = list(static_items)
|
|
263
|
+
else:
|
|
264
|
+
scored: list[tuple[float, PickerItem]] = []
|
|
265
|
+
for item in static_items:
|
|
266
|
+
score = fuzzy_match(query, item.filter_text)
|
|
267
|
+
if score is not None:
|
|
268
|
+
scored.append((score, item))
|
|
269
|
+
# Sort by score descending
|
|
270
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
271
|
+
self._filtered_items = [item for _, item in scored]
|
|
272
|
+
|
|
273
|
+
self._focused_index = 0
|
|
274
|
+
self._visible_from = 0
|
|
275
|
+
|
|
276
|
+
def _move_focus(self, delta: int) -> None:
|
|
277
|
+
"""Move focus by delta, clamping at edges."""
|
|
278
|
+
n = len(self._filtered_items)
|
|
279
|
+
if n == 0:
|
|
280
|
+
return
|
|
281
|
+
new_idx = max(0, min(self._focused_index + delta, n - 1))
|
|
282
|
+
self._focused_index = new_idx
|
|
283
|
+
self._update_scroll()
|
|
284
|
+
|
|
285
|
+
def _update_scroll(self) -> None:
|
|
286
|
+
"""Adjust visible_from so focused item is in view."""
|
|
287
|
+
if self._focused_index < self._visible_from:
|
|
288
|
+
self._visible_from = self._focused_index
|
|
289
|
+
elif self._focused_index >= self._visible_from + self._visible_count:
|
|
290
|
+
self._visible_from = self._focused_index - self._visible_count + 1
|
|
291
|
+
|
|
292
|
+
def _get_match_count_text(self) -> str:
|
|
293
|
+
total = len(self._items) if isinstance(self._items, list) else len(self._filtered_items)
|
|
294
|
+
matched = len(self._filtered_items)
|
|
295
|
+
if isinstance(self._items, list):
|
|
296
|
+
return f"{matched}/{total} matches"
|
|
297
|
+
return f"{matched} results"
|
|
298
|
+
|
|
299
|
+
def _render_item(self, item: PickerItem, is_focused: bool) -> Text:
|
|
300
|
+
text = Text()
|
|
301
|
+
if is_focused:
|
|
302
|
+
text.append("❯ ", style="bold cyan")
|
|
303
|
+
else:
|
|
304
|
+
text.append(" ")
|
|
305
|
+
text.append(item.display, style="bold" if is_focused else "")
|
|
306
|
+
if item.description:
|
|
307
|
+
text.append(f" {item.description}", style="dim")
|
|
308
|
+
return text
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""ProgressBar component using Rich Text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProgressBar:
|
|
9
|
+
"""A simple terminal progress bar rendered as Rich Text.
|
|
10
|
+
|
|
11
|
+
Format: "████░░░░ 65%"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
total: int = 100,
|
|
17
|
+
completed: int = 0,
|
|
18
|
+
width: int = 40,
|
|
19
|
+
filled_char: str = "█",
|
|
20
|
+
empty_char: str = "░",
|
|
21
|
+
style: str = "blue",
|
|
22
|
+
) -> None:
|
|
23
|
+
self.total = total
|
|
24
|
+
self._completed = completed
|
|
25
|
+
self.width = width
|
|
26
|
+
self.filled_char = filled_char
|
|
27
|
+
self.empty_char = empty_char
|
|
28
|
+
self.style = style
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Update
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def update(self, completed: int) -> None:
|
|
35
|
+
"""Set the completed count."""
|
|
36
|
+
self._completed = completed
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Rendering
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def render(self) -> Text:
|
|
43
|
+
"""Render the progress bar as Rich Text."""
|
|
44
|
+
ratio = self._completed / self.total if self.total > 0 else 0.0
|
|
45
|
+
ratio = max(0.0, min(1.0, ratio))
|
|
46
|
+
filled = round(self.width * ratio)
|
|
47
|
+
empty = self.width - filled
|
|
48
|
+
pct = int(ratio * 100)
|
|
49
|
+
|
|
50
|
+
text = Text()
|
|
51
|
+
text.append(self.filled_char * filled, style=self.style)
|
|
52
|
+
text.append(self.empty_char * empty, style="dim")
|
|
53
|
+
text.append(f" {pct}%")
|
|
54
|
+
return text
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Single-line text input component with editing operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from iac_code.ui.core.key_event import KeyEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SearchBox:
|
|
13
|
+
"""A single-line text input component with cursor and editing operations.
|
|
14
|
+
|
|
15
|
+
Editing operations supported:
|
|
16
|
+
- Printable character insertion
|
|
17
|
+
- Backspace (delete before cursor), Delete (delete after cursor)
|
|
18
|
+
- Left / Right cursor movement
|
|
19
|
+
- Home / Ctrl+A (move to start), End / Ctrl+E (move to end)
|
|
20
|
+
- Ctrl+K (delete to end of line)
|
|
21
|
+
- Ctrl+U (delete to start of line)
|
|
22
|
+
- Ctrl+W (delete previous word)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
placeholder: str = "",
|
|
28
|
+
initial_value: str = "",
|
|
29
|
+
on_change: Callable[[str], None] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._placeholder = placeholder
|
|
32
|
+
self._text: list[str] = list(initial_value)
|
|
33
|
+
self._cursor: int = len(self._text)
|
|
34
|
+
self._on_change = on_change
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
# Properties
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def value(self) -> str:
|
|
42
|
+
return "".join(self._text)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def cursor(self) -> int:
|
|
46
|
+
return self._cursor
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# Key handling
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def handle_key(self, key_event: KeyEvent) -> bool:
|
|
53
|
+
"""Handle a key event. Returns True if consumed, False otherwise."""
|
|
54
|
+
key = key_event.key
|
|
55
|
+
ctrl = key_event.ctrl
|
|
56
|
+
|
|
57
|
+
old_value = self.value
|
|
58
|
+
|
|
59
|
+
# --- Navigation (no text change) ---
|
|
60
|
+
if key == "left":
|
|
61
|
+
if self._cursor > 0:
|
|
62
|
+
self._cursor -= 1
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
if key == "right":
|
|
66
|
+
if self._cursor < len(self._text):
|
|
67
|
+
self._cursor += 1
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
if key == "home" or (ctrl and key == "a"):
|
|
71
|
+
self._cursor = 0
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
if key == "end" or (ctrl and key == "e"):
|
|
75
|
+
self._cursor = len(self._text)
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
# --- Deletion ---
|
|
79
|
+
if key == "backspace":
|
|
80
|
+
if self._cursor > 0:
|
|
81
|
+
self._cursor -= 1
|
|
82
|
+
self._text.pop(self._cursor)
|
|
83
|
+
self._notify(old_value)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
if key == "delete":
|
|
87
|
+
if self._cursor < len(self._text):
|
|
88
|
+
self._text.pop(self._cursor)
|
|
89
|
+
self._notify(old_value)
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
if ctrl and key == "k":
|
|
93
|
+
del self._text[self._cursor :]
|
|
94
|
+
self._notify(old_value)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
if ctrl and key == "u":
|
|
98
|
+
del self._text[: self._cursor]
|
|
99
|
+
self._cursor = 0
|
|
100
|
+
self._notify(old_value)
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
if ctrl and key == "w":
|
|
104
|
+
# Delete backwards one "token": either a run of spaces or a run of
|
|
105
|
+
# non-space characters immediately before the cursor.
|
|
106
|
+
pos = self._cursor
|
|
107
|
+
if pos > 0 and self._text[pos - 1] == " ":
|
|
108
|
+
# preceding char is a space: delete the run of spaces
|
|
109
|
+
while pos > 0 and self._text[pos - 1] == " ":
|
|
110
|
+
pos -= 1
|
|
111
|
+
else:
|
|
112
|
+
# preceding char is a non-space: delete the word
|
|
113
|
+
while pos > 0 and self._text[pos - 1] != " ":
|
|
114
|
+
pos -= 1
|
|
115
|
+
del self._text[pos : self._cursor]
|
|
116
|
+
self._cursor = pos
|
|
117
|
+
self._notify(old_value)
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# --- Character insertion ---
|
|
121
|
+
# Only handle printable characters (single char, no ctrl modifier)
|
|
122
|
+
char = key_event.char
|
|
123
|
+
if not ctrl and len(char) == 1 and char.isprintable():
|
|
124
|
+
self._text.insert(self._cursor, char)
|
|
125
|
+
self._cursor += 1
|
|
126
|
+
self._notify(old_value)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Rendering
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def render(self) -> Text:
|
|
136
|
+
"""Render the search box as a Rich Text object.
|
|
137
|
+
|
|
138
|
+
Format: "> text_" where _ represents the cursor position.
|
|
139
|
+
"""
|
|
140
|
+
text = Text()
|
|
141
|
+
text.append("> ", style="bold cyan")
|
|
142
|
+
|
|
143
|
+
value = self.value
|
|
144
|
+
if not value and self._placeholder:
|
|
145
|
+
text.append(self._placeholder, style="dim")
|
|
146
|
+
else:
|
|
147
|
+
before = value[: self._cursor]
|
|
148
|
+
at = value[self._cursor] if self._cursor < len(value) else " "
|
|
149
|
+
after = value[self._cursor + 1 :] if self._cursor < len(value) else ""
|
|
150
|
+
text.append(before)
|
|
151
|
+
text.append(at, style="reverse")
|
|
152
|
+
text.append(after)
|
|
153
|
+
|
|
154
|
+
return text
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Internal helpers
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _notify(self, old_value: str) -> None:
|
|
161
|
+
"""Call on_change callback if value changed."""
|
|
162
|
+
if self._on_change is not None:
|
|
163
|
+
new_value = self.value
|
|
164
|
+
if new_value != old_value:
|
|
165
|
+
self._on_change(new_value)
|