codemaster-cli 2.2.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 (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. vibe/whats_new.md +5 -0
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from acp.helpers import SessionUpdate, ToolCallContentVariant
4
+ from acp.schema import (
5
+ ContentToolCallContent,
6
+ TextContentBlock,
7
+ ToolCallProgress,
8
+ ToolCallStart,
9
+ ToolKind,
10
+ )
11
+
12
+ from vibe.acp.tools.base import (
13
+ ToolCallSessionUpdateProtocol,
14
+ ToolResultSessionUpdateProtocol,
15
+ )
16
+ from vibe.core.tools.ui import ToolUIDataAdapter
17
+ from vibe.core.types import ToolCallEvent, ToolResultEvent
18
+ from vibe.core.utils import TaggedText, is_user_cancellation_event
19
+
20
+ TOOL_KIND: dict[str, ToolKind] = {
21
+ "grep": "search",
22
+ "read_file": "read",
23
+ # Right now, jetbrains implementation of "edit" tool kind is broken
24
+ # Leading to the tool not appearing in the chat
25
+ # "write_file": "edit",
26
+ # "search_replace": "edit",
27
+ }
28
+
29
+
30
+ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
31
+ if issubclass(event.tool_class, ToolCallSessionUpdateProtocol):
32
+ return event.tool_class.tool_call_session_update(event)
33
+
34
+ adapter = ToolUIDataAdapter(event.tool_class)
35
+ display = adapter.get_call_display(event)
36
+ content: list[ToolCallContentVariant] | None = (
37
+ [
38
+ ContentToolCallContent(
39
+ type="content",
40
+ content=TextContentBlock(type="text", text=display.content),
41
+ )
42
+ ]
43
+ if display.content
44
+ else None
45
+ )
46
+
47
+ return ToolCallStart(
48
+ session_update="tool_call",
49
+ title=display.summary,
50
+ content=content,
51
+ tool_call_id=event.tool_call_id,
52
+ kind=TOOL_KIND.get(event.tool_name, "other"),
53
+ raw_input=event.args.model_dump_json(),
54
+ )
55
+
56
+
57
+ def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
58
+ if is_user_cancellation_event(event):
59
+ tool_status = "failed"
60
+ raw_output = (
61
+ TaggedText.from_string(event.skip_reason).message
62
+ if event.skip_reason
63
+ else None
64
+ )
65
+ elif event.result:
66
+ tool_status = "completed"
67
+ raw_output = event.result.model_dump_json()
68
+ else:
69
+ tool_status = "failed"
70
+ raw_output = (
71
+ TaggedText.from_string(event.error).message if event.error else None
72
+ )
73
+
74
+ if event.tool_class is None:
75
+ return ToolCallProgress(
76
+ session_update="tool_call_update",
77
+ tool_call_id=event.tool_call_id,
78
+ status="failed",
79
+ raw_output=raw_output,
80
+ content=[
81
+ ContentToolCallContent(
82
+ type="content",
83
+ content=TextContentBlock(type="text", text=raw_output or ""),
84
+ )
85
+ ],
86
+ )
87
+
88
+ if issubclass(event.tool_class, ToolResultSessionUpdateProtocol):
89
+ return event.tool_class.tool_result_session_update(event)
90
+
91
+ if tool_status == "failed":
92
+ content = [
93
+ ContentToolCallContent(
94
+ type="content",
95
+ content=TextContentBlock(type="text", text=raw_output or ""),
96
+ )
97
+ ]
98
+ else:
99
+ adapter = ToolUIDataAdapter(event.tool_class)
100
+ display = adapter.get_result_display(event)
101
+ content: list[ToolCallContentVariant] | None = (
102
+ [
103
+ ContentToolCallContent(
104
+ type="content",
105
+ content=TextContentBlock(type="text", text=display.message),
106
+ )
107
+ ]
108
+ if display.message
109
+ else None
110
+ )
111
+
112
+ return ToolCallProgress(
113
+ session_update="tool_call_update",
114
+ tool_call_id=event.tool_call_id,
115
+ status=tool_status,
116
+ raw_output=raw_output,
117
+ content=content,
118
+ )
vibe/acp/utils.py ADDED
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import TYPE_CHECKING, Literal, cast
5
+
6
+ from acp.schema import (
7
+ AgentMessageChunk,
8
+ AgentThoughtChunk,
9
+ ContentToolCallContent,
10
+ PermissionOption,
11
+ SessionMode,
12
+ TextContentBlock,
13
+ ToolCallProgress,
14
+ ToolCallStart,
15
+ UserMessageChunk,
16
+ )
17
+
18
+ from vibe.core.agents.models import AgentProfile, AgentType
19
+ from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
20
+ from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
21
+ from vibe.core.utils import compact_reduction_display
22
+
23
+ if TYPE_CHECKING:
24
+ from vibe.core.agents.manager import AgentManager
25
+
26
+
27
+ class ToolOption(StrEnum):
28
+ ALLOW_ONCE = "allow_once"
29
+ ALLOW_ALWAYS = "allow_always"
30
+ REJECT_ONCE = "reject_once"
31
+ REJECT_ALWAYS = "reject_always"
32
+
33
+
34
+ TOOL_OPTIONS = [
35
+ PermissionOption(
36
+ option_id=ToolOption.ALLOW_ONCE,
37
+ name="Allow once",
38
+ kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
39
+ ),
40
+ PermissionOption(
41
+ option_id=ToolOption.ALLOW_ALWAYS,
42
+ name="Allow always",
43
+ kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
44
+ ),
45
+ PermissionOption(
46
+ option_id=ToolOption.REJECT_ONCE,
47
+ name="Reject once",
48
+ kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
49
+ ),
50
+ ]
51
+
52
+
53
+ def agent_profile_to_acp(profile: AgentProfile) -> SessionMode:
54
+ return SessionMode(
55
+ id=profile.name, name=profile.display_name, description=profile.description
56
+ )
57
+
58
+
59
+ def is_valid_acp_agent(agent_manager: AgentManager, agent_name: str) -> bool:
60
+ return agent_name in agent_manager.available_agents
61
+
62
+
63
+ def get_all_acp_session_modes(agent_manager: AgentManager) -> list[SessionMode]:
64
+ return [
65
+ agent_profile_to_acp(profile)
66
+ for profile in agent_manager.available_agents.values()
67
+ if profile.agent_type == AgentType.AGENT
68
+ ]
69
+
70
+
71
+ def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart:
72
+ # WORKAROUND: Using tool_call to communicate compact events to the client.
73
+ # This should be revisited when the ACP protocol defines how compact events
74
+ # should be represented.
75
+ # [RFD](https://agentclientprotocol.com/rfds/session-usage)
76
+ return ToolCallStart(
77
+ session_update="tool_call",
78
+ tool_call_id=event.tool_call_id,
79
+ title="Compacting conversation history...",
80
+ kind="other",
81
+ status="in_progress",
82
+ content=[
83
+ ContentToolCallContent(
84
+ type="content",
85
+ content=TextContentBlock(
86
+ type="text",
87
+ text="Automatic context management, no approval required. This may take some time...",
88
+ ),
89
+ )
90
+ ],
91
+ )
92
+
93
+
94
+ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgress:
95
+ # WORKAROUND: Using tool_call_update to communicate compact events to the client.
96
+ # This should be revisited when the ACP protocol defines how compact events
97
+ # should be represented.
98
+ # [RFD](https://agentclientprotocol.com/rfds/session-usage)
99
+ return ToolCallProgress(
100
+ session_update="tool_call_update",
101
+ tool_call_id=event.tool_call_id,
102
+ title="Compacted conversation history",
103
+ status="completed",
104
+ content=[
105
+ ContentToolCallContent(
106
+ type="content",
107
+ content=TextContentBlock(
108
+ type="text",
109
+ text=(
110
+ compact_reduction_display(
111
+ event.old_context_tokens, event.new_context_tokens
112
+ )
113
+ ),
114
+ ),
115
+ )
116
+ ],
117
+ )
118
+
119
+
120
+ def get_proxy_help_text() -> str:
121
+ lines = [
122
+ "## Proxy Configuration",
123
+ "",
124
+ "Configure proxy and SSL settings for HTTP requests.",
125
+ "",
126
+ "### Usage:",
127
+ "- `/proxy-setup` - Show this help and current settings",
128
+ "- `/proxy-setup KEY value` - Set an environment variable",
129
+ "- `/proxy-setup KEY` - Remove an environment variable",
130
+ "",
131
+ "### Supported Variables:",
132
+ ]
133
+
134
+ for key, description in SUPPORTED_PROXY_VARS.items():
135
+ lines.append(f"- `{key}`: {description}")
136
+
137
+ lines.extend(["", "### Current Settings:"])
138
+
139
+ current = get_current_proxy_settings()
140
+ any_set = False
141
+ for key, value in current.items():
142
+ if value:
143
+ lines.append(f"- `{key}={value}`")
144
+ any_set = True
145
+
146
+ if not any_set:
147
+ lines.append("- (none configured)")
148
+
149
+ return "\n".join(lines)
150
+
151
+
152
+ def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk:
153
+ content = msg.content if isinstance(msg.content, str) else ""
154
+ return UserMessageChunk(
155
+ session_update="user_message_chunk",
156
+ content=TextContentBlock(type="text", text=content),
157
+ field_meta={"messageId": msg.message_id} if msg.message_id else {},
158
+ )
159
+
160
+
161
+ def create_assistant_message_replay(msg: LLMMessage) -> AgentMessageChunk | None:
162
+ content = msg.content if isinstance(msg.content, str) else ""
163
+ if not content:
164
+ return None
165
+
166
+ return AgentMessageChunk(
167
+ session_update="agent_message_chunk",
168
+ content=TextContentBlock(type="text", text=content),
169
+ field_meta={"messageId": msg.message_id} if msg.message_id else {},
170
+ )
171
+
172
+
173
+ def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None:
174
+ if not isinstance(msg.reasoning_content, str) or not msg.reasoning_content:
175
+ return None
176
+
177
+ return AgentThoughtChunk(
178
+ session_update="agent_thought_chunk",
179
+ content=TextContentBlock(type="text", text=msg.reasoning_content),
180
+ field_meta={"messageId": msg.message_id} if msg.message_id else {},
181
+ )
182
+
183
+
184
+ def create_tool_call_replay(
185
+ tool_call_id: str, tool_name: str, arguments: str | None
186
+ ) -> ToolCallStart:
187
+ return ToolCallStart(
188
+ session_update="tool_call",
189
+ title=tool_name,
190
+ tool_call_id=tool_call_id,
191
+ kind="other",
192
+ raw_input=arguments,
193
+ )
194
+
195
+
196
+ def create_tool_result_replay(msg: LLMMessage) -> ToolCallProgress | None:
197
+ if not msg.tool_call_id:
198
+ return None
199
+
200
+ content = msg.content if isinstance(msg.content, str) else ""
201
+ return ToolCallProgress(
202
+ session_update="tool_call_update",
203
+ tool_call_id=msg.tool_call_id,
204
+ status="completed",
205
+ raw_output=content,
206
+ content=[
207
+ ContentToolCallContent(
208
+ type="content", content=TextContentBlock(type="text", text=content)
209
+ )
210
+ ]
211
+ if content
212
+ else None,
213
+ )
vibe/cli/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Protocol
5
+
6
+
7
+ class CompletionResult(StrEnum):
8
+ IGNORED = "ignored"
9
+ HANDLED = "handled"
10
+ SUBMIT = "submit"
11
+
12
+
13
+ class CompletionView(Protocol):
14
+ def render_completion_suggestions(
15
+ self, suggestions: list[tuple[str, str]], selected_index: int
16
+ ) -> None: ...
17
+
18
+ def clear_completion_suggestions(self) -> None: ...
19
+
20
+ def replace_completion_range(
21
+ self, start: int, end: int, replacement: str
22
+ ) -> None: ...
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ from concurrent.futures import Future, ThreadPoolExecutor
5
+ from threading import Lock
6
+
7
+ from textual import events
8
+
9
+ from vibe.cli.autocompletion.base import CompletionResult, CompletionView
10
+ from vibe.core.autocompletion.completers import PathCompleter
11
+
12
+ MAX_SUGGESTIONS_COUNT = 10
13
+
14
+
15
+ class PathCompletionController:
16
+ _executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="path-completion")
17
+
18
+ def __init__(self, completer: PathCompleter, view: CompletionView) -> None:
19
+ self._completer = completer
20
+ self._view = view
21
+ self._suggestions: list[tuple[str, str]] = []
22
+ self._selected_index = 0
23
+ self._pending_future: Future | None = None
24
+ self._last_query: tuple[str, int] | None = None
25
+ self._query_lock = Lock()
26
+
27
+ def can_handle(self, text: str, cursor_index: int) -> bool:
28
+ if cursor_index < 0 or cursor_index > len(text):
29
+ return False
30
+
31
+ if cursor_index == 0:
32
+ return False
33
+
34
+ before_cursor = text[:cursor_index]
35
+ if "@" not in before_cursor:
36
+ return False
37
+
38
+ at_index = before_cursor.rfind("@")
39
+
40
+ if cursor_index <= at_index:
41
+ return False
42
+
43
+ fragment = before_cursor[at_index:cursor_index]
44
+ # fragment must not be empty (including @) and not contain any spaces
45
+ return bool(fragment) and " " not in fragment
46
+
47
+ def reset(self) -> None:
48
+ with self._query_lock:
49
+ if self._pending_future and not self._pending_future.done():
50
+ self._pending_future.cancel()
51
+ self._pending_future = None
52
+ self._last_query = None
53
+ if self._suggestions:
54
+ self._suggestions.clear()
55
+ self._selected_index = 0
56
+ self._view.clear_completion_suggestions()
57
+
58
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
59
+ if not self.can_handle(text, cursor_index):
60
+ self.reset()
61
+ return
62
+
63
+ query = (text, cursor_index)
64
+ with self._query_lock:
65
+ if query == self._last_query:
66
+ return
67
+
68
+ if self._pending_future and not self._pending_future.done():
69
+ # NOTE (Vince): this is a "best effort" cancellation: it only works if the task
70
+ # hasn't started; once running in the thread pool, it cannot be cancelled
71
+ self._pending_future.cancel()
72
+
73
+ self._last_query = query
74
+
75
+ app = getattr(self._view, "app", None)
76
+ if app:
77
+ with self._query_lock:
78
+ self._pending_future = self._executor.submit(
79
+ self._compute_completions, text, cursor_index
80
+ )
81
+ self._pending_future.add_done_callback(
82
+ lambda f: self._handle_completion_result(f, query)
83
+ )
84
+ else:
85
+ suggestions = self._compute_completions(text, cursor_index)
86
+ self._update_suggestions(suggestions)
87
+
88
+ def _compute_completions(
89
+ self, text: str, cursor_index: int
90
+ ) -> list[tuple[str, str]]:
91
+ return self._completer.get_completion_items(text, cursor_index)
92
+
93
+ def _handle_completion_result(self, future: Future, query: tuple[str, int]) -> None:
94
+ if future.cancelled():
95
+ return
96
+
97
+ try:
98
+ suggestions = future.result()
99
+ with self._query_lock:
100
+ if query == self._last_query:
101
+ self._update_suggestions(suggestions)
102
+ except Exception:
103
+ with self._query_lock:
104
+ self._pending_future = None
105
+ self._last_query = None
106
+
107
+ def _update_suggestions(self, suggestions: list[tuple[str, str]]) -> None:
108
+ if len(suggestions) > MAX_SUGGESTIONS_COUNT:
109
+ suggestions = suggestions[:MAX_SUGGESTIONS_COUNT]
110
+
111
+ app = getattr(self._view, "app", None)
112
+
113
+ if suggestions:
114
+ self._suggestions = suggestions
115
+ self._selected_index = 0
116
+ if app:
117
+ app.call_after_refresh(
118
+ self._view.render_completion_suggestions,
119
+ self._suggestions,
120
+ self._selected_index,
121
+ )
122
+ else:
123
+ self._view.render_completion_suggestions(
124
+ self._suggestions, self._selected_index
125
+ )
126
+ elif app:
127
+ app.call_after_refresh(self.reset)
128
+ else:
129
+ self.reset()
130
+
131
+ def on_key(
132
+ self, event: events.Key, text: str, cursor_index: int
133
+ ) -> CompletionResult:
134
+ if not self._suggestions:
135
+ return CompletionResult.IGNORED
136
+
137
+ match event.key:
138
+ case "tab" | "enter":
139
+ if self._apply_selected_completion(text, cursor_index):
140
+ return CompletionResult.HANDLED
141
+ return CompletionResult.IGNORED
142
+ case "down":
143
+ self._move_selection(1)
144
+ return CompletionResult.HANDLED
145
+ case "up":
146
+ self._move_selection(-1)
147
+ return CompletionResult.HANDLED
148
+ case _:
149
+ return CompletionResult.IGNORED
150
+
151
+ def _move_selection(self, delta: int) -> None:
152
+ if not self._suggestions:
153
+ return
154
+
155
+ count = len(self._suggestions)
156
+ self._selected_index = (self._selected_index + delta) % count
157
+ self._view.render_completion_suggestions(
158
+ self._suggestions, self._selected_index
159
+ )
160
+
161
+ def _apply_selected_completion(self, text: str, cursor_index: int) -> bool:
162
+ if not self._suggestions:
163
+ return False
164
+
165
+ completion, _ = self._suggestions[self._selected_index]
166
+ replacement_range = self._completer.get_replacement_range(text, cursor_index)
167
+ if replacement_range is None:
168
+ self.reset()
169
+ return False
170
+
171
+ start, end = replacement_range
172
+ self._view.replace_completion_range(start, end, completion)
173
+ self.reset()
174
+ return True
175
+
176
+
177
+ atexit.register(PathCompletionController._executor.shutdown)
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from textual import events
4
+
5
+ from vibe.cli.autocompletion.base import CompletionResult, CompletionView
6
+ from vibe.core.autocompletion.completers import CommandCompleter
7
+
8
+ MAX_SUGGESTIONS_COUNT = 10
9
+
10
+
11
+ class SlashCommandController:
12
+ def __init__(self, completer: CommandCompleter, view: CompletionView) -> None:
13
+ self._completer = completer
14
+ self._view = view
15
+ self._suggestions: list[tuple[str, str]] = []
16
+ self._selected_index = 0
17
+
18
+ def can_handle(self, text: str, cursor_index: int) -> bool:
19
+ return text.startswith("/")
20
+
21
+ def reset(self) -> None:
22
+ if self._suggestions:
23
+ self._suggestions.clear()
24
+ self._selected_index = 0
25
+ self._view.clear_completion_suggestions()
26
+
27
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
28
+ if cursor_index < 0 or cursor_index > len(text):
29
+ self.reset()
30
+ return
31
+
32
+ if not self.can_handle(text, cursor_index):
33
+ self.reset()
34
+ return
35
+
36
+ suggestions = self._completer.get_completion_items(text, cursor_index)
37
+ if len(suggestions) > MAX_SUGGESTIONS_COUNT:
38
+ suggestions = suggestions[:MAX_SUGGESTIONS_COUNT]
39
+ if suggestions:
40
+ self._suggestions = suggestions
41
+ self._selected_index = 0
42
+ self._view.render_completion_suggestions(
43
+ self._suggestions, self._selected_index
44
+ )
45
+ else:
46
+ self.reset()
47
+
48
+ def on_key(
49
+ self, event: events.Key, text: str, cursor_index: int
50
+ ) -> CompletionResult:
51
+ if not self._suggestions:
52
+ return CompletionResult.IGNORED
53
+
54
+ match event.key:
55
+ case "tab":
56
+ if self._apply_selected_completion(text, cursor_index):
57
+ result = CompletionResult.HANDLED
58
+ else:
59
+ result = CompletionResult.IGNORED
60
+ case "enter":
61
+ if self._apply_selected_completion(text, cursor_index):
62
+ result = CompletionResult.SUBMIT
63
+ else:
64
+ result = CompletionResult.HANDLED
65
+ case "down":
66
+ self._move_selection(1)
67
+ result = CompletionResult.HANDLED
68
+ case "up":
69
+ self._move_selection(-1)
70
+ result = CompletionResult.HANDLED
71
+ case _:
72
+ result = CompletionResult.IGNORED
73
+
74
+ return result
75
+
76
+ def _move_selection(self, delta: int) -> None:
77
+ if not self._suggestions:
78
+ return
79
+
80
+ count = len(self._suggestions)
81
+ self._selected_index = (self._selected_index + delta) % count
82
+ self._view.render_completion_suggestions(
83
+ self._suggestions, self._selected_index
84
+ )
85
+
86
+ def _apply_selected_completion(self, text: str, cursor_index: int) -> bool:
87
+ if not self._suggestions:
88
+ return False
89
+
90
+ alias, _ = self._suggestions[self._selected_index]
91
+ replacement_range = self._completer.get_replacement_range(text, cursor_index)
92
+ if replacement_range is None:
93
+ self.reset()
94
+ return False
95
+
96
+ start, end = replacement_range
97
+ self._view.replace_completion_range(start, end, alias)
98
+ self.reset()
99
+ return True