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.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]