rpr-cli 0.1.1__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 (97) hide show
  1. rpr/__init__.py +1 -0
  2. rpr/agent/__init__.py +29 -0
  3. rpr/agent/approval.py +19 -0
  4. rpr/agent/bootstrap.py +60 -0
  5. rpr/agent/client.py +115 -0
  6. rpr/agent/control.py +16 -0
  7. rpr/agent/mock.py +158 -0
  8. rpr/agent/runtime.py +291 -0
  9. rpr/agent/session.py +91 -0
  10. rpr/agent/tools/__init__.py +10 -0
  11. rpr/agent/tools/base.py +62 -0
  12. rpr/agent/tools/mutating.py +47 -0
  13. rpr/agent/tools/readonly.py +147 -0
  14. rpr/agent/tools/registry.py +30 -0
  15. rpr/application/__init__.py +1 -0
  16. rpr/application/catalog.py +427 -0
  17. rpr/application/chat_service.py +413 -0
  18. rpr/application/checks.py +49 -0
  19. rpr/application/cli_adapter.py +36 -0
  20. rpr/application/completer.py +163 -0
  21. rpr/application/conversation_service.py +92 -0
  22. rpr/application/prompt_service.py +85 -0
  23. rpr/application/selector.py +240 -0
  24. rpr/application/shell.py +543 -0
  25. rpr/checks/__init__.py +0 -0
  26. rpr/checks/base.py +13 -0
  27. rpr/checks/instructions.py +89 -0
  28. rpr/checks/packages.py +619 -0
  29. rpr/checks/workspace.py +170 -0
  30. rpr/cli.py +78 -0
  31. rpr/commands/__init__.py +0 -0
  32. rpr/commands/add.py +166 -0
  33. rpr/commands/chat.py +48 -0
  34. rpr/commands/check.py +53 -0
  35. rpr/commands/generate/__init__.py +0 -0
  36. rpr/commands/generate/api.py +228 -0
  37. rpr/commands/generate/domain.py +383 -0
  38. rpr/commands/generate/engine.py +148 -0
  39. rpr/commands/generate/storybook.py +442 -0
  40. rpr/commands/generate/ui.py +414 -0
  41. rpr/commands/init.py +822 -0
  42. rpr/commands/map.py +113 -0
  43. rpr/commands/settings.py +102 -0
  44. rpr/commands/sync.py +97 -0
  45. rpr/context.py +203 -0
  46. rpr/generators/__init__.py +0 -0
  47. rpr/generators/base.py +110 -0
  48. rpr/generators/claude.py +33 -0
  49. rpr/generators/copilot.py +36 -0
  50. rpr/generators/cursor.py +38 -0
  51. rpr/generators/gemini.py +33 -0
  52. rpr/map/__init__.py +0 -0
  53. rpr/map/architecture.py +495 -0
  54. rpr/map/chains.py +317 -0
  55. rpr/map/classifier.py +170 -0
  56. rpr/map/coverage.py +200 -0
  57. rpr/map/dependencies.py +243 -0
  58. rpr/map/extractor.py +223 -0
  59. rpr/map/graph.py +318 -0
  60. rpr/map/output.py +1030 -0
  61. rpr/map/responsibility.py +345 -0
  62. rpr/map/topology.py +327 -0
  63. rpr/map/walker.py +151 -0
  64. rpr/scaffolds/domain/base_entity.md +30 -0
  65. rpr/scaffolds/domain/base_repo.md +48 -0
  66. rpr/scaffolds/domain/container.md +76 -0
  67. rpr/scaffolds/domain/settings.md +57 -0
  68. rpr/scaffolds/instructions/all.instructions.md +50 -0
  69. rpr/scaffolds/instructions/api.instructions.md +42 -0
  70. rpr/scaffolds/instructions/domain.instructions.md +93 -0
  71. rpr/scaffolds/instructions/frontend.instructions.md +97 -0
  72. rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
  73. rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
  74. rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
  75. rpr/scaffolds/instructions/tooling.instructions.md +42 -0
  76. rpr/scaffolds/js_special_files/fetch.service.md +222 -0
  77. rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
  78. rpr/scaffolds/special_files/domain_container.md +76 -0
  79. rpr/scaffolds/special_files/domain_settings.md +57 -0
  80. rpr/scaffolds/special_files/dto_util.md +62 -0
  81. rpr/scaffolds/special_files/encrypted_column.md +98 -0
  82. rpr/scaffolds/special_files/mapper_util.md +159 -0
  83. rpr/scaffolds/special_files/partial_update.md +61 -0
  84. rpr/templates/__init__.py +0 -0
  85. rpr/templates/registry.py +81 -0
  86. rpr/ui/__init__.py +0 -0
  87. rpr/ui/console.py +32 -0
  88. rpr/ui/markdown.py +59 -0
  89. rpr/ui/prompt_session.py +430 -0
  90. rpr/ui/renderers.py +167 -0
  91. rpr/ui/theme.py +286 -0
  92. rpr/workspace.py +131 -0
  93. rpr_cli-0.1.1.dist-info/METADATA +201 -0
  94. rpr_cli-0.1.1.dist-info/RECORD +97 -0
  95. rpr_cli-0.1.1.dist-info/WHEEL +4 -0
  96. rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
  97. rpr_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,413 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections import deque
5
+ from contextlib import suppress
6
+ from dataclasses import dataclass
7
+ from typing import Callable
8
+ from typing import ClassVar
9
+
10
+ from prompt_toolkit import print_formatted_text
11
+ from prompt_toolkit.application.current import get_app_session
12
+ from prompt_toolkit.formatted_text import FormattedText
13
+
14
+ from rpr.agent.approval import ApprovalDecision, ApprovalMode
15
+ from rpr.agent.bootstrap import build_chat_runtime_bundle
16
+ from rpr.agent.client import AgentClient
17
+ from rpr.agent.control import TurnControl
18
+ from rpr.agent.runtime import RuntimeCallbacks
19
+ from rpr.agent.session import ToolCallRecord
20
+ from rpr.application.completer import (
21
+ FileContextCompleter,
22
+ MultiCompleter,
23
+ SlashCommandCandidate,
24
+ SlashCommandCompleter,
25
+ )
26
+ from rpr.application.prompt_service import PromptService
27
+ from rpr.application.shell import CommandShellService, ShellPresenter
28
+ from rpr.context import RunContext
29
+ from rpr.ui.console import console, refresh_console
30
+ from rpr.ui.renderers import (
31
+ render_approval_prompt,
32
+ render_chat_hint,
33
+ render_error,
34
+ render_heading,
35
+ render_info,
36
+ render_session_header,
37
+ render_status_row,
38
+ render_success,
39
+ render_table,
40
+ render_tool_call,
41
+ render_tool_result,
42
+ render_warning,
43
+ )
44
+ from rpr.ui.theme import SettingsManager
45
+
46
+ type AgentClientFactory = Callable[[str | None, str | None], AgentClient]
47
+ type ContextFactory = Callable[[], RunContext]
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class AssistantStreamRenderer:
52
+ _accumulated: str = ""
53
+ _lines_printed: int = 0
54
+
55
+ def append(self, chunk: str) -> None:
56
+ self._accumulated += chunk
57
+ output = get_app_session().output
58
+ if self._lines_printed > 0:
59
+ output.cursor_up(self._lines_printed)
60
+ output.erase_down()
61
+ output.flush()
62
+ print_formatted_text(
63
+ FormattedText(
64
+ [
65
+ ("", "\n"),
66
+ ("bold fg:ansiblue", "Assistant >"),
67
+ ("", "\n\n"),
68
+ ("", self._accumulated),
69
+ ]
70
+ ),
71
+ )
72
+ # 1 (leading \n) + 1 ("Assistant >") + 2 (\n\n) + content newlines + 1 (end=\n)
73
+ self._lines_printed = 4 + self._accumulated.count("\n")
74
+
75
+ def flush(self) -> None:
76
+ self._accumulated = ""
77
+ self._lines_printed = 0
78
+
79
+
80
+ @dataclass(slots=True)
81
+ class RichShellPresenter(ShellPresenter):
82
+ stream_renderer: AssistantStreamRenderer
83
+
84
+ def flush(self) -> None:
85
+ self.stream_renderer.flush()
86
+
87
+ def append_text(self, chunk: str) -> None:
88
+ self.stream_renderer.append(chunk)
89
+
90
+ def heading(self, text: str) -> None:
91
+ self.flush()
92
+ render_heading(text)
93
+
94
+ def info(self, message: str) -> None:
95
+ self.flush()
96
+ render_info(message)
97
+
98
+ def success(self, message: str) -> None:
99
+ self.flush()
100
+ render_success(message)
101
+
102
+ def warning(self, message: str) -> None:
103
+ self.flush()
104
+ render_warning(message)
105
+
106
+ def error(self, message: str) -> None:
107
+ self.flush()
108
+ render_error(message)
109
+
110
+ def table(self, title: str, columns: list[str], rows: list[list[str]]) -> None:
111
+ self.flush()
112
+ render_table(title, columns, rows)
113
+
114
+ def status_row(
115
+ self,
116
+ icon_char: str,
117
+ area: str,
118
+ path: str,
119
+ status: bool,
120
+ suggestion: str | None = None,
121
+ ) -> None:
122
+ self.flush()
123
+ render_status_row(icon_char, area, path, status, suggestion)
124
+
125
+ def on_setting_changed(self, key: str) -> None:
126
+ self.flush()
127
+ if key == "theme":
128
+ refresh_console()
129
+
130
+
131
+ class ChatCommandService:
132
+ _instance: ClassVar[ChatCommandService | None] = None
133
+
134
+ def __init__(self) -> None:
135
+ self._prompt_service = PromptService.instance()
136
+ self._shell_service = CommandShellService.instance()
137
+
138
+ @classmethod
139
+ def instance(cls) -> ChatCommandService:
140
+ if cls._instance is None:
141
+ cls._instance = cls()
142
+ return cls._instance
143
+
144
+ async def run(
145
+ self,
146
+ *,
147
+ provider: str | None,
148
+ model: str | None,
149
+ approval_mode_override: str | None,
150
+ client_factory: AgentClientFactory,
151
+ context_factory: ContextFactory,
152
+ settings: SettingsManager,
153
+ ) -> None:
154
+ ctx = context_factory()
155
+ client = client_factory(provider, model)
156
+ resolved_approval_mode = self._resolve_approval_mode(
157
+ approval_mode_override, settings
158
+ )
159
+ conversation = self._prompt_service.create_conversation(
160
+ project_dir=ctx.project_dir,
161
+ approval_mode=resolved_approval_mode,
162
+ )
163
+ presenter = RichShellPresenter(stream_renderer=AssistantStreamRenderer())
164
+
165
+ # Define these before bundle creation so the approval closure captures them.
166
+ message_queue: deque[str] = deque()
167
+
168
+ def _update_queue_display() -> None:
169
+ preview = message_queue[0] if message_queue else None
170
+ self._prompt_service.set_queue_info(len(message_queue), preview)
171
+
172
+ async def _request_approval(record: ToolCallRecord) -> ApprovalDecision:
173
+ presenter.flush()
174
+ render_approval_prompt(record.tool_name)
175
+ response = await self._prompt_service.read_input(
176
+ "Approve? [a]pprove/[r]eject/[s]ession > ",
177
+ modal=True,
178
+ )
179
+ normalized = response.lower()
180
+ if normalized in {"a", "approve"}:
181
+ return ApprovalDecision.APPROVE
182
+ if normalized in {"s", "session", "always"}:
183
+ return ApprovalDecision.ALWAYS_ALLOW
184
+ return ApprovalDecision.REJECT
185
+
186
+ bundle = self._build_runtime_bundle(
187
+ ctx=ctx,
188
+ client=client,
189
+ approval_mode=resolved_approval_mode,
190
+ presenter=presenter,
191
+ session=conversation,
192
+ request_approval=_request_approval,
193
+ )
194
+ self._shell_service.configure(
195
+ bundle=bundle,
196
+ presenter=presenter,
197
+ prompts=self._prompt_service,
198
+ )
199
+ candidates = [
200
+ SlashCommandCandidate(
201
+ name=spec.name,
202
+ description=spec.description,
203
+ usage=spec.usage,
204
+ )
205
+ for spec in self._shell_service.command_specs
206
+ ]
207
+ self._prompt_service.set_completer(
208
+ MultiCompleter(
209
+ [
210
+ SlashCommandCompleter(candidates),
211
+ FileContextCompleter(ctx.project_dir),
212
+ ]
213
+ )
214
+ )
215
+
216
+ render_session_header(
217
+ project_root=ctx.project_dir,
218
+ provider=client.provider_name,
219
+ model=client.model_name,
220
+ approval_mode=bundle.session.approval_mode.value,
221
+ )
222
+ if settings.startup_help:
223
+ render_chat_hint(
224
+ "Type /help to inspect slash commands and /exit to end the session."
225
+ )
226
+
227
+ active_task: asyncio.Task | None = None
228
+
229
+ def _dispatch_agent_turn(message: str) -> None:
230
+ nonlocal active_task
231
+ control = TurnControl()
232
+ self._prompt_service.set_active_control(control)
233
+
234
+ async def _run_turn() -> None:
235
+ try:
236
+ await self._shell_service.handle_input(message, control=control)
237
+ presenter.flush()
238
+ self._prompt_service.save_conversation(bundle.session)
239
+ except asyncio.CancelledError:
240
+ presenter.flush()
241
+ render_chat_hint("Response cancelled.")
242
+ raise
243
+ except Exception as exc:
244
+ presenter.flush()
245
+ render_error(f"Error: {exc}")
246
+
247
+ task = asyncio.create_task(_run_turn())
248
+ active_task = task
249
+ self._prompt_service.set_active_task(task)
250
+
251
+ def _on_done(fut: asyncio.Task) -> None:
252
+ nonlocal active_task
253
+ active_task = None
254
+ self._prompt_service.set_active_task(None)
255
+ self._prompt_service.set_active_control(None)
256
+ if message_queue:
257
+ next_msg = message_queue.popleft()
258
+ _update_queue_display()
259
+ _dispatch_agent_turn(next_msg)
260
+ else:
261
+ _update_queue_display()
262
+
263
+ task.add_done_callback(_on_done)
264
+
265
+ try:
266
+ while True:
267
+ try:
268
+ if active_task is not None and not active_task.done():
269
+ await asyncio.sleep(0)
270
+ while (
271
+ active_task is not None
272
+ and not active_task.done()
273
+ and self._prompt_service.has_modal_input()
274
+ ):
275
+ await asyncio.sleep(0.01)
276
+
277
+ user_input = await self._prompt_service.read_input(
278
+ "> ", multiline=True
279
+ )
280
+ if not user_input:
281
+ continue
282
+
283
+ normalized = user_input.strip()
284
+ is_slash = normalized.startswith("/") or normalized.lower() in {
285
+ "exit",
286
+ "quit",
287
+ }
288
+
289
+ if is_slash:
290
+ try:
291
+ should_continue = await self._shell_service.handle_input(
292
+ normalized
293
+ )
294
+ except Exception as exc:
295
+ presenter.flush()
296
+ render_error(f"Error: {exc}")
297
+ else:
298
+ presenter.flush()
299
+ self._prompt_service.save_conversation(bundle.session)
300
+ if not should_continue:
301
+ if active_task is not None and not active_task.done():
302
+ with suppress(asyncio.CancelledError, Exception):
303
+ await active_task
304
+ break
305
+ else:
306
+ if active_task is not None and not active_task.done():
307
+ message_queue.append(normalized)
308
+ _update_queue_display()
309
+ else:
310
+ _dispatch_agent_turn(normalized)
311
+
312
+ except KeyboardInterrupt, EOFError:
313
+ if active_task is not None and not active_task.done():
314
+ active_task.cancel()
315
+ presenter.flush()
316
+ render_chat_hint("Session ended.")
317
+ break
318
+ finally:
319
+ if active_task is not None and not active_task.done():
320
+ active_task.cancel()
321
+ with suppress(asyncio.CancelledError):
322
+ await active_task
323
+ presenter.flush()
324
+ self._prompt_service.save_conversation(bundle.session)
325
+
326
+ render_chat_hint("Goodbye!")
327
+
328
+ def _build_runtime_bundle(
329
+ self,
330
+ *,
331
+ ctx: RunContext,
332
+ client: AgentClient,
333
+ approval_mode: ApprovalMode,
334
+ presenter: RichShellPresenter,
335
+ session,
336
+ request_approval=None,
337
+ ):
338
+ callbacks = RuntimeCallbacks(
339
+ on_text_delta=presenter.append_text,
340
+ on_tool_call=lambda record: self._handle_tool_call_render(
341
+ record, presenter.stream_renderer
342
+ ),
343
+ on_tool_result=lambda record, result: self._handle_tool_result_render(
344
+ record, result.output, result.success
345
+ ),
346
+ request_approval=request_approval
347
+ or (lambda record: self._prompt_for_approval(record, presenter=presenter)),
348
+ on_attachment_notice=lambda message: self._handle_attachment_notice(
349
+ message, presenter.stream_renderer
350
+ ),
351
+ )
352
+ return build_chat_runtime_bundle(
353
+ ctx=ctx,
354
+ client=client,
355
+ approval_mode=approval_mode,
356
+ callbacks=callbacks,
357
+ session=session,
358
+ )
359
+
360
+ def _handle_tool_call_render(
361
+ self, record: ToolCallRecord, stream_renderer: AssistantStreamRenderer
362
+ ) -> None:
363
+ stream_renderer.flush()
364
+ render_tool_call(
365
+ record.tool_name, dict(record.arguments), is_mutating=record.is_mutating
366
+ )
367
+
368
+ @staticmethod
369
+ def _handle_tool_result_render(
370
+ record: ToolCallRecord, output: str, success: bool
371
+ ) -> None:
372
+ render_tool_result(record.tool_name, output, success=success)
373
+
374
+ @staticmethod
375
+ def _handle_attachment_notice(
376
+ message: str, stream_renderer: AssistantStreamRenderer
377
+ ) -> None:
378
+ stream_renderer.flush()
379
+ render_chat_hint(message)
380
+
381
+ async def _prompt_for_approval(
382
+ self,
383
+ record: ToolCallRecord,
384
+ *,
385
+ presenter: RichShellPresenter,
386
+ ) -> ApprovalDecision:
387
+ presenter.flush()
388
+ render_approval_prompt(record.tool_name)
389
+ response = await self._prompt_service.read_input(
390
+ "Approve? [a]pprove/[r]eject/[s]ession > ",
391
+ modal=True,
392
+ )
393
+ normalized = response.lower()
394
+ if normalized in {"a", "approve"}:
395
+ return ApprovalDecision.APPROVE
396
+ if normalized in {"s", "session", "always"}:
397
+ return ApprovalDecision.ALWAYS_ALLOW
398
+ return ApprovalDecision.REJECT
399
+
400
+ @staticmethod
401
+ def _resolve_approval_mode(
402
+ approval_mode_override: str | None,
403
+ settings: SettingsManager,
404
+ ) -> ApprovalMode:
405
+ if approval_mode_override is None:
406
+ return settings.approval_mode
407
+
408
+ try:
409
+ return ApprovalMode(approval_mode_override.lower())
410
+ except ValueError as exc:
411
+ raise ValueError(
412
+ "Approval mode must be ask, auto-readonly, or auto-all."
413
+ ) from exc
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from rpr.checks.instructions import check_instructions
7
+ from rpr.checks.packages import check_packages
8
+ from rpr.checks.workspace import check_workspace
9
+ from rpr.generators.base import SyncConfig
10
+
11
+
12
+ @dataclass(slots=True, frozen=True)
13
+ class CheckReport:
14
+ project_name: str
15
+ results: list[object]
16
+
17
+
18
+ def collect_check_report(project_dir: Path, group: str | None = None) -> CheckReport:
19
+ config_path = project_dir / ".rpr.yaml"
20
+ config = SyncConfig.load(config_path)
21
+
22
+ variables = config.variables or {}
23
+ project_name = variables.get("name", "(unknown)")
24
+ requested_group = None if group is None else group.lower()
25
+ results: list[object] = []
26
+
27
+ if requested_group in {None, "workspace"}:
28
+ results.extend(check_workspace(project_dir))
29
+
30
+ if requested_group in {None, "instructions"}:
31
+ results.extend(check_instructions(project_dir, variables))
32
+
33
+ if requested_group in {None, "packages"}:
34
+ results.extend(check_packages(project_dir, variables))
35
+
36
+ return CheckReport(project_name=project_name, results=results)
37
+
38
+
39
+ def serialize_check_results(results: list[object]) -> list[dict[str, object]]:
40
+ return [
41
+ {
42
+ "group": result.group,
43
+ "area": result.area,
44
+ "status": result.status,
45
+ "path": result.path,
46
+ "suggestion": result.suggestion,
47
+ }
48
+ for result in results
49
+ ]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from rpr.application.catalog import COMMAND_CATALOG, CommandDefinition
4
+
5
+
6
+ def get_cli_definition(action_id: str) -> CommandDefinition | None:
7
+ """Look up a CommandDefinition by action_id across all CLI-enabled commands.
8
+
9
+ Searches recursively through the full catalog tree. Returns the first match
10
+ where ``cmd.cli_enabled`` is True and ``cmd.action_id == action_id``.
11
+
12
+ Example::
13
+
14
+ get_cli_definition("generate.ui") # → CommandDefinition(name="ui", ...)
15
+ get_cli_definition("chat.exit") # → None (cli_enabled=False)
16
+ """
17
+
18
+ def _search(cmds: tuple[CommandDefinition, ...]) -> CommandDefinition | None:
19
+ for cmd in cmds:
20
+ if cmd.cli_enabled and cmd.action_id == action_id:
21
+ return cmd
22
+ if cmd.children:
23
+ found = _search(cmd.children)
24
+ if found is not None:
25
+ return found
26
+ return None
27
+
28
+ return _search(COMMAND_CATALOG)
29
+
30
+
31
+ def default_root_command() -> CommandDefinition | None:
32
+ """Return the top-level command flagged as default_root_command, if any."""
33
+ for cmd in COMMAND_CATALOG:
34
+ if cmd.default_root_command:
35
+ return cmd
36
+ return None
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Sequence
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
8
+ from prompt_toolkit.document import Document
9
+
10
+ from rpr.application.selector import TriggerSelectorCandidate, TriggerSelectorSource
11
+ from rpr.map.walker import list_project_entries
12
+
13
+
14
+ @dataclass(slots=True, frozen=True)
15
+ class SlashCommandCandidate:
16
+ name: str
17
+ description: str
18
+ usage: str
19
+
20
+
21
+ @dataclass(slots=True, frozen=True)
22
+ class FileContextCandidate:
23
+ path: str
24
+ is_dir: bool
25
+
26
+
27
+ class SlashCommandCompleter(Completer):
28
+ def __init__(self, candidates: Sequence[SlashCommandCandidate]) -> None:
29
+ self._candidates = candidates
30
+
31
+ @property
32
+ def selector_source(self) -> TriggerSelectorSource:
33
+ return TriggerSelectorSource(
34
+ trigger="/",
35
+ candidates=tuple(
36
+ TriggerSelectorCandidate(
37
+ value=candidate.name,
38
+ label=f"/{candidate.name}",
39
+ description=candidate.description,
40
+ insert_text=f"/{candidate.name} ",
41
+ detail=candidate.usage,
42
+ )
43
+ for candidate in self._candidates
44
+ ),
45
+ )
46
+
47
+ def get_completions(
48
+ self,
49
+ document: Document,
50
+ complete_event: CompleteEvent,
51
+ ) -> Iterable[Completion]:
52
+ text = document.text_before_cursor
53
+ if not text.startswith("/"):
54
+ return
55
+ partial = text[1:]
56
+ for candidate in self._candidates:
57
+ if candidate.name.startswith(partial):
58
+ yield Completion(
59
+ text=candidate.name,
60
+ start_position=-len(partial),
61
+ display=f"/{candidate.name}",
62
+ display_meta=candidate.description,
63
+ )
64
+
65
+
66
+ class FileContextCompleter(Completer):
67
+ def __init__(self, root: Path) -> None:
68
+ self._root = root
69
+ self._candidates = self._load_candidates()
70
+
71
+ @property
72
+ def selector_source(self) -> TriggerSelectorSource:
73
+ return TriggerSelectorSource(
74
+ trigger="@",
75
+ anchored_to_start=False,
76
+ hide_on_exact_match=True,
77
+ case_sensitive=False,
78
+ candidates=tuple(
79
+ TriggerSelectorCandidate(
80
+ value=candidate.path,
81
+ label=candidate.path,
82
+ description="Directory" if candidate.is_dir else "File",
83
+ insert_text=f"@{candidate.path}",
84
+ detail=str(self._root / candidate.path.rstrip("/")),
85
+ keep_open_on_exact_match=candidate.is_dir,
86
+ )
87
+ for candidate in self._candidates
88
+ ),
89
+ )
90
+
91
+ def get_completions(
92
+ self,
93
+ document: Document,
94
+ complete_event: CompleteEvent,
95
+ ) -> Iterable[Completion]:
96
+ del complete_event
97
+ query = self._extract_query(document.text_before_cursor)
98
+ if query is None:
99
+ return
100
+ normalized_query = query.casefold()
101
+ for candidate in self._candidates:
102
+ if candidate.path.casefold().startswith(normalized_query):
103
+ yield Completion(
104
+ text=candidate.path,
105
+ start_position=-len(query),
106
+ display=candidate.path,
107
+ display_meta="Directory" if candidate.is_dir else "File",
108
+ )
109
+
110
+ def _load_candidates(self) -> tuple[FileContextCandidate, ...]:
111
+ entries = list_project_entries(self._root)
112
+ return tuple(
113
+ FileContextCandidate(
114
+ path=f"{entry.rel_path.as_posix()}/"
115
+ if entry.is_dir
116
+ else entry.rel_path.as_posix(),
117
+ is_dir=entry.is_dir,
118
+ )
119
+ for entry in entries
120
+ )
121
+
122
+ @staticmethod
123
+ def _extract_query(text_before_cursor: str) -> str | None:
124
+ trigger_index = text_before_cursor.rfind("@")
125
+ if trigger_index < 0:
126
+ return None
127
+ if trigger_index > 0 and not text_before_cursor[trigger_index - 1].isspace():
128
+ return None
129
+ query = text_before_cursor[trigger_index + 1 :]
130
+ if any(character.isspace() for character in query):
131
+ return None
132
+ return query
133
+
134
+
135
+ class MultiCompleter(Completer):
136
+ def __init__(self, completers: Sequence[Completer]) -> None:
137
+ self._completers = tuple(completers)
138
+
139
+ @property
140
+ def selector_sources(self) -> tuple[TriggerSelectorSource, ...]:
141
+ sources: list[TriggerSelectorSource] = []
142
+ for completer in self._completers:
143
+ selector_sources = getattr(completer, "selector_sources", None)
144
+ if isinstance(selector_sources, tuple):
145
+ sources.extend(
146
+ source
147
+ for source in selector_sources
148
+ if isinstance(source, TriggerSelectorSource)
149
+ )
150
+ continue
151
+
152
+ selector_source = getattr(completer, "selector_source", None)
153
+ if isinstance(selector_source, TriggerSelectorSource):
154
+ sources.append(selector_source)
155
+ return tuple(sources)
156
+
157
+ def get_completions(
158
+ self,
159
+ document: Document,
160
+ complete_event: CompleteEvent,
161
+ ) -> Iterable[Completion]:
162
+ for completer in self._completers:
163
+ yield from completer.get_completions(document, complete_event)