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.
Files changed (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. 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