rlmness-cli 0.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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/tui/suggest.py
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
"""Autocomplete engine for rlm-agent's CLI/TUI prompt input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rlmagent.directives import CommandRegistry, SlashCommand
|
|
12
|
+
from rlmagent.prompts import PromptTemplate
|
|
13
|
+
from rlmagent.skillset import Skill
|
|
14
|
+
|
|
15
|
+
# ── constants ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
_SKILL_PREFIX = "/skill:"
|
|
18
|
+
_FILE_REF_PREFIX = "@"
|
|
19
|
+
_SHELL_PREFIXES = ("! ", "!! ")
|
|
20
|
+
_MAX_RESULTS_DEFAULT = 50
|
|
21
|
+
|
|
22
|
+
_IGNORED_DIRS_DEFAULT: frozenset[str] = frozenset({
|
|
23
|
+
".git", ".hg", ".svn",
|
|
24
|
+
"__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
25
|
+
"node_modules", ".tox", ".nox",
|
|
26
|
+
".venv", "venv", "env", ".env",
|
|
27
|
+
"dist", "build", ".build",
|
|
28
|
+
".eggs", "*.egg-info",
|
|
29
|
+
"target",
|
|
30
|
+
".next", ".nuxt",
|
|
31
|
+
"coverage", ".coverage", "htmlcov",
|
|
32
|
+
".terraform",
|
|
33
|
+
".cache",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
_UNSAFE_PATH_RE = re.compile(r"[$~*?\[\]{}]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── CompletionOption ──────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class CompletionOption:
|
|
44
|
+
"""A possible argument value for a command that accepts discrete choices."""
|
|
45
|
+
|
|
46
|
+
value: str
|
|
47
|
+
description: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── CompletionItem ────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class CompletionItem:
|
|
55
|
+
"""One selectable autocomplete suggestion."""
|
|
56
|
+
|
|
57
|
+
display: str
|
|
58
|
+
replacement: str
|
|
59
|
+
start: int
|
|
60
|
+
end: int
|
|
61
|
+
description: str = ""
|
|
62
|
+
category: str = ""
|
|
63
|
+
|
|
64
|
+
def apply(self, text: str) -> str:
|
|
65
|
+
"""Return *text* with the active token replaced by ``replacement``."""
|
|
66
|
+
return text[:self.start] + self.replacement + text[self.end:]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── CompletionState ───────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class CompletionState:
|
|
74
|
+
"""Mutable selection state over a tuple of ``CompletionItem`` values."""
|
|
75
|
+
|
|
76
|
+
items: tuple[CompletionItem, ...] = ()
|
|
77
|
+
selected_index: int = -1
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def selected(self) -> CompletionItem | None:
|
|
81
|
+
"""The currently highlighted item, or ``None`` when nothing is selected."""
|
|
82
|
+
if not self.items or self.selected_index < 0:
|
|
83
|
+
return None
|
|
84
|
+
return self.items[self.selected_index % len(self.items)]
|
|
85
|
+
|
|
86
|
+
def select_next(self) -> None:
|
|
87
|
+
"""Advance the selection, wrapping around at the end."""
|
|
88
|
+
if not self.items:
|
|
89
|
+
return
|
|
90
|
+
self.selected_index = (self.selected_index + 1) % len(self.items)
|
|
91
|
+
|
|
92
|
+
def select_previous(self) -> None:
|
|
93
|
+
"""Move the selection backward, wrapping around at the start."""
|
|
94
|
+
if not self.items:
|
|
95
|
+
return
|
|
96
|
+
if self.selected_index < 0:
|
|
97
|
+
self.selected_index = len(self.items) - 1
|
|
98
|
+
else:
|
|
99
|
+
self.selected_index = (self.selected_index - 1) % len(self.items)
|
|
100
|
+
|
|
101
|
+
def __bool__(self) -> bool:
|
|
102
|
+
return len(self.items) > 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── ignored-path detection ────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_ignored_dir(name: str, ignored: frozenset[str] = _IGNORED_DIRS_DEFAULT) -> bool:
|
|
109
|
+
"""Return whether *name* should be skipped during filesystem traversal."""
|
|
110
|
+
if name.startswith("."):
|
|
111
|
+
return True
|
|
112
|
+
return name in ignored or name.lower() in ignored
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── active-token parsing ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _active_token(text: str, cursor: int) -> tuple[str, int, int]:
|
|
119
|
+
"""Extract the token under the cursor and its span."""
|
|
120
|
+
if cursor > len(text):
|
|
121
|
+
cursor = len(text)
|
|
122
|
+
|
|
123
|
+
# Walk backward to find token start
|
|
124
|
+
start = cursor
|
|
125
|
+
while start > 0 and not text[start - 1].isspace():
|
|
126
|
+
start -= 1
|
|
127
|
+
|
|
128
|
+
# Walk forward to find token end
|
|
129
|
+
end = cursor
|
|
130
|
+
while end < len(text) and not text[end].isspace():
|
|
131
|
+
end += 1
|
|
132
|
+
|
|
133
|
+
return text[start:end], start, end
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _last_token(text: str) -> tuple[str, int, int]:
|
|
137
|
+
"""Convenience: active token at the very end of *text*."""
|
|
138
|
+
return _active_token(text, len(text))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── sorting ──────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _sort_items(items: list[CompletionItem], prefix: str) -> list[CompletionItem]:
|
|
145
|
+
"""Sort completions: exact prefix matches first, then alphabetically."""
|
|
146
|
+
low = prefix.lower()
|
|
147
|
+
|
|
148
|
+
def _key(item: CompletionItem) -> tuple[int, str]:
|
|
149
|
+
repl = item.replacement.lower()
|
|
150
|
+
# Exact prefix match gets priority 0, others get 1
|
|
151
|
+
priority = 0 if repl.startswith(low) else 1
|
|
152
|
+
return priority, repl
|
|
153
|
+
|
|
154
|
+
return sorted(items, key=_key)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _dedupe_items(items: list[CompletionItem]) -> list[CompletionItem]:
|
|
158
|
+
"""Remove duplicate completions by replacement value."""
|
|
159
|
+
seen: set[str] = set()
|
|
160
|
+
result: list[CompletionItem] = []
|
|
161
|
+
for item in items:
|
|
162
|
+
if item.replacement not in seen:
|
|
163
|
+
seen.add(item.replacement)
|
|
164
|
+
result.append(item)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── slash-command completion ─────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def complete_commands(
|
|
172
|
+
prefix: str,
|
|
173
|
+
registry: CommandRegistry,
|
|
174
|
+
*,
|
|
175
|
+
start: int,
|
|
176
|
+
end: int,
|
|
177
|
+
max_results: int = _MAX_RESULTS_DEFAULT,
|
|
178
|
+
) -> list[CompletionItem]:
|
|
179
|
+
"""Generate completions for a partial slash command."""
|
|
180
|
+
if not prefix.startswith("/"):
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
fragment = prefix[1:].lower()
|
|
184
|
+
items: list[CompletionItem] = []
|
|
185
|
+
|
|
186
|
+
for cmd in registry.all_commands():
|
|
187
|
+
if _command_matches(cmd, fragment):
|
|
188
|
+
items.append(CompletionItem(
|
|
189
|
+
display=f"/{cmd.name}",
|
|
190
|
+
replacement=f"/{cmd.name}",
|
|
191
|
+
start=start,
|
|
192
|
+
end=end,
|
|
193
|
+
description=cmd.description,
|
|
194
|
+
category="command",
|
|
195
|
+
))
|
|
196
|
+
if len(items) >= max_results:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
return _sort_items(items, prefix)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _command_matches(cmd: SlashCommand, fragment: str) -> bool:
|
|
203
|
+
"""Return whether *cmd* is a candidate for the typed *fragment*."""
|
|
204
|
+
if cmd.name.startswith(fragment):
|
|
205
|
+
return True
|
|
206
|
+
for alias in cmd.aliases:
|
|
207
|
+
if alias.startswith(fragment):
|
|
208
|
+
return True
|
|
209
|
+
for term in cmd.search_terms:
|
|
210
|
+
if term.startswith(fragment):
|
|
211
|
+
return True
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── argument completion ──────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def complete_arguments(
|
|
219
|
+
command_name: str,
|
|
220
|
+
arg_prefix: str,
|
|
221
|
+
options: Sequence[CompletionOption],
|
|
222
|
+
*,
|
|
223
|
+
start: int,
|
|
224
|
+
end: int,
|
|
225
|
+
max_results: int = _MAX_RESULTS_DEFAULT,
|
|
226
|
+
) -> list[CompletionItem]:
|
|
227
|
+
"""Generate argument completions for a known command."""
|
|
228
|
+
low = arg_prefix.lower()
|
|
229
|
+
items: list[CompletionItem] = []
|
|
230
|
+
|
|
231
|
+
for opt in options:
|
|
232
|
+
if opt.value.lower().startswith(low):
|
|
233
|
+
items.append(CompletionItem(
|
|
234
|
+
display=opt.value,
|
|
235
|
+
replacement=opt.value,
|
|
236
|
+
start=start,
|
|
237
|
+
end=end,
|
|
238
|
+
description=opt.description,
|
|
239
|
+
category="argument",
|
|
240
|
+
))
|
|
241
|
+
if len(items) >= max_results:
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
return _sort_items(items, arg_prefix)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ── skill completion ─────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def complete_skills(
|
|
251
|
+
prefix: str,
|
|
252
|
+
skills: Sequence[Skill],
|
|
253
|
+
*,
|
|
254
|
+
start: int,
|
|
255
|
+
end: int,
|
|
256
|
+
max_results: int = _MAX_RESULTS_DEFAULT,
|
|
257
|
+
) -> list[CompletionItem]:
|
|
258
|
+
"""Generate completions for ``/skill:<partial>``."""
|
|
259
|
+
if not prefix.lower().startswith(_SKILL_PREFIX):
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
fragment = prefix[len(_SKILL_PREFIX):]
|
|
263
|
+
|
|
264
|
+
# If the fragment already contains whitespace, the user is past the
|
|
265
|
+
# skill name and is typing arguments — stop suggesting skills.
|
|
266
|
+
if " " in fragment:
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
low = fragment.lower()
|
|
270
|
+
items: list[CompletionItem] = []
|
|
271
|
+
|
|
272
|
+
for skill in skills:
|
|
273
|
+
if skill.name.lower().startswith(low):
|
|
274
|
+
replacement = f"{_SKILL_PREFIX}{skill.name}"
|
|
275
|
+
items.append(CompletionItem(
|
|
276
|
+
display=replacement,
|
|
277
|
+
replacement=replacement,
|
|
278
|
+
start=start,
|
|
279
|
+
end=end,
|
|
280
|
+
description=skill.description,
|
|
281
|
+
category="skill",
|
|
282
|
+
))
|
|
283
|
+
if len(items) >= max_results:
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
return _sort_items(items, prefix)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ── prompt-template completion ───────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def complete_prompts(
|
|
293
|
+
prefix: str,
|
|
294
|
+
templates: Sequence[PromptTemplate],
|
|
295
|
+
*,
|
|
296
|
+
start: int,
|
|
297
|
+
end: int,
|
|
298
|
+
max_results: int = _MAX_RESULTS_DEFAULT,
|
|
299
|
+
) -> list[CompletionItem]:
|
|
300
|
+
"""Generate completions for prompt template names."""
|
|
301
|
+
if not prefix.startswith("/"):
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
fragment = prefix[1:].lower()
|
|
305
|
+
items: list[CompletionItem] = []
|
|
306
|
+
|
|
307
|
+
for tpl in templates:
|
|
308
|
+
if tpl.name.lower().startswith(fragment):
|
|
309
|
+
items.append(CompletionItem(
|
|
310
|
+
display=f"/{tpl.name}",
|
|
311
|
+
replacement=f"/{tpl.name}",
|
|
312
|
+
start=start,
|
|
313
|
+
end=end,
|
|
314
|
+
description=tpl.description or "",
|
|
315
|
+
category="prompt",
|
|
316
|
+
))
|
|
317
|
+
if len(items) >= max_results:
|
|
318
|
+
break
|
|
319
|
+
|
|
320
|
+
return _sort_items(items, prefix)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ── file-reference completion ────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def complete_file_refs(
|
|
327
|
+
prefix: str,
|
|
328
|
+
cwd: str,
|
|
329
|
+
*,
|
|
330
|
+
start: int,
|
|
331
|
+
end: int,
|
|
332
|
+
ignored: frozenset[str] = _IGNORED_DIRS_DEFAULT,
|
|
333
|
+
max_results: int = _MAX_RESULTS_DEFAULT,
|
|
334
|
+
) -> list[CompletionItem]:
|
|
335
|
+
"""Generate completions for ``@path/to/file`` references."""
|
|
336
|
+
if not prefix.startswith(_FILE_REF_PREFIX):
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
partial = prefix[len(_FILE_REF_PREFIX):]
|
|
340
|
+
base = Path(cwd)
|
|
341
|
+
items: list[CompletionItem] = []
|
|
342
|
+
|
|
343
|
+
# Determine the directory to scan and the filename prefix to match
|
|
344
|
+
if "/" in partial or "\\" in partial:
|
|
345
|
+
sep_pos = max(partial.rfind("/"), partial.rfind("\\"))
|
|
346
|
+
dir_part = partial[:sep_pos + 1]
|
|
347
|
+
file_prefix = partial[sep_pos + 1:]
|
|
348
|
+
scan_dir = base / dir_part.replace("\\", "/")
|
|
349
|
+
else:
|
|
350
|
+
dir_part = ""
|
|
351
|
+
file_prefix = partial
|
|
352
|
+
scan_dir = base
|
|
353
|
+
|
|
354
|
+
_collect_file_refs(
|
|
355
|
+
scan_dir, base, dir_part, file_prefix, items,
|
|
356
|
+
ignored=ignored, max_results=max_results, start=start, end=end,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return _sort_items(items, prefix)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _collect_file_refs(
|
|
363
|
+
scan_dir: Path,
|
|
364
|
+
base: Path,
|
|
365
|
+
dir_part: str,
|
|
366
|
+
file_prefix: str,
|
|
367
|
+
items: list[CompletionItem],
|
|
368
|
+
*,
|
|
369
|
+
ignored: frozenset[str],
|
|
370
|
+
max_results: int,
|
|
371
|
+
start: int,
|
|
372
|
+
end: int,
|
|
373
|
+
depth: int = 0,
|
|
374
|
+
max_depth: int = 4,
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Recursively collect file-reference completions."""
|
|
377
|
+
if len(items) >= max_results or depth > max_depth:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
if not scan_dir.is_dir():
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
entries = sorted(scan_dir.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
385
|
+
except OSError:
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
low_prefix = file_prefix.lower()
|
|
389
|
+
|
|
390
|
+
for entry in entries:
|
|
391
|
+
if len(items) >= max_results:
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
if is_ignored_dir(entry.name, ignored) and entry.is_dir():
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
rel = entry.relative_to(base).as_posix()
|
|
399
|
+
except ValueError:
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
if entry.is_dir():
|
|
403
|
+
dirname = entry.name + "/"
|
|
404
|
+
if dirname.lower().startswith(low_prefix) or not file_prefix:
|
|
405
|
+
full_ref = f"{_FILE_REF_PREFIX}{rel}/"
|
|
406
|
+
items.append(CompletionItem(
|
|
407
|
+
display=full_ref,
|
|
408
|
+
replacement=full_ref,
|
|
409
|
+
start=start,
|
|
410
|
+
end=end,
|
|
411
|
+
description="directory",
|
|
412
|
+
category="file",
|
|
413
|
+
))
|
|
414
|
+
# Recurse into matching directories
|
|
415
|
+
matches_prefix = (
|
|
416
|
+
entry.name.lower().startswith(low_prefix[:len(entry.name)].lower())
|
|
417
|
+
if file_prefix
|
|
418
|
+
else True
|
|
419
|
+
)
|
|
420
|
+
if matches_prefix:
|
|
421
|
+
_collect_file_refs(
|
|
422
|
+
entry, base, dir_part + entry.name + "/", "",
|
|
423
|
+
items, ignored=ignored, max_results=max_results,
|
|
424
|
+
start=start, end=end, depth=depth + 1, max_depth=max_depth,
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
if entry.name.lower().startswith(low_prefix):
|
|
428
|
+
full_ref = f"{_FILE_REF_PREFIX}{rel}"
|
|
429
|
+
items.append(CompletionItem(
|
|
430
|
+
display=full_ref,
|
|
431
|
+
replacement=full_ref,
|
|
432
|
+
start=start,
|
|
433
|
+
end=end,
|
|
434
|
+
description=_file_description(entry),
|
|
435
|
+
category="file",
|
|
436
|
+
))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _file_description(path: Path) -> str:
|
|
440
|
+
"""Derive a short description for a file path."""
|
|
441
|
+
suffix = path.suffix.lower()
|
|
442
|
+
descriptions: dict[str, str] = {
|
|
443
|
+
".py": "Python",
|
|
444
|
+
".js": "JavaScript",
|
|
445
|
+
".ts": "TypeScript",
|
|
446
|
+
".tsx": "TypeScript JSX",
|
|
447
|
+
".jsx": "JavaScript JSX",
|
|
448
|
+
".rs": "Rust",
|
|
449
|
+
".go": "Go",
|
|
450
|
+
".md": "Markdown",
|
|
451
|
+
".toml": "TOML",
|
|
452
|
+
".yaml": "YAML",
|
|
453
|
+
".yml": "YAML",
|
|
454
|
+
".json": "JSON",
|
|
455
|
+
".txt": "Text",
|
|
456
|
+
".sh": "Shell",
|
|
457
|
+
".css": "CSS",
|
|
458
|
+
".html": "HTML",
|
|
459
|
+
".sql": "SQL",
|
|
460
|
+
}
|
|
461
|
+
return descriptions.get(suffix, "file")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ── shell-path completion ────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def complete_shell_paths(
|
|
468
|
+
text: str,
|
|
469
|
+
cwd: str,
|
|
470
|
+
*,
|
|
471
|
+
ignored: frozenset[str] = _IGNORED_DIRS_DEFAULT,
|
|
472
|
+
max_results: int = _MAX_RESULTS_DEFAULT,
|
|
473
|
+
) -> list[CompletionItem]:
|
|
474
|
+
"""Generate path completions for shell-mode input (``!`` or ``!!``)."""
|
|
475
|
+
shell_prefix, body = _parse_shell_prefix(text)
|
|
476
|
+
if shell_prefix is None:
|
|
477
|
+
return []
|
|
478
|
+
|
|
479
|
+
# Extract the last token from the shell body
|
|
480
|
+
token, tok_start, tok_end = _last_token(body)
|
|
481
|
+
if not token:
|
|
482
|
+
return []
|
|
483
|
+
|
|
484
|
+
# Reject unsafe patterns
|
|
485
|
+
if _is_unsafe_path(token):
|
|
486
|
+
return []
|
|
487
|
+
|
|
488
|
+
# Adjust offsets to account for the shell prefix
|
|
489
|
+
offset = len(shell_prefix)
|
|
490
|
+
abs_start = offset + tok_start
|
|
491
|
+
abs_end = offset + tok_end
|
|
492
|
+
|
|
493
|
+
return _complete_relative_path(
|
|
494
|
+
token, cwd, start=abs_start, end=abs_end,
|
|
495
|
+
ignored=ignored, max_results=max_results,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _parse_shell_prefix(text: str) -> tuple[str | None, str]:
|
|
500
|
+
"""Split shell prefix (``"! "`` or ``"!! "``) from the body."""
|
|
501
|
+
for prefix in _SHELL_PREFIXES:
|
|
502
|
+
if text.startswith(prefix):
|
|
503
|
+
return prefix, text[len(prefix):]
|
|
504
|
+
return None, ""
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _is_unsafe_path(token: str) -> bool:
|
|
508
|
+
"""Return whether *token* contains unsafe shell path patterns."""
|
|
509
|
+
if not token:
|
|
510
|
+
return False
|
|
511
|
+
# Absolute paths
|
|
512
|
+
if token.startswith("/") or token.startswith("\\"):
|
|
513
|
+
return True
|
|
514
|
+
# Windows absolute paths (C:\...)
|
|
515
|
+
if len(token) >= 2 and token[1] == ":" and token[0].isalpha():
|
|
516
|
+
return True
|
|
517
|
+
# Home expansion
|
|
518
|
+
if token.startswith("~"):
|
|
519
|
+
return True
|
|
520
|
+
# Shell variables, wildcards, braces
|
|
521
|
+
if _UNSAFE_PATH_RE.search(token):
|
|
522
|
+
return True
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _complete_relative_path(
|
|
527
|
+
partial: str,
|
|
528
|
+
cwd: str,
|
|
529
|
+
*,
|
|
530
|
+
start: int,
|
|
531
|
+
end: int,
|
|
532
|
+
ignored: frozenset[str],
|
|
533
|
+
max_results: int,
|
|
534
|
+
) -> list[CompletionItem]:
|
|
535
|
+
"""Generate completions for a relative filesystem path."""
|
|
536
|
+
base = Path(cwd)
|
|
537
|
+
|
|
538
|
+
if "/" in partial or "\\" in partial:
|
|
539
|
+
sep_pos = max(partial.rfind("/"), partial.rfind("\\"))
|
|
540
|
+
dir_part = partial[:sep_pos + 1]
|
|
541
|
+
name_prefix = partial[sep_pos + 1:]
|
|
542
|
+
scan_dir = base / dir_part.replace("\\", "/")
|
|
543
|
+
else:
|
|
544
|
+
dir_part = ""
|
|
545
|
+
name_prefix = partial
|
|
546
|
+
scan_dir = base
|
|
547
|
+
|
|
548
|
+
if not scan_dir.is_dir():
|
|
549
|
+
return []
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
entries = sorted(scan_dir.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
553
|
+
except OSError:
|
|
554
|
+
return []
|
|
555
|
+
|
|
556
|
+
low = name_prefix.lower()
|
|
557
|
+
items: list[CompletionItem] = []
|
|
558
|
+
|
|
559
|
+
for entry in entries:
|
|
560
|
+
if len(items) >= max_results:
|
|
561
|
+
break
|
|
562
|
+
if is_ignored_dir(entry.name, ignored) and entry.is_dir():
|
|
563
|
+
continue
|
|
564
|
+
if entry.name.startswith("."):
|
|
565
|
+
continue
|
|
566
|
+
if not entry.name.lower().startswith(low):
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
if entry.is_dir():
|
|
570
|
+
replacement = dir_part + entry.name + "/"
|
|
571
|
+
desc = "directory"
|
|
572
|
+
else:
|
|
573
|
+
replacement = dir_part + entry.name
|
|
574
|
+
desc = _file_description(entry)
|
|
575
|
+
|
|
576
|
+
items.append(CompletionItem(
|
|
577
|
+
display=replacement,
|
|
578
|
+
replacement=replacement,
|
|
579
|
+
start=start,
|
|
580
|
+
end=end,
|
|
581
|
+
description=desc,
|
|
582
|
+
category="path",
|
|
583
|
+
))
|
|
584
|
+
|
|
585
|
+
return _sort_items(items, partial)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# ── context detection ────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _detect_command_arg_context(
|
|
592
|
+
text: str,
|
|
593
|
+
registry: CommandRegistry,
|
|
594
|
+
) -> tuple[str, str, int, int] | None:
|
|
595
|
+
"""Detect whether *text* is a command followed by a partial argument."""
|
|
596
|
+
if not text.startswith("/"):
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
parts = text.split(None, 1)
|
|
600
|
+
if len(parts) < 2:
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
cmd_name = parts[0][1:].lower()
|
|
604
|
+
cmd = registry.get(cmd_name)
|
|
605
|
+
if cmd is None:
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
arg_text = parts[1]
|
|
609
|
+
arg_start = len(text) - len(arg_text)
|
|
610
|
+
|
|
611
|
+
# The active token within the arg text
|
|
612
|
+
token, t_start, t_end = _last_token(arg_text)
|
|
613
|
+
return cmd.name, token, arg_start + t_start, arg_start + t_end
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ── argument option registry ─────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
_ARGUMENT_OPTIONS: dict[str, list[CompletionOption]] = {
|
|
620
|
+
"model": [
|
|
621
|
+
CompletionOption("claude-sonnet-4-20250514", "Claude Sonnet 4"),
|
|
622
|
+
CompletionOption("claude-opus-4-20250514", "Claude Opus 4"),
|
|
623
|
+
CompletionOption("claude-haiku-4-5-20251001", "Claude Haiku 4.5"),
|
|
624
|
+
],
|
|
625
|
+
"export": [
|
|
626
|
+
CompletionOption("text", "Plain text transcript"),
|
|
627
|
+
CompletionOption("json", "JSON format"),
|
|
628
|
+
CompletionOption("jsonl", "JSON Lines format"),
|
|
629
|
+
],
|
|
630
|
+
"think": [
|
|
631
|
+
CompletionOption("off", "Disable thinking"),
|
|
632
|
+
CompletionOption("low", "Low thinking budget"),
|
|
633
|
+
CompletionOption("medium", "Medium thinking budget"),
|
|
634
|
+
CompletionOption("high", "High thinking budget"),
|
|
635
|
+
],
|
|
636
|
+
"login": [
|
|
637
|
+
CompletionOption("anthropic", "Anthropic API"),
|
|
638
|
+
CompletionOption("openai", "OpenAI-compatible API"),
|
|
639
|
+
],
|
|
640
|
+
"logout": [
|
|
641
|
+
CompletionOption("anthropic", "Anthropic API"),
|
|
642
|
+
CompletionOption("openai", "OpenAI-compatible API"),
|
|
643
|
+
],
|
|
644
|
+
"theme": [
|
|
645
|
+
CompletionOption("dark", "Dark theme"),
|
|
646
|
+
CompletionOption("light", "Light theme"),
|
|
647
|
+
],
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def get_argument_options(
|
|
652
|
+
command_name: str,
|
|
653
|
+
extra: dict[str, list[CompletionOption]] | None = None,
|
|
654
|
+
) -> list[CompletionOption]:
|
|
655
|
+
"""Return the argument options for *command_name*."""
|
|
656
|
+
options = list((extra or {}).get(command_name, _ARGUMENT_OPTIONS.get(command_name, [])))
|
|
657
|
+
return options
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ── main API ─────────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def build_completion_state(
|
|
664
|
+
text: str,
|
|
665
|
+
*,
|
|
666
|
+
cursor: int | None = None,
|
|
667
|
+
registry: CommandRegistry | None = None,
|
|
668
|
+
skills: Sequence[Skill] = (),
|
|
669
|
+
templates: Sequence[PromptTemplate] = (),
|
|
670
|
+
cwd: str | None = None,
|
|
671
|
+
argument_options: dict[str, list[CompletionOption]] | None = None,
|
|
672
|
+
ignored_dirs: frozenset[str] = _IGNORED_DIRS_DEFAULT,
|
|
673
|
+
max_results: int = _MAX_RESULTS_DEFAULT,
|
|
674
|
+
) -> CompletionState:
|
|
675
|
+
"""Inspect *text* and return a ``CompletionState`` with matching candidates."""
|
|
676
|
+
if cursor is None:
|
|
677
|
+
cursor = len(text)
|
|
678
|
+
|
|
679
|
+
resolved_cwd = cwd or os.getcwd()
|
|
680
|
+
items: list[CompletionItem] = []
|
|
681
|
+
|
|
682
|
+
# ── shell paths (! or !! prefix) ─────────────────────────────────
|
|
683
|
+
if _is_shell_input(text):
|
|
684
|
+
items = complete_shell_paths(
|
|
685
|
+
text, resolved_cwd,
|
|
686
|
+
ignored=ignored_dirs, max_results=max_results,
|
|
687
|
+
)
|
|
688
|
+
return CompletionState(items=tuple(items))
|
|
689
|
+
|
|
690
|
+
# ── file references (@...) ───────────────────────────────────────
|
|
691
|
+
token, tok_start, tok_end = _active_token(text, cursor)
|
|
692
|
+
if token.startswith(_FILE_REF_PREFIX) and len(token) > 1:
|
|
693
|
+
items = complete_file_refs(
|
|
694
|
+
token, resolved_cwd,
|
|
695
|
+
start=tok_start, end=tok_end,
|
|
696
|
+
ignored=ignored_dirs, max_results=max_results,
|
|
697
|
+
)
|
|
698
|
+
return CompletionState(items=tuple(items))
|
|
699
|
+
|
|
700
|
+
# ── skill completion (/skill:...) ────────────────────────────────
|
|
701
|
+
if token.lower().startswith(_SKILL_PREFIX) and skills:
|
|
702
|
+
items = complete_skills(
|
|
703
|
+
token, skills,
|
|
704
|
+
start=tok_start, end=tok_end, max_results=max_results,
|
|
705
|
+
)
|
|
706
|
+
return CompletionState(items=tuple(items))
|
|
707
|
+
|
|
708
|
+
# ── command argument completion (/cmd arg) ───────────────────────
|
|
709
|
+
if registry is not None:
|
|
710
|
+
ctx = _detect_command_arg_context(text, registry)
|
|
711
|
+
if ctx is not None:
|
|
712
|
+
cmd_name, arg_prefix, arg_start, arg_end = ctx
|
|
713
|
+
opts = get_argument_options(cmd_name, argument_options)
|
|
714
|
+
if opts:
|
|
715
|
+
items = complete_arguments(
|
|
716
|
+
cmd_name, arg_prefix, opts,
|
|
717
|
+
start=arg_start, end=arg_end, max_results=max_results,
|
|
718
|
+
)
|
|
719
|
+
return CompletionState(items=tuple(items))
|
|
720
|
+
|
|
721
|
+
# ── slash command + template completion (/...) ───────────────────
|
|
722
|
+
if token.startswith("/") and len(token) > 1:
|
|
723
|
+
if registry is not None:
|
|
724
|
+
items.extend(complete_commands(
|
|
725
|
+
token, registry,
|
|
726
|
+
start=tok_start, end=tok_end, max_results=max_results,
|
|
727
|
+
))
|
|
728
|
+
if templates:
|
|
729
|
+
items.extend(complete_prompts(
|
|
730
|
+
token, templates,
|
|
731
|
+
start=tok_start, end=tok_end, max_results=max_results,
|
|
732
|
+
))
|
|
733
|
+
items = _dedupe_items(_sort_items(items, token))
|
|
734
|
+
if len(items) > max_results:
|
|
735
|
+
items = items[:max_results]
|
|
736
|
+
return CompletionState(items=tuple(items))
|
|
737
|
+
|
|
738
|
+
return CompletionState()
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _is_shell_input(text: str) -> bool:
|
|
742
|
+
"""Return whether *text* starts with a shell prefix."""
|
|
743
|
+
return any(text.startswith(p) for p in _SHELL_PREFIXES)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
# ── registration hook ────────────────────────────────────────────────────
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class CompletionProvider:
|
|
750
|
+
"""Hook point for registering custom completion providers."""
|
|
751
|
+
|
|
752
|
+
def complete(
|
|
753
|
+
self,
|
|
754
|
+
text: str,
|
|
755
|
+
cursor: int,
|
|
756
|
+
*,
|
|
757
|
+
start: int,
|
|
758
|
+
end: int,
|
|
759
|
+
) -> list[CompletionItem]:
|
|
760
|
+
"""Return completions for the given input state."""
|
|
761
|
+
return []
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
__all__ = [
|
|
765
|
+
"CompletionItem",
|
|
766
|
+
"CompletionOption",
|
|
767
|
+
"CompletionProvider",
|
|
768
|
+
"CompletionState",
|
|
769
|
+
"build_completion_state",
|
|
770
|
+
"complete_arguments",
|
|
771
|
+
"complete_commands",
|
|
772
|
+
"complete_file_refs",
|
|
773
|
+
"complete_prompts",
|
|
774
|
+
"complete_shell_paths",
|
|
775
|
+
"complete_skills",
|
|
776
|
+
"get_argument_options",
|
|
777
|
+
"is_ignored_dir",
|
|
778
|
+
]
|