talkops-opscloud 0.3.0b1__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 (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
@@ -0,0 +1,709 @@
1
+ """Autocomplete system for @ mentions and / commands.
2
+
3
+ This is a custom implementation that handles trigger-based completion
4
+ for slash commands (/) and file mentions (@).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from opscloud.utils.logger import get_logger
11
+ import os
12
+ import shutil
13
+
14
+ # S404: subprocess is required for git ls-files to get project file list
15
+ import subprocess # noqa: S404
16
+ from difflib import SequenceMatcher
17
+ from enum import StrEnum
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Protocol
20
+
21
+ from opscloud.project_utils import find_project_root
22
+ from opscloud.security.unicode_security import sanitize_control_chars
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ def _get_git_executable() -> str | None:
28
+ """Get full path to git executable using shutil.which().
29
+
30
+ Returns:
31
+ Full path to git executable, or None if not found.
32
+ """
33
+ return shutil.which("git")
34
+
35
+
36
+ if TYPE_CHECKING:
37
+ from textual import events
38
+
39
+ from opscloud.ui.command_registry import CommandEntry
40
+
41
+
42
+ class CompletionResult(StrEnum):
43
+ """Result of handling a key event in the completion system."""
44
+
45
+ IGNORED = "ignored" # Key not handled, let default behavior proceed
46
+ HANDLED = "handled" # Key handled, prevent default
47
+ SUBMIT = "submit" # Key triggers submission (e.g., Enter on slash command)
48
+
49
+
50
+ class CompletionView(Protocol):
51
+ """Protocol for views that can display completion suggestions."""
52
+
53
+ def render_completion_suggestions(
54
+ self, suggestions: list[tuple[str, str]], selected_index: int
55
+ ) -> None:
56
+ """Render the completion suggestions popup.
57
+
58
+ Args:
59
+ suggestions: List of (label, description) tuples
60
+ selected_index: Index of currently selected item
61
+ """
62
+ ...
63
+
64
+ def clear_completion_suggestions(self) -> None:
65
+ """Hide/clear the completion suggestions popup."""
66
+ ...
67
+
68
+ def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
69
+ """Replace text in the input from start to end with replacement.
70
+
71
+ Args:
72
+ start: Start index in the input text
73
+ end: End index in the input text
74
+ replacement: Text to insert
75
+ """
76
+ ...
77
+
78
+
79
+ class CompletionController(Protocol):
80
+ """Protocol for completion controllers."""
81
+
82
+ def can_handle(self, text: str, cursor_index: int) -> bool:
83
+ """Check if this controller can handle the current input state."""
84
+ ...
85
+
86
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
87
+ """Called when input text changes."""
88
+ ...
89
+
90
+ def on_key(
91
+ self, event: events.Key, text: str, cursor_index: int
92
+ ) -> CompletionResult:
93
+ """Handle a key event. Returns how the event was handled."""
94
+ ...
95
+
96
+ def reset(self) -> None:
97
+ """Reset/clear the completion state."""
98
+ ...
99
+
100
+
101
+ # ============================================================================
102
+ # Slash Command Completion
103
+ # ============================================================================
104
+
105
+
106
+ MAX_SUGGESTIONS = 10
107
+ """UI cap so the completion popup doesn't get unwieldy."""
108
+
109
+ _MIN_SLASH_FUZZY_SCORE = 25
110
+ """Minimum score for slash-command fuzzy matches."""
111
+
112
+ _MIN_DESC_SEARCH_LEN = 2
113
+ """Minimum query length to search command descriptions (avoids single-char noise)."""
114
+
115
+
116
+ class SlashCommandController:
117
+ """Controller for / slash command completion."""
118
+
119
+ def __init__(
120
+ self,
121
+ commands: list[CommandEntry],
122
+ view: CompletionView,
123
+ ) -> None:
124
+ """Initialize the slash command controller.
125
+
126
+ Args:
127
+ commands: List of `CommandEntry` instances.
128
+ view: View to render suggestions to.
129
+ """
130
+ self._commands = commands
131
+ self._view = view
132
+ self._suggestions: list[tuple[str, str]] = []
133
+ self._suggestion_names: list[str] = []
134
+ self._selected_index = 0
135
+
136
+ def update_commands(self, commands: list[CommandEntry]) -> None:
137
+ """Replace the commands list and reset suggestions."""
138
+ self._commands = commands
139
+ self.reset()
140
+
141
+ @staticmethod
142
+ def can_handle(text: str, cursor_index: int) -> bool: # noqa: ARG004
143
+ """Handle input that starts with /.
144
+
145
+ Returns:
146
+ True if text starts with slash, indicating a command.
147
+ """
148
+ return text.startswith("/")
149
+
150
+ def reset(self) -> None:
151
+ """Clear suggestions."""
152
+ if self._suggestions:
153
+ self._suggestions.clear()
154
+ self._suggestion_names.clear()
155
+ self._selected_index = 0
156
+ self._view.clear_completion_suggestions()
157
+
158
+ def name_prefix_matches(self, text: str, cursor_index: int) -> list[CommandEntry]:
159
+ """Return commands whose names start with the current slash query."""
160
+ if cursor_index < 0 or cursor_index > len(text):
161
+ return []
162
+ if not self.can_handle(text, cursor_index):
163
+ return []
164
+
165
+ search = text[1:cursor_index].lower()
166
+ if not search or " " in search:
167
+ return []
168
+
169
+ return [
170
+ entry
171
+ for entry in self._commands
172
+ if entry.name.lstrip("/").lower().startswith(search)
173
+ ]
174
+
175
+ @staticmethod
176
+ def _score_command(search: str, cmd: str, desc: str, keywords: str = "") -> float:
177
+ """Score a command against a search string. Higher = better match."""
178
+ if not search:
179
+ return 0.0
180
+ name = cmd.lstrip("/").lower()
181
+ lower_desc = desc.lower()
182
+ if name.startswith(search):
183
+ return 200.0
184
+ if search in name:
185
+ return 150.0
186
+ if keywords and len(search) >= _MIN_DESC_SEARCH_LEN:
187
+ for kw in keywords.lower().split():
188
+ if kw.startswith(search) or search in kw:
189
+ return 120.0
190
+ if len(search) >= _MIN_DESC_SEARCH_LEN and search in lower_desc:
191
+ idx = lower_desc.find(search)
192
+ if idx == 0 or lower_desc[idx - 1] == " ":
193
+ return 110.0
194
+ return 90.0
195
+ name_ratio = SequenceMatcher(None, search, name).ratio()
196
+ desc_ratio = SequenceMatcher(None, search, lower_desc).ratio()
197
+ best = max(name_ratio * 60, desc_ratio * 30)
198
+ return best if best >= _MIN_SLASH_FUZZY_SCORE else 0.0
199
+
200
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
201
+ """Update suggestions when text changes."""
202
+ if cursor_index < 0 or cursor_index > len(text):
203
+ self.reset()
204
+ return
205
+
206
+ if not self.can_handle(text, cursor_index):
207
+ self.reset()
208
+ return
209
+
210
+ search = text[1:cursor_index].lower()
211
+
212
+ if " " in search:
213
+ self.reset()
214
+ return
215
+
216
+ if not search:
217
+ selected = list(self._commands)[:MAX_SUGGESTIONS]
218
+ else:
219
+ scored = [
220
+ (score, entry)
221
+ for entry in self._commands
222
+ if (
223
+ score := self._score_command(
224
+ search, entry.name, entry.description, entry.hidden_keywords
225
+ )
226
+ )
227
+ > 0
228
+ ]
229
+ scored.sort(key=lambda x: -x[0])
230
+ selected = [entry for _, entry in scored[:MAX_SUGGESTIONS]]
231
+
232
+ if selected:
233
+ self._suggestions = [
234
+ (
235
+ entry.display_name if getattr(entry, "display_name", "") else entry.name,
236
+ entry.description,
237
+ )
238
+ for entry in selected
239
+ ]
240
+ self._suggestion_names = [entry.name for entry in selected]
241
+ self._selected_index = 0
242
+ self._view.render_completion_suggestions(
243
+ self._suggestions, self._selected_index
244
+ )
245
+ else:
246
+ self.reset()
247
+
248
+ def on_key(
249
+ self, event: events.Key, text: str, cursor_index: int
250
+ ) -> CompletionResult:
251
+ """Handle key events for navigation and selection."""
252
+ if not self._suggestions:
253
+ return CompletionResult.IGNORED
254
+
255
+ match event.key:
256
+ case "tab" | "space":
257
+ if self._apply_selected_completion(cursor_index):
258
+ return CompletionResult.HANDLED
259
+ return CompletionResult.IGNORED
260
+ case "enter":
261
+ if self._apply_selected_completion(cursor_index):
262
+ return CompletionResult.SUBMIT
263
+ return CompletionResult.HANDLED
264
+ case "down":
265
+ self._move_selection(1)
266
+ return CompletionResult.HANDLED
267
+ case "up":
268
+ self._move_selection(-1)
269
+ return CompletionResult.HANDLED
270
+ case "escape":
271
+ self.reset()
272
+ return CompletionResult.HANDLED
273
+ case _:
274
+ return CompletionResult.IGNORED
275
+
276
+ def _move_selection(self, delta: int) -> None:
277
+ """Move selection up or down."""
278
+ if not self._suggestions:
279
+ return
280
+ count = len(self._suggestions)
281
+ self._selected_index = (self._selected_index + delta) % count
282
+ self._view.render_completion_suggestions(
283
+ self._suggestions, self._selected_index
284
+ )
285
+
286
+ def _apply_selected_completion(self, cursor_index: int) -> bool:
287
+ """Apply the currently selected completion."""
288
+ if not self._suggestions:
289
+ return False
290
+
291
+ command = self._suggestion_names[self._selected_index]
292
+ self._view.replace_completion_range(0, cursor_index, command)
293
+ self.reset()
294
+ return True
295
+
296
+ def apply_name_prefix_completion(
297
+ self, match: CommandEntry, cursor_index: int
298
+ ) -> None:
299
+ """Apply a command-name prefix match."""
300
+ self._view.replace_completion_range(0, cursor_index, match.name)
301
+ self.reset()
302
+
303
+
304
+ # ============================================================================
305
+ # Fuzzy File Completion (scoped to current working directory)
306
+ # ============================================================================
307
+
308
+ _MAX_FALLBACK_FILES = 1000
309
+ """Hard cap on files returned by the non-git glob fallback."""
310
+
311
+ _MIN_FUZZY_SCORE = 15
312
+ """Minimum score to include in file-completion results."""
313
+
314
+ _MIN_FUZZY_RATIO = 0.4
315
+ """SequenceMatcher threshold for filename-only fuzzy matches."""
316
+
317
+ _NOT_A_REPO_MARKER = "not a git repository"
318
+ """Marker in `git ls-files` stderr for a non-repository directory."""
319
+
320
+ _GIT_STDERR_LOG_LIMIT = 500
321
+ """Max characters of git stderr to include in a diagnostic log line."""
322
+
323
+
324
+ def _run_git_ls_files(
325
+ git_path: str, root: Path, extra_args: list[str]
326
+ ) -> tuple[bool, list[str]]:
327
+ """Run `git ls-files` with the given arguments and return file paths."""
328
+ try:
329
+ result = subprocess.run( # noqa: S603
330
+ [git_path, "ls-files", *extra_args],
331
+ cwd=root,
332
+ capture_output=True,
333
+ text=True,
334
+ timeout=5,
335
+ check=False,
336
+ env={**os.environ, "LC_ALL": "C"},
337
+ )
338
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
339
+ logger.debug("git ls-files %s failed to run", extra_args, exc_info=True)
340
+ return False, []
341
+ if result.returncode != 0:
342
+ stderr = sanitize_control_chars(result.stderr, max_length=_GIT_STDERR_LOG_LIMIT)
343
+ if _NOT_A_REPO_MARKER not in stderr.lower():
344
+ logger.debug(
345
+ "git ls-files failed: root=%s args=%s exit=%d stderr=%s",
346
+ root,
347
+ extra_args,
348
+ result.returncode,
349
+ stderr,
350
+ )
351
+ return False, []
352
+ return True, [f for f in result.stdout.strip().split("\n") if f]
353
+
354
+
355
+ def _get_project_files(root: Path) -> list[str]:
356
+ """Get project files using git ls-files or fallback to glob."""
357
+ git_path = _get_git_executable()
358
+ if git_path:
359
+ tracked_ok, tracked = _run_git_ls_files(git_path, root, [])
360
+ if tracked_ok:
361
+ _, untracked = _run_git_ls_files(
362
+ git_path, root, ["--others", "--exclude-standard"]
363
+ )
364
+ seen: set[str] = set()
365
+ files: list[str] = []
366
+ for f in (*tracked, *untracked):
367
+ if f not in seen:
368
+ seen.add(f)
369
+ files.append(f)
370
+ return files
371
+
372
+ files = []
373
+ try:
374
+ for pattern in ["*", "*/*", "*/*/*", "*/*/*/*"]:
375
+ for p in root.glob(pattern):
376
+ if p.is_file() and not any(part.startswith(".") for part in p.parts):
377
+ files.append(p.relative_to(root).as_posix())
378
+ if len(files) >= _MAX_FALLBACK_FILES:
379
+ break
380
+ if len(files) >= _MAX_FALLBACK_FILES:
381
+ break
382
+ except OSError:
383
+ logger.debug("glob fallback failed for %s", root, exc_info=True)
384
+ return files
385
+
386
+
387
+ def _fuzzy_score(query: str, candidate: str) -> float:
388
+ """Score a candidate against query. Higher = better match."""
389
+ query_lower = query.lower()
390
+ candidate_normalized = candidate.replace("\\", "/")
391
+ candidate_lower = candidate_normalized.lower()
392
+
393
+ filename = candidate_normalized.rsplit("/", 1)[-1].lower()
394
+ filename_start = candidate_lower.rfind("/") + 1
395
+
396
+ if query_lower in filename:
397
+ idx = filename.find(query_lower)
398
+ if idx == 0:
399
+ return 150 + (1 / len(candidate))
400
+ if idx > 0 and filename[idx - 1] in "_-.":
401
+ return 120 + (1 / len(candidate))
402
+ return 100 + (1 / len(candidate))
403
+
404
+ if query_lower in candidate_lower:
405
+ idx = candidate_lower.find(query_lower)
406
+ if idx == filename_start:
407
+ return 80 + (1 / len(candidate))
408
+ if idx == 0 or candidate[idx - 1] in "/_-.":
409
+ return 60 + (1 / len(candidate))
410
+ return 40 + (1 / len(candidate))
411
+
412
+ filename_ratio = SequenceMatcher(None, query_lower, filename).ratio()
413
+ if filename_ratio > _MIN_FUZZY_RATIO:
414
+ return filename_ratio * 30
415
+
416
+ ratio = SequenceMatcher(None, query_lower, candidate_lower).ratio()
417
+ return ratio * 15
418
+
419
+
420
+ def _is_dotpath(path: str) -> bool:
421
+ """Check if path contains dotfiles/dotdirs."""
422
+ return any(part.startswith(".") for part in path.split("/"))
423
+
424
+
425
+ def _path_depth(path: str) -> int:
426
+ """Get depth of path."""
427
+ return path.count("/")
428
+
429
+
430
+ def _fuzzy_search(
431
+ query: str,
432
+ candidates: list[str],
433
+ limit: int = 10,
434
+ *,
435
+ include_dotfiles: bool = False,
436
+ ) -> list[str]:
437
+ """Return top matches sorted by score."""
438
+ filtered = (
439
+ candidates
440
+ if include_dotfiles
441
+ else [c for c in candidates if not _is_dotpath(c)]
442
+ )
443
+
444
+ if not query:
445
+ sorted_files = sorted(filtered, key=lambda p: (_path_depth(p), p.lower()))
446
+ return sorted_files[:limit]
447
+
448
+ scored = [
449
+ (score, c)
450
+ for c in filtered
451
+ if (score := _fuzzy_score(query, c)) >= _MIN_FUZZY_SCORE
452
+ ]
453
+ scored.sort(key=lambda x: -x[0])
454
+ return [c for _, c in scored[:limit]]
455
+
456
+
457
+ def _scope_files_to_cwd(files: list[str], project_root: Path, cwd: Path) -> list[str]:
458
+ """Scope a project-root-relative file list to paths under `cwd`."""
459
+ if cwd == project_root:
460
+ return files
461
+ try:
462
+ relative_cwd = cwd.relative_to(project_root).as_posix()
463
+ except ValueError:
464
+ return []
465
+ prefix = f"{relative_cwd}/"
466
+ return [path[len(prefix) :] for path in files if path.startswith(prefix)]
467
+
468
+
469
+ class FuzzyFileController:
470
+ """Controller for @ file completion with fuzzy matching from current cwd."""
471
+
472
+ def __init__(
473
+ self,
474
+ view: CompletionView,
475
+ cwd: Path | None = None,
476
+ ) -> None:
477
+ """Initialize the fuzzy file controller."""
478
+ self._view = view
479
+ self._cwd = (cwd or Path.cwd()).resolve()
480
+ self._project_root = find_project_root(self._cwd) or self._cwd
481
+ self._suggestions: list[tuple[str, str]] = []
482
+ self._selected_index = 0
483
+ self._file_cache: list[str] | None = None
484
+ self._project_root_pending = False
485
+ self._cache_generation = 0
486
+
487
+ def _get_files(self) -> list[str]:
488
+ """Get cached file list or refresh."""
489
+ if self._file_cache is None:
490
+ files = _get_project_files(self._project_root)
491
+ self._file_cache = _scope_files_to_cwd(files, self._project_root, self._cwd)
492
+ return self._file_cache
493
+
494
+ def refresh_cache(self) -> None:
495
+ """Force refresh of file cache."""
496
+ self._cache_generation += 1
497
+ self._file_cache = None
498
+
499
+ def set_cwd(self, cwd: Path) -> None:
500
+ """Switch completion roots to a new cwd."""
501
+ self._cache_generation += 1
502
+ self._cwd = cwd.resolve()
503
+ self._project_root = self._cwd
504
+ self._project_root_pending = True
505
+ self._file_cache = None
506
+ self.reset()
507
+
508
+ async def warm_cache(self, *, force: bool = False) -> None:
509
+ """Pre-populate the file cache off the event loop."""
510
+ cwd = self._cwd
511
+ generation = self._cache_generation
512
+ if self._project_root_pending:
513
+ root = await asyncio.to_thread(find_project_root, cwd)
514
+ if generation != self._cache_generation:
515
+ return
516
+ resolved = root or cwd
517
+ if resolved != self._project_root:
518
+ self._file_cache = None
519
+ self._project_root = resolved
520
+ self._project_root_pending = False
521
+ if not force and self._file_cache is not None:
522
+ return
523
+ project_root = self._project_root
524
+ try:
525
+ files = await asyncio.to_thread(_get_project_files, project_root)
526
+ if generation == self._cache_generation:
527
+ self._file_cache = _scope_files_to_cwd(files, project_root, cwd)
528
+ except Exception:
529
+ logger.debug("File-cache warm failed for %s", project_root, exc_info=True)
530
+
531
+ @staticmethod
532
+ def can_handle(text: str, cursor_index: int) -> bool:
533
+ """Handle input that contains @ not followed by space."""
534
+ if cursor_index <= 0 or cursor_index > len(text):
535
+ return False
536
+
537
+ before_cursor = text[:cursor_index]
538
+ if "@" not in before_cursor:
539
+ return False
540
+
541
+ at_index = before_cursor.rfind("@")
542
+ if cursor_index <= at_index:
543
+ return False
544
+
545
+ fragment = before_cursor[at_index:cursor_index]
546
+ return bool(fragment) and " " not in fragment
547
+
548
+ def reset(self) -> None:
549
+ """Clear suggestions."""
550
+ if self._suggestions:
551
+ self._suggestions.clear()
552
+ self._selected_index = 0
553
+ self._view.clear_completion_suggestions()
554
+
555
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
556
+ """Update suggestions when text changes."""
557
+ if not self.can_handle(text, cursor_index):
558
+ self.reset()
559
+ return
560
+
561
+ before_cursor = text[:cursor_index]
562
+ at_index = before_cursor.rfind("@")
563
+ search = before_cursor[at_index + 1 :]
564
+
565
+ suggestions = self._get_fuzzy_suggestions(search)
566
+
567
+ if suggestions:
568
+ self._suggestions = suggestions
569
+ self._selected_index = 0
570
+ self._view.render_completion_suggestions(
571
+ self._suggestions, self._selected_index
572
+ )
573
+ else:
574
+ self.reset()
575
+
576
+ def _get_fuzzy_suggestions(self, search: str) -> list[tuple[str, str]]:
577
+ """Get fuzzy file suggestions."""
578
+ files = self._get_files()
579
+ include_dots = search.startswith(".")
580
+ matches = _fuzzy_search(
581
+ search, files, limit=MAX_SUGGESTIONS, include_dotfiles=include_dots
582
+ )
583
+
584
+ suggestions: list[tuple[str, str]] = []
585
+ for path in matches:
586
+ ext = Path(path).suffix.lower()
587
+ type_hint = ext[1:] if ext else "file"
588
+ suggestions.append((f"@{path}", type_hint))
589
+
590
+ return suggestions
591
+
592
+ def on_key(
593
+ self, event: events.Key, text: str, cursor_index: int
594
+ ) -> CompletionResult:
595
+ """Handle key events for navigation and selection."""
596
+ if not self._suggestions:
597
+ return CompletionResult.IGNORED
598
+
599
+ match event.key:
600
+ case "tab" | "enter":
601
+ if self._apply_selected_completion(text, cursor_index):
602
+ return CompletionResult.HANDLED
603
+ return CompletionResult.IGNORED
604
+ case "down":
605
+ self._move_selection(1)
606
+ return CompletionResult.HANDLED
607
+ case "up":
608
+ self._move_selection(-1)
609
+ return CompletionResult.HANDLED
610
+ case "escape":
611
+ self.reset()
612
+ return CompletionResult.HANDLED
613
+ case _:
614
+ return CompletionResult.IGNORED
615
+
616
+ def _move_selection(self, delta: int) -> None:
617
+ """Move selection up or down."""
618
+ if not self._suggestions:
619
+ return
620
+ count = len(self._suggestions)
621
+ self._selected_index = (self._selected_index + delta) % count
622
+ self._view.render_completion_suggestions(
623
+ self._suggestions, self._selected_index
624
+ )
625
+
626
+ def _apply_selected_completion(self, text: str, cursor_index: int) -> bool:
627
+ """Apply the currently selected completion."""
628
+ if not self._suggestions:
629
+ return False
630
+
631
+ label, _ = self._suggestions[self._selected_index]
632
+ before_cursor = text[:cursor_index]
633
+ at_index = before_cursor.rfind("@")
634
+
635
+ if at_index < 0:
636
+ return False
637
+
638
+ self._view.replace_completion_range(at_index, cursor_index, label)
639
+ self.reset()
640
+ return True
641
+
642
+
643
+ PathCompletionController = FuzzyFileController
644
+
645
+
646
+ # ============================================================================
647
+ # Multi-Completion Manager
648
+ # ============================================================================
649
+
650
+
651
+ class MultiCompletionManager:
652
+ """Manages multiple completion controllers, delegating to the active one."""
653
+
654
+ def __init__(self, controllers: list[CompletionController]) -> None:
655
+ """Initialize with a list of controllers."""
656
+ self._controllers = controllers
657
+ self._active: CompletionController | None = None
658
+
659
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
660
+ """Handle text change, activating the appropriate controller."""
661
+ candidate = None
662
+ for controller in self._controllers:
663
+ if controller.can_handle(text, cursor_index):
664
+ candidate = controller
665
+ break
666
+
667
+ if candidate is None:
668
+ if self._active is not None:
669
+ self._active.reset()
670
+ self._active = None
671
+ return
672
+
673
+ if candidate is not self._active:
674
+ if self._active is not None:
675
+ self._active.reset()
676
+ self._active = candidate
677
+
678
+ candidate.on_text_changed(text, cursor_index)
679
+
680
+ def on_key(
681
+ self, event: events.Key, text: str, cursor_index: int
682
+ ) -> CompletionResult:
683
+ """Handle key event, delegating to active controller."""
684
+ if self._active is None:
685
+ return CompletionResult.IGNORED
686
+ return self._active.on_key(event, text, cursor_index)
687
+
688
+ def reset(self) -> None:
689
+ """Reset all controllers."""
690
+ if self._active is not None:
691
+ self._active.reset()
692
+ self._active = None
693
+
694
+
695
+ # ── AutocompletePopup (widget stub) ──────────────────────
696
+
697
+
698
+ class AutocompletePopup:
699
+ """Stub namespace for the ``CommandSelected`` message."""
700
+
701
+ from textual.message import Message as _Message
702
+
703
+ class CommandSelected(_Message):
704
+ """Fired when the user picks a slash command from the popup."""
705
+
706
+ def __init__(self, command_name: str, *, by_enter: bool = False) -> None:
707
+ super().__init__()
708
+ self.command_name = command_name
709
+ self.by_enter = by_enter