iac-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- iac_code-0.1.0.dist-info/top_level.txt +1 -0
iac_code/ui/spinner.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Spinner for Rich Live — single warm color.
|
|
2
|
+
|
|
3
|
+
Pure data renderer — no timers. The caller controls refresh rate by
|
|
4
|
+
calling render() in a loop.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
# Animation frame sequences
|
|
15
|
+
SPINNER_DOTS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
16
|
+
|
|
17
|
+
# Spinner color — warm orange
|
|
18
|
+
SPINNER_COLOR = "rgb(215,119,87)"
|
|
19
|
+
|
|
20
|
+
# Frame interval for spinner character rotation
|
|
21
|
+
_FRAME_INTERVAL = 0.08
|
|
22
|
+
|
|
23
|
+
# Verbs displayed in spinner while processing (present participle)
|
|
24
|
+
# These are i18n keys — call _() on them before display.
|
|
25
|
+
SPINNER_VERBS = [
|
|
26
|
+
"Thinking",
|
|
27
|
+
"Processing",
|
|
28
|
+
"Working",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Past-tense verbs for turn completion messages (works with "for Xs")
|
|
32
|
+
# These are i18n keys — call _() on them before display.
|
|
33
|
+
COMPLETION_VERBS = [
|
|
34
|
+
"Thought",
|
|
35
|
+
"Processed",
|
|
36
|
+
"Worked",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _format_elapsed(seconds: float) -> str:
|
|
41
|
+
if seconds < 60:
|
|
42
|
+
return f"{seconds:.0f}s"
|
|
43
|
+
minutes = int(seconds // 60)
|
|
44
|
+
secs = int(seconds % 60)
|
|
45
|
+
return f"{minutes}m {secs}s"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def random_spinner_verb() -> str:
|
|
49
|
+
"""Pick a random translated spinner verb for in-progress display."""
|
|
50
|
+
from iac_code.i18n import _
|
|
51
|
+
|
|
52
|
+
# NOTE: explicit _() calls for pybabel extraction
|
|
53
|
+
_("Thinking")
|
|
54
|
+
_("Processing")
|
|
55
|
+
_("Working")
|
|
56
|
+
return _(random.choice(SPINNER_VERBS))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def random_completion_verb() -> str:
|
|
60
|
+
"""Pick a random translated past-tense verb for completion display."""
|
|
61
|
+
from iac_code.i18n import _
|
|
62
|
+
|
|
63
|
+
# NOTE: explicit _() calls for pybabel extraction
|
|
64
|
+
_("Thought")
|
|
65
|
+
_("Processed")
|
|
66
|
+
_("Worked")
|
|
67
|
+
return _(random.choice(COMPLETION_VERBS))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ShimmerSpinner:
|
|
71
|
+
"""Animated spinner with warm single-color style.
|
|
72
|
+
|
|
73
|
+
Usage::
|
|
74
|
+
|
|
75
|
+
spinner = ShimmerSpinner()
|
|
76
|
+
with Live(spinner.render(), console=console, refresh_per_second=20) as live:
|
|
77
|
+
while working:
|
|
78
|
+
live.update(spinner.render())
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, status: str | None = None) -> None:
|
|
82
|
+
self._status = status or (random_spinner_verb() + "...")
|
|
83
|
+
self._start_time = time.monotonic()
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def elapsed(self) -> float:
|
|
87
|
+
"""Seconds since this spinner was created."""
|
|
88
|
+
return time.monotonic() - self._start_time
|
|
89
|
+
|
|
90
|
+
def render(self) -> Text:
|
|
91
|
+
"""Produce a single frame as a Rich Text object."""
|
|
92
|
+
now = time.monotonic()
|
|
93
|
+
elapsed = now - self._start_time
|
|
94
|
+
|
|
95
|
+
# Spinner character (rotates based on wall clock)
|
|
96
|
+
frame_idx = int(now / _FRAME_INTERVAL) % len(SPINNER_DOTS)
|
|
97
|
+
frame = SPINNER_DOTS[frame_idx]
|
|
98
|
+
|
|
99
|
+
text = Text()
|
|
100
|
+
|
|
101
|
+
# Spinner character + status in warm orange
|
|
102
|
+
style = f"bold {SPINNER_COLOR}"
|
|
103
|
+
text.append(f"{frame} ", style=style)
|
|
104
|
+
text.append(self._status, style=style)
|
|
105
|
+
|
|
106
|
+
# Elapsed time in dim
|
|
107
|
+
text.append(f" ({_format_elapsed(elapsed)})", style="dim")
|
|
108
|
+
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
def update_status(self, status: str) -> None:
|
|
112
|
+
self._status = status
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Suggestion aggregator that coordinates multiple providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from iac_code.ui.suggestions.token_extractor import TokenExtractor
|
|
6
|
+
from iac_code.ui.suggestions.types import SuggestionItem, SuggestionProvider
|
|
7
|
+
|
|
8
|
+
OVERLAY_MAX_ITEMS = 5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SuggestionAggregator:
|
|
12
|
+
"""Aggregates suggestions from multiple providers based on the current input."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, providers: list[SuggestionProvider]) -> None:
|
|
15
|
+
self._providers = providers
|
|
16
|
+
self._extractor = TokenExtractor()
|
|
17
|
+
self._suggestions: list[SuggestionItem] = []
|
|
18
|
+
self._selected_index: int = 0
|
|
19
|
+
self._token_text: str = ""
|
|
20
|
+
self._token_start: int = 0
|
|
21
|
+
self._token_end: int = 0
|
|
22
|
+
self._active: bool = False
|
|
23
|
+
|
|
24
|
+
def update(self, text: str, cursor_pos: int) -> None:
|
|
25
|
+
"""Extract a token from text and dispatch to matching providers."""
|
|
26
|
+
token = self._extractor.extract(text, cursor_pos)
|
|
27
|
+
|
|
28
|
+
if token is None:
|
|
29
|
+
self.dismiss()
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
# Find providers that handle this trigger
|
|
33
|
+
matching = [p for p in self._providers if p.trigger == token.trigger]
|
|
34
|
+
|
|
35
|
+
if not matching:
|
|
36
|
+
self.dismiss()
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
# Collect suggestions from all matching providers
|
|
40
|
+
all_items: list[SuggestionItem] = []
|
|
41
|
+
for provider in matching:
|
|
42
|
+
items = provider.provide(token)
|
|
43
|
+
all_items.extend(items)
|
|
44
|
+
|
|
45
|
+
# Sort by score descending
|
|
46
|
+
all_items.sort(key=lambda i: i.score, reverse=True)
|
|
47
|
+
self._suggestions = all_items
|
|
48
|
+
self._selected_index = 0
|
|
49
|
+
self._token_text = token.text
|
|
50
|
+
self._token_start = token.start
|
|
51
|
+
self._token_end = token.end
|
|
52
|
+
self._active = True
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def suggestions(self) -> list[SuggestionItem]:
|
|
56
|
+
"""The full list of suggestions."""
|
|
57
|
+
return self._suggestions
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def visible_suggestions(self) -> list[SuggestionItem]:
|
|
61
|
+
"""The visible window of suggestions (at most OVERLAY_MAX_ITEMS)."""
|
|
62
|
+
if len(self._suggestions) <= OVERLAY_MAX_ITEMS:
|
|
63
|
+
return self._suggestions
|
|
64
|
+
start = self._visible_start
|
|
65
|
+
return self._suggestions[start : start + OVERLAY_MAX_ITEMS]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def visible_selected_index(self) -> int:
|
|
69
|
+
"""The selected index relative to the visible window."""
|
|
70
|
+
return self._selected_index - self._visible_start
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def _visible_start(self) -> int:
|
|
74
|
+
"""Calculate the start of the visible window based on selected index."""
|
|
75
|
+
n = len(self._suggestions)
|
|
76
|
+
if n <= OVERLAY_MAX_ITEMS:
|
|
77
|
+
return 0
|
|
78
|
+
# Keep selected item within the visible window
|
|
79
|
+
start = max(0, self._selected_index - OVERLAY_MAX_ITEMS + 1)
|
|
80
|
+
start = min(start, n - OVERLAY_MAX_ITEMS)
|
|
81
|
+
return start
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def has_more_above(self) -> bool:
|
|
85
|
+
"""Whether there are items above the visible window."""
|
|
86
|
+
return self._visible_start > 0
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def has_more_below(self) -> bool:
|
|
90
|
+
"""Whether there are items below the visible window."""
|
|
91
|
+
n = len(self._suggestions)
|
|
92
|
+
return self._visible_start + OVERLAY_MAX_ITEMS < n
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def ghost_text(self) -> str:
|
|
96
|
+
"""Best match completion minus the typed portion.
|
|
97
|
+
|
|
98
|
+
Returns the part of the top suggestion's completion that extends
|
|
99
|
+
beyond the already-typed text. When the user has typed the full
|
|
100
|
+
command name and the selected item declares an ``arg_hint``, the
|
|
101
|
+
hint is appended as visual-only ghost text (Tab won't insert it).
|
|
102
|
+
"""
|
|
103
|
+
if not self._suggestions:
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
selected = self._suggestions[self._selected_index]
|
|
107
|
+
typed = self._token_text
|
|
108
|
+
completion = selected.completion
|
|
109
|
+
|
|
110
|
+
if not completion.lower().startswith(typed.lower()):
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
base = completion[len(typed) :]
|
|
114
|
+
|
|
115
|
+
arg_hint = selected.arg_hint
|
|
116
|
+
if arg_hint:
|
|
117
|
+
name_only = completion.rstrip()
|
|
118
|
+
if typed.lower() == name_only.lower():
|
|
119
|
+
return base + arg_hint
|
|
120
|
+
return base
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def selected_index(self) -> int:
|
|
124
|
+
"""The currently selected suggestion index."""
|
|
125
|
+
return self._selected_index
|
|
126
|
+
|
|
127
|
+
def move_selection(self, delta: int) -> None:
|
|
128
|
+
"""Move the selection by delta steps, wrapping around."""
|
|
129
|
+
n = len(self._suggestions)
|
|
130
|
+
if n == 0:
|
|
131
|
+
return
|
|
132
|
+
self._selected_index = (self._selected_index + delta) % n
|
|
133
|
+
|
|
134
|
+
def accept_selected(self) -> tuple[str, int, int] | None:
|
|
135
|
+
"""Accept the currently selected suggestion.
|
|
136
|
+
|
|
137
|
+
Returns (completion_text, token_start, token_end) or None if nothing selected.
|
|
138
|
+
"""
|
|
139
|
+
if not self._suggestions or not self._active:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
item = self._suggestions[self._selected_index]
|
|
143
|
+
result = (item.completion, self._token_start, self._token_end)
|
|
144
|
+
self.dismiss()
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
def accept_ghost_text(self) -> tuple[str, int, int] | None:
|
|
148
|
+
"""Accept the ghost text (top suggestion completion).
|
|
149
|
+
|
|
150
|
+
Returns (completion_text, token_start, token_end) or None if no ghost text.
|
|
151
|
+
"""
|
|
152
|
+
if not self._suggestions or not self._active:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
ghost = self.ghost_text
|
|
156
|
+
if not ghost:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
item = self._suggestions[self._selected_index]
|
|
160
|
+
result = (item.completion, self._token_start, self._token_end)
|
|
161
|
+
self.dismiss()
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
def dismiss(self) -> None:
|
|
165
|
+
"""Clear all suggestions and reset state."""
|
|
166
|
+
self._suggestions = []
|
|
167
|
+
self._selected_index = 0
|
|
168
|
+
self._token_text = ""
|
|
169
|
+
self._token_start = 0
|
|
170
|
+
self._token_end = 0
|
|
171
|
+
self._active = False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Command suggestion provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from iac_code.commands.registry import CommandRegistry, LocalCommand
|
|
6
|
+
from iac_code.ui.suggestions.types import CompletionToken, SuggestionItem, SuggestionProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommandProvider(SuggestionProvider):
|
|
10
|
+
"""Provides slash-command suggestions from a CommandRegistry."""
|
|
11
|
+
|
|
12
|
+
trigger = "/"
|
|
13
|
+
|
|
14
|
+
def __init__(self, registry: CommandRegistry) -> None:
|
|
15
|
+
self._registry = registry
|
|
16
|
+
|
|
17
|
+
def provide(self, token: CompletionToken) -> list[SuggestionItem]:
|
|
18
|
+
"""Return suggestions for the given completion token."""
|
|
19
|
+
# Strip the leading "/" to get the query
|
|
20
|
+
query = token.text[1:] if token.text.startswith("/") else token.text
|
|
21
|
+
|
|
22
|
+
matches = self._registry.fuzzy_search(query)
|
|
23
|
+
|
|
24
|
+
items: list[SuggestionItem] = []
|
|
25
|
+
for match in matches:
|
|
26
|
+
cmd = match.command
|
|
27
|
+
name = match.name
|
|
28
|
+
completion = f"/{name} "
|
|
29
|
+
arg_hint = cmd.arg_hint if isinstance(cmd, LocalCommand) else None
|
|
30
|
+
items.append(
|
|
31
|
+
SuggestionItem(
|
|
32
|
+
id=f"cmd:{cmd.name}",
|
|
33
|
+
display_text=name,
|
|
34
|
+
completion=completion,
|
|
35
|
+
description=cmd.description,
|
|
36
|
+
icon="/",
|
|
37
|
+
source="command",
|
|
38
|
+
score=float(-match.priority * 1000 - match.score),
|
|
39
|
+
arg_hint=arg_hint,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return items
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Directory suggestion provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from iac_code.ui.components.fuzzy_picker import fuzzy_match
|
|
8
|
+
from iac_code.ui.suggestions.file_provider import EXCLUDE_DIRS
|
|
9
|
+
from iac_code.ui.suggestions.types import CompletionToken, SuggestionItem, SuggestionProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DirectoryProvider(SuggestionProvider):
|
|
13
|
+
"""Suggests directory entries (dirs + files) matching an @-prefixed query.
|
|
14
|
+
|
|
15
|
+
Unlike FileProvider which indexes the entire tree, DirectoryProvider lists
|
|
16
|
+
entries in the directory that corresponds to the query path prefix, similar
|
|
17
|
+
to shell tab-completion behaviour.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
trigger = "@"
|
|
21
|
+
|
|
22
|
+
def __init__(self, root_dir: str) -> None:
|
|
23
|
+
self._root_dir = os.path.abspath(root_dir)
|
|
24
|
+
|
|
25
|
+
def _list_entries(self, dir_path: str) -> list[tuple[str, bool]]:
|
|
26
|
+
"""List (name, is_dir) entries in dir_path, excluding hidden/excluded dirs."""
|
|
27
|
+
entries: list[tuple[str, bool]] = []
|
|
28
|
+
try:
|
|
29
|
+
for entry in os.scandir(dir_path):
|
|
30
|
+
name = entry.name
|
|
31
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
32
|
+
# Skip hidden entries and excluded directories
|
|
33
|
+
if name.startswith("."):
|
|
34
|
+
continue
|
|
35
|
+
if is_dir and name in EXCLUDE_DIRS:
|
|
36
|
+
continue
|
|
37
|
+
entries.append((name, is_dir))
|
|
38
|
+
except PermissionError:
|
|
39
|
+
pass
|
|
40
|
+
entries.sort(key=lambda e: (not e[1], e[0].lower()))
|
|
41
|
+
return entries
|
|
42
|
+
|
|
43
|
+
def provide(self, token: CompletionToken) -> list[SuggestionItem]:
|
|
44
|
+
"""Return directory-listing suggestions for the given token."""
|
|
45
|
+
# Strip the leading "@"
|
|
46
|
+
query = token.text[1:] if token.text.startswith("@") else token.text
|
|
47
|
+
|
|
48
|
+
# Split query into directory prefix and filename fragment
|
|
49
|
+
# e.g. "src/ui/inp" → dir_prefix="src/ui", fragment="inp"
|
|
50
|
+
# e.g. "src/" → dir_prefix="src", fragment=""
|
|
51
|
+
# e.g. "src" → dir_prefix="", fragment="src"
|
|
52
|
+
if "/" in query:
|
|
53
|
+
last_slash = query.rfind("/")
|
|
54
|
+
dir_prefix = query[:last_slash]
|
|
55
|
+
fragment = query[last_slash + 1 :]
|
|
56
|
+
else:
|
|
57
|
+
dir_prefix = ""
|
|
58
|
+
fragment = query
|
|
59
|
+
|
|
60
|
+
# Resolve the directory to list
|
|
61
|
+
if dir_prefix:
|
|
62
|
+
list_dir = os.path.join(self._root_dir, dir_prefix)
|
|
63
|
+
else:
|
|
64
|
+
list_dir = self._root_dir
|
|
65
|
+
|
|
66
|
+
entries = self._list_entries(list_dir)
|
|
67
|
+
|
|
68
|
+
items: list[SuggestionItem] = []
|
|
69
|
+
for name, is_dir in entries:
|
|
70
|
+
if fragment:
|
|
71
|
+
score = fuzzy_match(fragment, name)
|
|
72
|
+
if score is None:
|
|
73
|
+
continue
|
|
74
|
+
else:
|
|
75
|
+
score = 0.0
|
|
76
|
+
|
|
77
|
+
if dir_prefix:
|
|
78
|
+
rel_path = f"{dir_prefix}/{name}"
|
|
79
|
+
else:
|
|
80
|
+
rel_path = name
|
|
81
|
+
|
|
82
|
+
completion_suffix = "/" if is_dir else ""
|
|
83
|
+
items.append(
|
|
84
|
+
SuggestionItem(
|
|
85
|
+
id=f"dir:{rel_path}",
|
|
86
|
+
display_text=rel_path + completion_suffix,
|
|
87
|
+
completion=f"@{rel_path}{completion_suffix}",
|
|
88
|
+
description="directory" if is_dir else "",
|
|
89
|
+
icon="◇",
|
|
90
|
+
source="directory",
|
|
91
|
+
score=score,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return items
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""File suggestion provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from iac_code.ui.components.fuzzy_picker import fuzzy_match
|
|
9
|
+
from iac_code.ui.suggestions.types import CompletionToken, SuggestionItem, SuggestionProvider
|
|
10
|
+
|
|
11
|
+
EXCLUDE_DIRS: frozenset[str] = frozenset(
|
|
12
|
+
{
|
|
13
|
+
".git",
|
|
14
|
+
".svn",
|
|
15
|
+
".hg",
|
|
16
|
+
".bzr",
|
|
17
|
+
".jj",
|
|
18
|
+
".sl",
|
|
19
|
+
".vscode",
|
|
20
|
+
".idea",
|
|
21
|
+
".claude",
|
|
22
|
+
"__pycache__",
|
|
23
|
+
".venv",
|
|
24
|
+
"venv",
|
|
25
|
+
".tox",
|
|
26
|
+
".mypy_cache",
|
|
27
|
+
".ruff_cache",
|
|
28
|
+
".pytest_cache",
|
|
29
|
+
".eggs",
|
|
30
|
+
".nox",
|
|
31
|
+
"node_modules",
|
|
32
|
+
".next",
|
|
33
|
+
".nuxt",
|
|
34
|
+
"bower_components",
|
|
35
|
+
"dist",
|
|
36
|
+
"build",
|
|
37
|
+
"_build",
|
|
38
|
+
".build",
|
|
39
|
+
"target",
|
|
40
|
+
".cache",
|
|
41
|
+
".npm",
|
|
42
|
+
".yarn",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
MAX_INDEX_FILES = 10_000
|
|
47
|
+
INDEX_STALE_SECONDS = 30
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _should_exclude_dir(name: str) -> bool:
|
|
51
|
+
"""Return True if this directory should be excluded from indexing."""
|
|
52
|
+
if name in EXCLUDE_DIRS:
|
|
53
|
+
return True
|
|
54
|
+
# Exclude *.egg-info directories
|
|
55
|
+
if name.endswith(".egg-info"):
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FileProvider(SuggestionProvider):
|
|
61
|
+
"""Suggests files from the project tree matching an @-prefixed query."""
|
|
62
|
+
|
|
63
|
+
trigger = "@"
|
|
64
|
+
|
|
65
|
+
def __init__(self, root_dir: str) -> None:
|
|
66
|
+
self._root_dir = os.path.abspath(root_dir)
|
|
67
|
+
self._index: list[str] = [] # relative paths
|
|
68
|
+
self._index_time: float = 0.0
|
|
69
|
+
|
|
70
|
+
def _needs_refresh(self) -> bool:
|
|
71
|
+
return (time.monotonic() - self._index_time) > INDEX_STALE_SECONDS
|
|
72
|
+
|
|
73
|
+
def _build_index(self) -> None:
|
|
74
|
+
"""Walk the directory tree and build the file index."""
|
|
75
|
+
files: list[str] = []
|
|
76
|
+
for dirpath, dirnames, filenames in os.walk(self._root_dir):
|
|
77
|
+
# Prune excluded dirs in-place so os.walk won't descend into them
|
|
78
|
+
dirnames[:] = [d for d in dirnames if not _should_exclude_dir(d)]
|
|
79
|
+
for fname in filenames:
|
|
80
|
+
abs_path = os.path.join(dirpath, fname)
|
|
81
|
+
rel_path = os.path.relpath(abs_path, self._root_dir)
|
|
82
|
+
files.append(rel_path)
|
|
83
|
+
if len(files) >= MAX_INDEX_FILES:
|
|
84
|
+
break
|
|
85
|
+
if len(files) >= MAX_INDEX_FILES:
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
self._index = files
|
|
89
|
+
self._index_time = time.monotonic()
|
|
90
|
+
|
|
91
|
+
def provide(self, token: CompletionToken) -> list[SuggestionItem]:
|
|
92
|
+
"""Return file suggestions for the given token."""
|
|
93
|
+
if self._needs_refresh():
|
|
94
|
+
self._build_index()
|
|
95
|
+
|
|
96
|
+
# Strip the leading "@" to get the query
|
|
97
|
+
query = token.text[1:] if token.text.startswith("@") else token.text
|
|
98
|
+
|
|
99
|
+
scored: list[tuple[float, str]] = []
|
|
100
|
+
for rel_path in self._index:
|
|
101
|
+
score = fuzzy_match(query, rel_path) if query else 0.0
|
|
102
|
+
if score is not None:
|
|
103
|
+
scored.append((score, rel_path))
|
|
104
|
+
|
|
105
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
106
|
+
|
|
107
|
+
items: list[SuggestionItem] = []
|
|
108
|
+
for score, rel_path in scored:
|
|
109
|
+
items.append(
|
|
110
|
+
SuggestionItem(
|
|
111
|
+
id=f"file:{rel_path}",
|
|
112
|
+
display_text=rel_path,
|
|
113
|
+
completion=f"@{rel_path}",
|
|
114
|
+
description="",
|
|
115
|
+
icon="+",
|
|
116
|
+
source="file",
|
|
117
|
+
score=score,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return items
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Shell history suggestion provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from iac_code.ui.suggestions.types import CompletionToken, SuggestionItem, SuggestionProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _detect_history_path() -> str | None:
|
|
11
|
+
"""Detect the shell history file path from the SHELL environment variable."""
|
|
12
|
+
shell = os.environ.get("SHELL", "")
|
|
13
|
+
home = os.path.expanduser("~")
|
|
14
|
+
|
|
15
|
+
if "zsh" in shell:
|
|
16
|
+
candidate = os.path.join(home, ".zsh_history")
|
|
17
|
+
elif "bash" in shell:
|
|
18
|
+
candidate = os.path.join(home, ".bash_history")
|
|
19
|
+
else:
|
|
20
|
+
# Fallback: try zsh first, then bash
|
|
21
|
+
zsh = os.path.join(home, ".zsh_history")
|
|
22
|
+
bash = os.path.join(home, ".bash_history")
|
|
23
|
+
if os.path.exists(zsh):
|
|
24
|
+
return zsh
|
|
25
|
+
if os.path.exists(bash):
|
|
26
|
+
return bash
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
return candidate if os.path.exists(candidate) else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_history(path: str) -> list[str]:
|
|
33
|
+
"""Read history entries from a history file.
|
|
34
|
+
|
|
35
|
+
Handles both plain bash history and zsh extended history format.
|
|
36
|
+
Returns entries in file order (oldest first).
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
with open(path, "rb") as f:
|
|
40
|
+
raw = f.read()
|
|
41
|
+
except OSError:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
# Decode, ignoring errors (history files can have mixed encodings)
|
|
45
|
+
text = raw.decode("utf-8", errors="replace")
|
|
46
|
+
lines = text.splitlines()
|
|
47
|
+
|
|
48
|
+
entries: list[str] = []
|
|
49
|
+
for line in lines:
|
|
50
|
+
# zsh extended_history format: ": <timestamp>:<elapsed>;<command>"
|
|
51
|
+
if line.startswith(": ") and ";" in line:
|
|
52
|
+
_, _, cmd = line.partition(";")
|
|
53
|
+
cmd = cmd.strip()
|
|
54
|
+
if cmd:
|
|
55
|
+
entries.append(cmd)
|
|
56
|
+
else:
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line:
|
|
59
|
+
entries.append(line)
|
|
60
|
+
|
|
61
|
+
return entries
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ShellHistoryProvider(SuggestionProvider):
|
|
65
|
+
"""Provides shell history suggestions for ! trigger."""
|
|
66
|
+
|
|
67
|
+
trigger = "!"
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._history_path: str | None = _detect_history_path()
|
|
71
|
+
|
|
72
|
+
def provide(self, token: CompletionToken) -> list[SuggestionItem]:
|
|
73
|
+
"""Return shell history suggestions matching the query."""
|
|
74
|
+
if not self._history_path:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
# Strip the leading "!" to get the query
|
|
78
|
+
query = token.text[1:] if token.text.startswith("!") else token.text
|
|
79
|
+
|
|
80
|
+
entries = _read_history(self._history_path)
|
|
81
|
+
|
|
82
|
+
# Substring match, dedup, most recent first
|
|
83
|
+
seen: set[str] = set()
|
|
84
|
+
matched: list[str] = []
|
|
85
|
+
|
|
86
|
+
# Iterate in reverse so most recent entries come first
|
|
87
|
+
for entry in reversed(entries):
|
|
88
|
+
if entry in seen:
|
|
89
|
+
continue
|
|
90
|
+
if query.lower() in entry.lower():
|
|
91
|
+
seen.add(entry)
|
|
92
|
+
matched.append(entry)
|
|
93
|
+
|
|
94
|
+
items: list[SuggestionItem] = []
|
|
95
|
+
for i, entry in enumerate(matched):
|
|
96
|
+
items.append(
|
|
97
|
+
SuggestionItem(
|
|
98
|
+
id=f"shell:{i}",
|
|
99
|
+
display_text=entry,
|
|
100
|
+
completion=f"!{entry}",
|
|
101
|
+
description="",
|
|
102
|
+
icon="↑",
|
|
103
|
+
source="shell",
|
|
104
|
+
score=float(len(matched) - i),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return items
|