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,280 @@
|
|
|
1
|
+
"""ModelPicker dialog with effort level support.
|
|
2
|
+
|
|
3
|
+
Thinking-mode capability is defined per-model in
|
|
4
|
+
``iac_code.providers.thinking``. This module re-exports the familiar
|
|
5
|
+
``EffortLevel`` / ``EFFORT_SYMBOLS`` names so callers stay compatible while
|
|
6
|
+
the underlying configuration is shared with the provider layer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from rich.console import Group, RenderableType
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from iac_code.providers.thinking import (
|
|
17
|
+
EFFORT_ORDER as _EFFORT_ORDER_SHARED,
|
|
18
|
+
)
|
|
19
|
+
from iac_code.providers.thinking import (
|
|
20
|
+
EFFORT_SYMBOLS,
|
|
21
|
+
EffortLevel,
|
|
22
|
+
get_thinking_spec,
|
|
23
|
+
)
|
|
24
|
+
from iac_code.ui.core.key_event import KeyEvent
|
|
25
|
+
|
|
26
|
+
_EFFORT_ORDER = list(_EFFORT_ORDER_SHARED)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ModelPicker:
|
|
30
|
+
"""A picker dialog for selecting a model and optionally adjusting effort level.
|
|
31
|
+
|
|
32
|
+
Navigation:
|
|
33
|
+
↑/↓ move focus (skipping group headers).
|
|
34
|
+
←/→ cycle effort level for the focused model (no-op if unsupported).
|
|
35
|
+
Enter confirm selection.
|
|
36
|
+
Escape cancel.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
initial_model: str,
|
|
42
|
+
configured_providers: list[str],
|
|
43
|
+
on_select: Callable[[str, EffortLevel | None], None],
|
|
44
|
+
on_cancel: Callable[[], None],
|
|
45
|
+
keybinding_manager: object | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._initial_model = initial_model
|
|
48
|
+
self._configured_providers = configured_providers
|
|
49
|
+
self._on_select = on_select
|
|
50
|
+
self._on_cancel = on_cancel
|
|
51
|
+
self._km = keybinding_manager
|
|
52
|
+
|
|
53
|
+
# Build flat item list; each model item carries {"model": ..., "provider_key": ...}
|
|
54
|
+
self._items: list[dict] = self._build_items()
|
|
55
|
+
|
|
56
|
+
# Initialize effort levels from defaults for each (provider, model) pair
|
|
57
|
+
# in the visible items. Models without effort default to None and are
|
|
58
|
+
# never used downstream.
|
|
59
|
+
self._efforts: dict[tuple[str, str], EffortLevel] = {}
|
|
60
|
+
for item in self._items:
|
|
61
|
+
if "model" not in item:
|
|
62
|
+
continue
|
|
63
|
+
spec = get_thinking_spec(item["provider_key"], item["model"])
|
|
64
|
+
if spec.default_effort is not None:
|
|
65
|
+
self._efforts[(item["provider_key"], item["model"])] = spec.default_effort
|
|
66
|
+
|
|
67
|
+
# Set initial focus on initial_model (or first selectable)
|
|
68
|
+
self._focused_index: int = 0
|
|
69
|
+
self._set_initial_focus()
|
|
70
|
+
|
|
71
|
+
self._done: bool = False
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Public API
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def run(self) -> tuple[str, EffortLevel | None] | None:
|
|
78
|
+
"""Blocking run via Dialog. Returns (model, effort) or None on cancel."""
|
|
79
|
+
from iac_code.ui.components.dialog import Dialog
|
|
80
|
+
from iac_code.ui.keybindings.manager import KeybindingManager
|
|
81
|
+
|
|
82
|
+
km: KeybindingManager = self._km if isinstance(self._km, KeybindingManager) else KeybindingManager()
|
|
83
|
+
|
|
84
|
+
result_holder: list[tuple[str, EffortLevel | None]] = []
|
|
85
|
+
cancelled = [False]
|
|
86
|
+
|
|
87
|
+
def _on_select(model: str, effort: EffortLevel | None) -> None:
|
|
88
|
+
result_holder.append((model, effort))
|
|
89
|
+
|
|
90
|
+
def _on_cancel() -> None:
|
|
91
|
+
cancelled[0] = True
|
|
92
|
+
|
|
93
|
+
orig_on_select = self._on_select
|
|
94
|
+
orig_on_cancel = self._on_cancel
|
|
95
|
+
self._on_select = _on_select
|
|
96
|
+
self._on_cancel = _on_cancel
|
|
97
|
+
|
|
98
|
+
dialog = Dialog(
|
|
99
|
+
title="Select Model",
|
|
100
|
+
keybinding_manager=km,
|
|
101
|
+
on_cancel=_on_cancel,
|
|
102
|
+
footer_hints=[
|
|
103
|
+
("↑↓", "navigate"),
|
|
104
|
+
("←→", "effort"),
|
|
105
|
+
("Enter", "select"),
|
|
106
|
+
("Esc", "cancel"),
|
|
107
|
+
],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
dialog.run(
|
|
111
|
+
body_builder=self.render,
|
|
112
|
+
key_handler=self.handle_key,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
self._on_select = orig_on_select
|
|
116
|
+
self._on_cancel = orig_on_cancel
|
|
117
|
+
|
|
118
|
+
if cancelled[0]:
|
|
119
|
+
return None
|
|
120
|
+
return result_holder[0] if result_holder else None
|
|
121
|
+
|
|
122
|
+
def handle_key(self, key_event: KeyEvent) -> bool:
|
|
123
|
+
"""Handle a key event. Returns True if consumed."""
|
|
124
|
+
key = key_event.key
|
|
125
|
+
|
|
126
|
+
if key == "up":
|
|
127
|
+
self._move_focus(-1)
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
if key == "down":
|
|
131
|
+
self._move_focus(1)
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
if key == "left":
|
|
135
|
+
pair = self._focused_pair()
|
|
136
|
+
if pair is not None:
|
|
137
|
+
self._cycle_effort(pair, -1)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
if key == "right":
|
|
141
|
+
pair = self._focused_pair()
|
|
142
|
+
if pair is not None:
|
|
143
|
+
self._cycle_effort(pair, 1)
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
if key == "enter":
|
|
147
|
+
pair = self._focused_pair()
|
|
148
|
+
if pair is not None:
|
|
149
|
+
provider_key, model = pair
|
|
150
|
+
spec = get_thinking_spec(provider_key, model)
|
|
151
|
+
effort = self._efforts.get(pair) if spec.supports_effort else None
|
|
152
|
+
self._on_select(model, effort)
|
|
153
|
+
self._done = True
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
if key == "escape":
|
|
157
|
+
self._on_cancel()
|
|
158
|
+
self._done = True
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def render(self) -> RenderableType:
|
|
164
|
+
"""Render the model picker list."""
|
|
165
|
+
lines: list[RenderableType] = []
|
|
166
|
+
for i, item in enumerate(self._items):
|
|
167
|
+
if "header" in item:
|
|
168
|
+
text = Text()
|
|
169
|
+
text.append(item["header"], style="bold")
|
|
170
|
+
lines.append(text)
|
|
171
|
+
else:
|
|
172
|
+
is_focused = i == self._focused_index
|
|
173
|
+
lines.append(self._render_model_line(item, is_focused))
|
|
174
|
+
return Group(*lines)
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Internal helpers
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _build_items(self) -> list[dict]:
|
|
181
|
+
"""Build flat list of header and model items for configured providers."""
|
|
182
|
+
from iac_code.commands.auth import PROVIDERS
|
|
183
|
+
|
|
184
|
+
items: list[dict] = []
|
|
185
|
+
for provider in PROVIDERS:
|
|
186
|
+
key = str(provider["key_name"])
|
|
187
|
+
if key not in self._configured_providers:
|
|
188
|
+
continue
|
|
189
|
+
items.append({"header": str(provider["display_name"])})
|
|
190
|
+
for model in list(provider["models"]):
|
|
191
|
+
items.append({"model": model, "provider_key": key})
|
|
192
|
+
return items
|
|
193
|
+
|
|
194
|
+
def _set_initial_focus(self) -> None:
|
|
195
|
+
"""Set focus to initial_model, or first selectable item."""
|
|
196
|
+
for i, item in enumerate(self._items):
|
|
197
|
+
if item.get("model") == self._initial_model:
|
|
198
|
+
self._focused_index = i
|
|
199
|
+
return
|
|
200
|
+
# Fall back to first selectable
|
|
201
|
+
for i, item in enumerate(self._items):
|
|
202
|
+
if "model" in item:
|
|
203
|
+
self._focused_index = i
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
def _focused_pair(self) -> tuple[str, str] | None:
|
|
207
|
+
"""Return (provider_key, model) for the focused item, or None."""
|
|
208
|
+
item = self._items[self._focused_index] if self._items else None
|
|
209
|
+
if item is None or "model" not in item:
|
|
210
|
+
return None
|
|
211
|
+
return item["provider_key"], item["model"]
|
|
212
|
+
|
|
213
|
+
def _focused_model(self) -> str | None:
|
|
214
|
+
"""Return the model name at the current focused index, or None."""
|
|
215
|
+
pair = self._focused_pair()
|
|
216
|
+
return pair[1] if pair is not None else None
|
|
217
|
+
|
|
218
|
+
def _move_focus(self, direction: int) -> None:
|
|
219
|
+
"""Move focus by direction (+1 or -1), skipping headers."""
|
|
220
|
+
n = len(self._items)
|
|
221
|
+
if n == 0:
|
|
222
|
+
return
|
|
223
|
+
current = self._focused_index
|
|
224
|
+
step = 1 if direction > 0 else -1
|
|
225
|
+
idx = current + step
|
|
226
|
+
while 0 <= idx < n:
|
|
227
|
+
if "model" in self._items[idx]:
|
|
228
|
+
self._focused_index = idx
|
|
229
|
+
return
|
|
230
|
+
idx += step
|
|
231
|
+
# No selectable found — stay at current
|
|
232
|
+
|
|
233
|
+
def _cycle_effort(self, pair: tuple[str, str], direction: int) -> None:
|
|
234
|
+
"""Cycle effort level for a (provider_key, model) pair by direction (+1 or -1).
|
|
235
|
+
|
|
236
|
+
Cycles through the model's explicit allowed list, so families with
|
|
237
|
+
gaps (e.g. DeepSeek → ``[HIGH, MAX]``) skip unsupported levels.
|
|
238
|
+
"""
|
|
239
|
+
provider_key, model = pair
|
|
240
|
+
spec = get_thinking_spec(provider_key, model)
|
|
241
|
+
if not spec.supports_effort:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
allowed = spec.allowed_efforts
|
|
245
|
+
current = self._efforts.get(pair, spec.default_effort)
|
|
246
|
+
try:
|
|
247
|
+
current_idx = allowed.index(current)
|
|
248
|
+
except ValueError:
|
|
249
|
+
current_idx = allowed.index(spec.default_effort)
|
|
250
|
+
new_idx = max(0, min(len(allowed) - 1, current_idx + direction))
|
|
251
|
+
self._efforts[pair] = allowed[new_idx]
|
|
252
|
+
|
|
253
|
+
def _render_model_line(self, item: dict, is_focused: bool) -> Text:
|
|
254
|
+
"""Render a single model line."""
|
|
255
|
+
model = item["model"]
|
|
256
|
+
provider_key = item["provider_key"]
|
|
257
|
+
text = Text()
|
|
258
|
+
|
|
259
|
+
# Focus indicator
|
|
260
|
+
if is_focused:
|
|
261
|
+
text.append("> ", style="bold cyan")
|
|
262
|
+
else:
|
|
263
|
+
text.append(" ")
|
|
264
|
+
|
|
265
|
+
# Model name
|
|
266
|
+
style = "bold" if is_focused else ""
|
|
267
|
+
text.append(model, style=style)
|
|
268
|
+
|
|
269
|
+
# Current marker
|
|
270
|
+
if model == self._initial_model:
|
|
271
|
+
text.append(" (current)", style="dim green")
|
|
272
|
+
|
|
273
|
+
# Effort symbol for effort-capable models
|
|
274
|
+
spec = get_thinking_spec(provider_key, model)
|
|
275
|
+
if spec.supports_effort and spec.default_effort is not None:
|
|
276
|
+
effort = self._efforts.get((provider_key, model), spec.default_effort)
|
|
277
|
+
symbol = EFFORT_SYMBOLS[effort]
|
|
278
|
+
text.append(f" {symbol}", style="yellow")
|
|
279
|
+
|
|
280
|
+
return text
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""QuickOpen dialog — open files by fuzzy name search (Ctrl+P)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.syntax import Syntax
|
|
10
|
+
|
|
11
|
+
from iac_code.i18n import _
|
|
12
|
+
from iac_code.ui.components.fuzzy_picker import FuzzyPicker, PickerItem
|
|
13
|
+
from iac_code.ui.suggestions.file_provider import _should_exclude_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class QuickOpen:
|
|
17
|
+
"""Dialog for opening files by fuzzy-searching the file tree.
|
|
18
|
+
|
|
19
|
+
Selecting a file returns the absolute path and inserts ``@relative_path``
|
|
20
|
+
into the input.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
root_dir: str,
|
|
26
|
+
on_select: Callable[[str], None],
|
|
27
|
+
on_cancel: Callable[[], None],
|
|
28
|
+
keybinding_manager: object | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._root_dir = os.path.abspath(root_dir)
|
|
31
|
+
self._on_select = on_select
|
|
32
|
+
self._on_cancel = on_cancel
|
|
33
|
+
self._km = keybinding_manager
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Public API
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def run(self) -> str | None:
|
|
40
|
+
"""Open the quick-open picker and return selected file path or None."""
|
|
41
|
+
items = self._build_items()
|
|
42
|
+
result_holder: list[str] = []
|
|
43
|
+
cancelled = [False]
|
|
44
|
+
|
|
45
|
+
def _on_select(item: PickerItem) -> None:
|
|
46
|
+
abs_path: str = item.metadata
|
|
47
|
+
result_holder.append(abs_path)
|
|
48
|
+
self._on_select(f"@{item.display}")
|
|
49
|
+
|
|
50
|
+
def _on_cancel() -> None:
|
|
51
|
+
cancelled[0] = True
|
|
52
|
+
self._on_cancel()
|
|
53
|
+
|
|
54
|
+
picker = FuzzyPicker(
|
|
55
|
+
items=items,
|
|
56
|
+
on_select=_on_select,
|
|
57
|
+
on_cancel=_on_cancel,
|
|
58
|
+
title=_("Open File"),
|
|
59
|
+
placeholder=_("Type to search files..."),
|
|
60
|
+
empty_message=_("No matching files"),
|
|
61
|
+
render_preview=self._render_preview,
|
|
62
|
+
keybinding_manager=self._km,
|
|
63
|
+
)
|
|
64
|
+
picker.run()
|
|
65
|
+
|
|
66
|
+
return result_holder[0] if result_holder else None
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Internal helpers
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _build_items(self) -> list[PickerItem]:
|
|
73
|
+
"""Walk the directory tree and build PickerItems for all files."""
|
|
74
|
+
items: list[PickerItem] = []
|
|
75
|
+
for dirpath, dirnames, filenames in os.walk(self._root_dir):
|
|
76
|
+
# Prune excluded dirs in-place
|
|
77
|
+
dirnames[:] = [d for d in dirnames if not _should_exclude_dir(d)]
|
|
78
|
+
for fname in filenames:
|
|
79
|
+
abs_path = os.path.join(dirpath, fname)
|
|
80
|
+
rel_path = os.path.relpath(abs_path, self._root_dir)
|
|
81
|
+
items.append(
|
|
82
|
+
PickerItem(
|
|
83
|
+
key=f"file:{rel_path}",
|
|
84
|
+
display=rel_path,
|
|
85
|
+
metadata=abs_path,
|
|
86
|
+
filter_text=rel_path,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return items
|
|
90
|
+
|
|
91
|
+
def _render_preview(self, item: PickerItem) -> Panel:
|
|
92
|
+
"""Render first 20 lines of the file with syntax highlighting."""
|
|
93
|
+
abs_path: str = item.metadata
|
|
94
|
+
ext = os.path.splitext(abs_path)[1].lstrip(".")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
with open(abs_path, encoding="utf-8", errors="replace") as fh:
|
|
98
|
+
lines = []
|
|
99
|
+
for i, line in enumerate(fh):
|
|
100
|
+
if i >= 20:
|
|
101
|
+
break
|
|
102
|
+
lines.append(line)
|
|
103
|
+
content = "".join(lines)
|
|
104
|
+
except OSError:
|
|
105
|
+
content = ""
|
|
106
|
+
|
|
107
|
+
syntax = Syntax(content, ext or "text", line_numbers=True)
|
|
108
|
+
return Panel(syntax, title=item.display, border_style="dim")
|