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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- 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
|