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.
- rpr/__init__.py +1 -0
- rpr/agent/__init__.py +29 -0
- rpr/agent/approval.py +19 -0
- rpr/agent/bootstrap.py +60 -0
- rpr/agent/client.py +115 -0
- rpr/agent/control.py +16 -0
- rpr/agent/mock.py +158 -0
- rpr/agent/runtime.py +291 -0
- rpr/agent/session.py +91 -0
- rpr/agent/tools/__init__.py +10 -0
- rpr/agent/tools/base.py +62 -0
- rpr/agent/tools/mutating.py +47 -0
- rpr/agent/tools/readonly.py +147 -0
- rpr/agent/tools/registry.py +30 -0
- rpr/application/__init__.py +1 -0
- rpr/application/catalog.py +427 -0
- rpr/application/chat_service.py +413 -0
- rpr/application/checks.py +49 -0
- rpr/application/cli_adapter.py +36 -0
- rpr/application/completer.py +163 -0
- rpr/application/conversation_service.py +92 -0
- rpr/application/prompt_service.py +85 -0
- rpr/application/selector.py +240 -0
- rpr/application/shell.py +543 -0
- rpr/checks/__init__.py +0 -0
- rpr/checks/base.py +13 -0
- rpr/checks/instructions.py +89 -0
- rpr/checks/packages.py +619 -0
- rpr/checks/workspace.py +170 -0
- rpr/cli.py +78 -0
- rpr/commands/__init__.py +0 -0
- rpr/commands/add.py +166 -0
- rpr/commands/chat.py +48 -0
- rpr/commands/check.py +53 -0
- rpr/commands/generate/__init__.py +0 -0
- rpr/commands/generate/api.py +228 -0
- rpr/commands/generate/domain.py +383 -0
- rpr/commands/generate/engine.py +148 -0
- rpr/commands/generate/storybook.py +442 -0
- rpr/commands/generate/ui.py +414 -0
- rpr/commands/init.py +822 -0
- rpr/commands/map.py +113 -0
- rpr/commands/settings.py +102 -0
- rpr/commands/sync.py +97 -0
- rpr/context.py +203 -0
- rpr/generators/__init__.py +0 -0
- rpr/generators/base.py +110 -0
- rpr/generators/claude.py +33 -0
- rpr/generators/copilot.py +36 -0
- rpr/generators/cursor.py +38 -0
- rpr/generators/gemini.py +33 -0
- rpr/map/__init__.py +0 -0
- rpr/map/architecture.py +495 -0
- rpr/map/chains.py +317 -0
- rpr/map/classifier.py +170 -0
- rpr/map/coverage.py +200 -0
- rpr/map/dependencies.py +243 -0
- rpr/map/extractor.py +223 -0
- rpr/map/graph.py +318 -0
- rpr/map/output.py +1030 -0
- rpr/map/responsibility.py +345 -0
- rpr/map/topology.py +327 -0
- rpr/map/walker.py +151 -0
- rpr/scaffolds/domain/base_entity.md +30 -0
- rpr/scaffolds/domain/base_repo.md +48 -0
- rpr/scaffolds/domain/container.md +76 -0
- rpr/scaffolds/domain/settings.md +57 -0
- rpr/scaffolds/instructions/all.instructions.md +50 -0
- rpr/scaffolds/instructions/api.instructions.md +42 -0
- rpr/scaffolds/instructions/domain.instructions.md +93 -0
- rpr/scaffolds/instructions/frontend.instructions.md +97 -0
- rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
- rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
- rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
- rpr/scaffolds/instructions/tooling.instructions.md +42 -0
- rpr/scaffolds/js_special_files/fetch.service.md +222 -0
- rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
- rpr/scaffolds/special_files/domain_container.md +76 -0
- rpr/scaffolds/special_files/domain_settings.md +57 -0
- rpr/scaffolds/special_files/dto_util.md +62 -0
- rpr/scaffolds/special_files/encrypted_column.md +98 -0
- rpr/scaffolds/special_files/mapper_util.md +159 -0
- rpr/scaffolds/special_files/partial_update.md +61 -0
- rpr/templates/__init__.py +0 -0
- rpr/templates/registry.py +81 -0
- rpr/ui/__init__.py +0 -0
- rpr/ui/console.py +32 -0
- rpr/ui/markdown.py +59 -0
- rpr/ui/prompt_session.py +430 -0
- rpr/ui/renderers.py +167 -0
- rpr/ui/theme.py +286 -0
- rpr/workspace.py +131 -0
- rpr_cli-0.1.1.dist-info/METADATA +201 -0
- rpr_cli-0.1.1.dist-info/RECORD +97 -0
- rpr_cli-0.1.1.dist-info/WHEEL +4 -0
- rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
- rpr_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
rpr/ui/markdown.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from rpr.ui.console import console
|
|
10
|
+
|
|
11
|
+
DEFAULT_MARKDOWN_PREVIEW_LINES = 120
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class MarkdownPreview:
|
|
16
|
+
raw: str
|
|
17
|
+
preview: str
|
|
18
|
+
truncated: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_markdown_preview(
|
|
22
|
+
content: str,
|
|
23
|
+
*,
|
|
24
|
+
max_lines: int | None = DEFAULT_MARKDOWN_PREVIEW_LINES,
|
|
25
|
+
) -> MarkdownPreview:
|
|
26
|
+
if max_lines is None:
|
|
27
|
+
return MarkdownPreview(raw=content, preview=content, truncated=False)
|
|
28
|
+
|
|
29
|
+
lines = content.splitlines()
|
|
30
|
+
if len(lines) <= max_lines:
|
|
31
|
+
return MarkdownPreview(raw=content, preview=content, truncated=False)
|
|
32
|
+
|
|
33
|
+
return MarkdownPreview(
|
|
34
|
+
raw=content, preview="\n".join(lines[:max_lines]), truncated=True
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_markdown(
|
|
39
|
+
content: str,
|
|
40
|
+
*,
|
|
41
|
+
mode: Literal["preview", "raw"] = "preview",
|
|
42
|
+
title: str | None = None,
|
|
43
|
+
use_panel: bool = True,
|
|
44
|
+
max_lines: int | None = DEFAULT_MARKDOWN_PREVIEW_LINES,
|
|
45
|
+
) -> str:
|
|
46
|
+
preview = build_markdown_preview(content, max_lines=max_lines)
|
|
47
|
+
|
|
48
|
+
if mode == "raw":
|
|
49
|
+
return preview.raw
|
|
50
|
+
|
|
51
|
+
renderable = Markdown(preview.preview)
|
|
52
|
+
if use_panel or title is not None:
|
|
53
|
+
renderable = Panel(renderable, title=title, border_style="dim", padding=(0, 1))
|
|
54
|
+
|
|
55
|
+
console.print(renderable)
|
|
56
|
+
if preview.truncated:
|
|
57
|
+
console.print("[warning](preview truncated)[/warning]")
|
|
58
|
+
|
|
59
|
+
return preview.raw
|
rpr/ui/prompt_session.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from prompt_toolkit import PromptSession
|
|
7
|
+
from prompt_toolkit.application.current import get_app_or_none
|
|
8
|
+
from prompt_toolkit.completion import Completer
|
|
9
|
+
from prompt_toolkit.document import Document
|
|
10
|
+
from prompt_toolkit.filters import Condition, is_true, to_filter
|
|
11
|
+
from prompt_toolkit.formatted_text import AnyFormattedText
|
|
12
|
+
from prompt_toolkit.formatted_text.utils import fragment_list_to_text
|
|
13
|
+
from prompt_toolkit.history import History, InMemoryHistory
|
|
14
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
15
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
16
|
+
from prompt_toolkit.layout.layout import Layout
|
|
17
|
+
from prompt_toolkit.layout.processors import (
|
|
18
|
+
Processor,
|
|
19
|
+
Transformation,
|
|
20
|
+
TransformationInput,
|
|
21
|
+
)
|
|
22
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
23
|
+
from prompt_toolkit.styles import Style
|
|
24
|
+
|
|
25
|
+
from rpr.agent.control import TurnControl
|
|
26
|
+
from rpr.application.selector import (
|
|
27
|
+
TriggerSelector,
|
|
28
|
+
TriggerSelectorSnapshot,
|
|
29
|
+
TriggerSelectorSource,
|
|
30
|
+
)
|
|
31
|
+
from rpr.ui.theme import THEMES
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
PROMPT_STYLE = Style.from_dict(
|
|
35
|
+
{
|
|
36
|
+
"bottom-toolbar": f"bg:#111111 #020b0f",
|
|
37
|
+
"bottom-toolbar.text": "",
|
|
38
|
+
"file-ref": f"bold {THEMES['dracula'].path}",
|
|
39
|
+
"selector.rule": "bg:#111111 #6b7280",
|
|
40
|
+
"selector.row": "",
|
|
41
|
+
"selector.row.selected": f"bg:#111111 {THEMES['dracula'].muted} bold",
|
|
42
|
+
"selector.label": "bold",
|
|
43
|
+
"selector.count": "#9ca3af",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileReferenceProcessor(Processor):
|
|
49
|
+
_pattern = re.compile(r"@[A-Za-z0-9_./-]+")
|
|
50
|
+
|
|
51
|
+
def apply_transformation(
|
|
52
|
+
self, transformation_input: TransformationInput
|
|
53
|
+
) -> Transformation:
|
|
54
|
+
line = fragment_list_to_text(transformation_input.fragments)
|
|
55
|
+
if "@" not in line:
|
|
56
|
+
return Transformation(transformation_input.fragments)
|
|
57
|
+
|
|
58
|
+
ranges = [
|
|
59
|
+
match.span()
|
|
60
|
+
for match in self._pattern.finditer(line)
|
|
61
|
+
if match.start() == 0 or line[match.start() - 1].isspace()
|
|
62
|
+
]
|
|
63
|
+
if not ranges:
|
|
64
|
+
return Transformation(transformation_input.fragments)
|
|
65
|
+
|
|
66
|
+
fragments: list[tuple[str, str] | tuple[str, str, object]] = []
|
|
67
|
+
offset = 0
|
|
68
|
+
range_index = 0
|
|
69
|
+
active_range = ranges[range_index]
|
|
70
|
+
|
|
71
|
+
for fragment in transformation_input.fragments:
|
|
72
|
+
style, text, *rest = fragment
|
|
73
|
+
segment_start = 0
|
|
74
|
+
while segment_start < len(text):
|
|
75
|
+
while (
|
|
76
|
+
range_index < len(ranges)
|
|
77
|
+
and offset + segment_start >= active_range[1]
|
|
78
|
+
):
|
|
79
|
+
range_index += 1
|
|
80
|
+
if range_index < len(ranges):
|
|
81
|
+
active_range = ranges[range_index]
|
|
82
|
+
in_highlight = (
|
|
83
|
+
range_index < len(ranges)
|
|
84
|
+
and active_range[0] <= offset + segment_start < active_range[1]
|
|
85
|
+
)
|
|
86
|
+
boundary = (
|
|
87
|
+
active_range[1] - offset
|
|
88
|
+
if in_highlight and range_index < len(ranges)
|
|
89
|
+
else len(text)
|
|
90
|
+
)
|
|
91
|
+
if not in_highlight and range_index < len(ranges):
|
|
92
|
+
boundary = min(boundary, active_range[0] - offset)
|
|
93
|
+
segment_end = min(len(text), max(segment_start + 1, boundary))
|
|
94
|
+
segment_text = text[segment_start:segment_end]
|
|
95
|
+
segment_style = (
|
|
96
|
+
style
|
|
97
|
+
if not in_highlight
|
|
98
|
+
else self._merge_style(style, "class:file-ref")
|
|
99
|
+
)
|
|
100
|
+
if rest:
|
|
101
|
+
fragments.append((segment_style, segment_text, *rest))
|
|
102
|
+
else:
|
|
103
|
+
fragments.append((segment_style, segment_text))
|
|
104
|
+
segment_start = segment_end
|
|
105
|
+
offset += len(text)
|
|
106
|
+
|
|
107
|
+
return Transformation(fragments)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _merge_style(existing: str, addition: str) -> str:
|
|
111
|
+
return addition if not existing else f"{existing} {addition}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class FramedPromptSession(PromptSession[str]):
|
|
115
|
+
def _create_layout(self) -> Layout:
|
|
116
|
+
layout = super()._create_layout()
|
|
117
|
+
layout.current_window.dont_extend_height = to_filter(True)
|
|
118
|
+
return layout
|
|
119
|
+
|
|
120
|
+
def _get_default_buffer_control_height(self) -> Dimension:
|
|
121
|
+
dimension = super()._get_default_buffer_control_height()
|
|
122
|
+
if is_true(self.multiline):
|
|
123
|
+
if dimension.min is not None and dimension.min > 1:
|
|
124
|
+
return dimension
|
|
125
|
+
return Dimension(min=1)
|
|
126
|
+
if dimension.min is not None and dimension.min > 1:
|
|
127
|
+
return dimension
|
|
128
|
+
return Dimension(min=1, max=1, preferred=1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class PromptSessionAdapter:
|
|
132
|
+
def __init__(self) -> None:
|
|
133
|
+
self._prompt_session: PromptSession[str] | None = None
|
|
134
|
+
self._prompt_session_loop: asyncio.AbstractEventLoop | None = None
|
|
135
|
+
self._input_lock: asyncio.Lock | None = None
|
|
136
|
+
self._input_lock_loop: asyncio.AbstractEventLoop | None = None
|
|
137
|
+
self._history: History = InMemoryHistory()
|
|
138
|
+
self._completer: Completer | None = None
|
|
139
|
+
self._selector = TriggerSelector()
|
|
140
|
+
self._active_task: asyncio.Task | None = None
|
|
141
|
+
self._active_control: TurnControl | None = None
|
|
142
|
+
self._queued_count: int = 0
|
|
143
|
+
self._next_queued_preview: str | None = None
|
|
144
|
+
self._active_prompt_kind: str | None = None
|
|
145
|
+
|
|
146
|
+
def set_active_task(self, task: asyncio.Task | None) -> None:
|
|
147
|
+
self._active_task = task
|
|
148
|
+
|
|
149
|
+
def set_active_control(self, control: TurnControl | None) -> None:
|
|
150
|
+
self._active_control = control
|
|
151
|
+
|
|
152
|
+
def set_queue_info(self, count: int, preview: str | None) -> None:
|
|
153
|
+
self._queued_count = count
|
|
154
|
+
self._next_queued_preview = preview
|
|
155
|
+
|
|
156
|
+
def set_completer(self, completer: Completer) -> None:
|
|
157
|
+
self._completer = completer
|
|
158
|
+
selector_sources = getattr(completer, "selector_sources", None)
|
|
159
|
+
if isinstance(selector_sources, tuple):
|
|
160
|
+
self._selector.set_sources(
|
|
161
|
+
[
|
|
162
|
+
source
|
|
163
|
+
for source in selector_sources
|
|
164
|
+
if isinstance(source, TriggerSelectorSource)
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
selector_source = getattr(completer, "selector_source", None)
|
|
169
|
+
if isinstance(selector_source, TriggerSelectorSource):
|
|
170
|
+
self._selector.set_sources([selector_source])
|
|
171
|
+
else:
|
|
172
|
+
self._selector.set_sources([])
|
|
173
|
+
if self._prompt_session is not None:
|
|
174
|
+
self._prompt_session.completer = completer
|
|
175
|
+
|
|
176
|
+
def has_modal_input(self) -> bool:
|
|
177
|
+
return self._active_prompt_kind == "modal"
|
|
178
|
+
|
|
179
|
+
def get_prompt_session(self) -> PromptSession[str]:
|
|
180
|
+
current_loop = asyncio.get_running_loop()
|
|
181
|
+
if (
|
|
182
|
+
self._prompt_session is None
|
|
183
|
+
or self._prompt_session_loop is not current_loop
|
|
184
|
+
):
|
|
185
|
+
self._prompt_session = FramedPromptSession(
|
|
186
|
+
history=self._history,
|
|
187
|
+
completer=self._completer,
|
|
188
|
+
complete_while_typing=False,
|
|
189
|
+
reserve_space_for_menu=0,
|
|
190
|
+
key_bindings=self._build_key_bindings(),
|
|
191
|
+
bottom_toolbar=self._render_toolbar,
|
|
192
|
+
input_processors=[FileReferenceProcessor()],
|
|
193
|
+
style=PROMPT_STYLE,
|
|
194
|
+
)
|
|
195
|
+
self._prompt_session_loop = current_loop
|
|
196
|
+
return self._prompt_session
|
|
197
|
+
|
|
198
|
+
async def read_input(
|
|
199
|
+
self,
|
|
200
|
+
message: str = "> ",
|
|
201
|
+
*,
|
|
202
|
+
multiline: bool = False,
|
|
203
|
+
modal: bool = False,
|
|
204
|
+
) -> str:
|
|
205
|
+
async with self._get_input_lock():
|
|
206
|
+
self._active_prompt_kind = "modal" if modal else "input"
|
|
207
|
+
try:
|
|
208
|
+
if not sys.stdin.isatty():
|
|
209
|
+
print(message, end="", flush=True)
|
|
210
|
+
line = await asyncio.to_thread(sys.stdin.readline)
|
|
211
|
+
if not line:
|
|
212
|
+
raise EOFError
|
|
213
|
+
return line.rstrip("\n")
|
|
214
|
+
|
|
215
|
+
with patch_stdout():
|
|
216
|
+
response = await self.get_prompt_session().prompt_async(
|
|
217
|
+
message,
|
|
218
|
+
placeholder=" Type your message, a slash command or @path/to/file",
|
|
219
|
+
multiline=multiline,
|
|
220
|
+
show_frame=True,
|
|
221
|
+
)
|
|
222
|
+
return response.strip()
|
|
223
|
+
finally:
|
|
224
|
+
self._active_prompt_kind = None
|
|
225
|
+
|
|
226
|
+
async def prompt(self, label: str, *, default: str | None = None) -> str:
|
|
227
|
+
prompt_text = label if default is None else f"{label} [{default}]"
|
|
228
|
+
response = await self.read_input(f"{prompt_text}> ")
|
|
229
|
+
if not response and default is not None:
|
|
230
|
+
return default
|
|
231
|
+
return response
|
|
232
|
+
|
|
233
|
+
async def confirm(self, label: str, *, default: bool = False) -> bool:
|
|
234
|
+
suffix = "Y/n" if default else "y/N"
|
|
235
|
+
response = await self.read_input(f"{label} [{suffix}] > ")
|
|
236
|
+
if not response:
|
|
237
|
+
return default
|
|
238
|
+
return response.lower() in {"y", "yes"}
|
|
239
|
+
|
|
240
|
+
def reset(self) -> None:
|
|
241
|
+
self._prompt_session = None
|
|
242
|
+
self._prompt_session_loop = None
|
|
243
|
+
self._input_lock = None
|
|
244
|
+
self._input_lock_loop = None
|
|
245
|
+
self._history = InMemoryHistory()
|
|
246
|
+
self._completer = None
|
|
247
|
+
self._selector.reset()
|
|
248
|
+
self._active_task = None
|
|
249
|
+
self._active_control = None
|
|
250
|
+
self._queued_count = 0
|
|
251
|
+
self._next_queued_preview = None
|
|
252
|
+
# Clear the PromptToolkit AppSession's cached output so the next
|
|
253
|
+
# invocation binds to the current sys.stdout rather than a stale buffer.
|
|
254
|
+
from prompt_toolkit.application.current import get_app_session
|
|
255
|
+
|
|
256
|
+
get_app_session()._output = None
|
|
257
|
+
|
|
258
|
+
def _get_input_lock(self) -> asyncio.Lock:
|
|
259
|
+
current_loop = asyncio.get_running_loop()
|
|
260
|
+
if self._input_lock is None or self._input_lock_loop is not current_loop:
|
|
261
|
+
self._input_lock = asyncio.Lock()
|
|
262
|
+
self._input_lock_loop = current_loop
|
|
263
|
+
return self._input_lock
|
|
264
|
+
|
|
265
|
+
def _build_key_bindings(self) -> KeyBindings:
|
|
266
|
+
bindings = KeyBindings()
|
|
267
|
+
|
|
268
|
+
@Condition
|
|
269
|
+
def multiline_prompt_active() -> bool:
|
|
270
|
+
session = self._prompt_session
|
|
271
|
+
return session is not None and is_true(session.multiline)
|
|
272
|
+
|
|
273
|
+
@Condition
|
|
274
|
+
def selector_visible() -> bool:
|
|
275
|
+
app = get_app_or_none()
|
|
276
|
+
if app is None:
|
|
277
|
+
return False
|
|
278
|
+
return self._selector.is_visible(
|
|
279
|
+
app.current_buffer.text,
|
|
280
|
+
app.current_buffer.cursor_position,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@bindings.add("enter", filter=multiline_prompt_active & ~selector_visible)
|
|
284
|
+
def submit_multiline_input(event) -> None:
|
|
285
|
+
event.current_buffer.validate_and_handle()
|
|
286
|
+
|
|
287
|
+
@bindings.add(
|
|
288
|
+
"escape", "enter", filter=multiline_prompt_active & ~selector_visible
|
|
289
|
+
)
|
|
290
|
+
def insert_multiline_newline(event) -> None:
|
|
291
|
+
event.current_buffer.newline(copy_margin=True)
|
|
292
|
+
|
|
293
|
+
@Condition
|
|
294
|
+
def agent_task_active() -> bool:
|
|
295
|
+
return self._active_task is not None and not self._active_task.done()
|
|
296
|
+
|
|
297
|
+
@bindings.add("escape", filter=~selector_visible & agent_task_active)
|
|
298
|
+
def cancel_agent_task(event) -> None:
|
|
299
|
+
if self._active_control is not None:
|
|
300
|
+
self._active_control.cancel()
|
|
301
|
+
if self._active_task is not None and not self._active_task.done():
|
|
302
|
+
self._active_task.cancel()
|
|
303
|
+
event.app.invalidate()
|
|
304
|
+
|
|
305
|
+
@bindings.add("down", filter=selector_visible)
|
|
306
|
+
@bindings.add("c-n", filter=selector_visible)
|
|
307
|
+
def select_next(event) -> None:
|
|
308
|
+
buffer = event.app.current_buffer
|
|
309
|
+
self._selector.move_selection(
|
|
310
|
+
buffer.text,
|
|
311
|
+
buffer.cursor_position,
|
|
312
|
+
step=1,
|
|
313
|
+
)
|
|
314
|
+
event.app.invalidate()
|
|
315
|
+
|
|
316
|
+
@bindings.add("up", filter=selector_visible)
|
|
317
|
+
@bindings.add("c-p", filter=selector_visible)
|
|
318
|
+
def select_previous(event) -> None:
|
|
319
|
+
buffer = event.app.current_buffer
|
|
320
|
+
self._selector.move_selection(
|
|
321
|
+
buffer.text,
|
|
322
|
+
buffer.cursor_position,
|
|
323
|
+
step=-1,
|
|
324
|
+
)
|
|
325
|
+
event.app.invalidate()
|
|
326
|
+
|
|
327
|
+
@bindings.add("tab", filter=selector_visible)
|
|
328
|
+
@bindings.add("enter", filter=selector_visible)
|
|
329
|
+
def insert_selection(event) -> None:
|
|
330
|
+
buffer = event.app.current_buffer
|
|
331
|
+
application = self._selector.apply_selection(
|
|
332
|
+
buffer.text,
|
|
333
|
+
buffer.cursor_position,
|
|
334
|
+
)
|
|
335
|
+
if application is None:
|
|
336
|
+
buffer.validate_and_handle()
|
|
337
|
+
return
|
|
338
|
+
buffer.document = Document(
|
|
339
|
+
text=application.text,
|
|
340
|
+
cursor_position=application.cursor_position,
|
|
341
|
+
)
|
|
342
|
+
event.app.invalidate()
|
|
343
|
+
|
|
344
|
+
@bindings.add("escape", filter=selector_visible)
|
|
345
|
+
def dismiss_selector(event) -> None:
|
|
346
|
+
buffer = event.app.current_buffer
|
|
347
|
+
self._selector.dismiss(buffer.text, buffer.cursor_position)
|
|
348
|
+
event.app.invalidate()
|
|
349
|
+
|
|
350
|
+
return bindings
|
|
351
|
+
|
|
352
|
+
def _render_toolbar(self) -> AnyFormattedText:
|
|
353
|
+
app = get_app_or_none()
|
|
354
|
+
if app is not None:
|
|
355
|
+
snapshot = self._selector.snapshot(
|
|
356
|
+
app.current_buffer.text,
|
|
357
|
+
app.current_buffer.cursor_position,
|
|
358
|
+
)
|
|
359
|
+
if snapshot.visible:
|
|
360
|
+
width = max(40, app.output.get_size().columns)
|
|
361
|
+
return self._selector_fragments(snapshot, width=width)
|
|
362
|
+
|
|
363
|
+
fragments: list[tuple[str, str]] = []
|
|
364
|
+
task_active = self._active_task is not None and not self._active_task.done()
|
|
365
|
+
if task_active:
|
|
366
|
+
fragments.append(("class:bottom-toolbar.text", " Press ESC to cancel"))
|
|
367
|
+
if self._queued_count > 0 and self._next_queued_preview is not None:
|
|
368
|
+
preview = self._next_queued_preview[:50]
|
|
369
|
+
fragments.append(
|
|
370
|
+
(
|
|
371
|
+
"class:bottom-toolbar.text",
|
|
372
|
+
f" | Queued ({self._queued_count}): {preview}...",
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
return fragments
|
|
376
|
+
|
|
377
|
+
def _selector_fragments(
|
|
378
|
+
self, snapshot: TriggerSelectorSnapshot, *, width: int
|
|
379
|
+
) -> AnyFormattedText:
|
|
380
|
+
content_width = max(20, width)
|
|
381
|
+
visible_start = snapshot.visible_start + 1
|
|
382
|
+
count_line = (
|
|
383
|
+
f"({visible_start}/{len(snapshot.matches)})"
|
|
384
|
+
if snapshot.matches
|
|
385
|
+
else "(0/0)"
|
|
386
|
+
)
|
|
387
|
+
fragments: list[tuple[str, str]] = [
|
|
388
|
+
("class:selector.rule", self._fill_rule("▄", content_width) + "\n"),
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
for offset, candidate in enumerate(snapshot.visible_matches):
|
|
392
|
+
absolute_index = snapshot.visible_start + offset
|
|
393
|
+
row_style = (
|
|
394
|
+
"class:selector.row.selected"
|
|
395
|
+
if absolute_index == snapshot.selected_index
|
|
396
|
+
else "class:selector.row"
|
|
397
|
+
)
|
|
398
|
+
row_text = self._format_candidate_row(
|
|
399
|
+
candidate.label, candidate.description, content_width
|
|
400
|
+
)
|
|
401
|
+
fragments.append((row_style, row_text + "\n"))
|
|
402
|
+
|
|
403
|
+
fragments.extend(
|
|
404
|
+
[
|
|
405
|
+
("class:selector.row", "▼\n"),
|
|
406
|
+
("class:selector.count", count_line.ljust(content_width) + "\n"),
|
|
407
|
+
("class:selector.rule", self._fill_rule(" ", content_width)),
|
|
408
|
+
]
|
|
409
|
+
)
|
|
410
|
+
return fragments
|
|
411
|
+
|
|
412
|
+
def _format_candidate_row(self, label: str, description: str, width: int) -> str:
|
|
413
|
+
label_width = min(16, max(10, width // 6))
|
|
414
|
+
gap = 3
|
|
415
|
+
description_width = max(0, width - label_width - gap)
|
|
416
|
+
fitted_label = self._fit_panel_text(label, label_width)
|
|
417
|
+
fitted_description = self._fit_panel_text(description, description_width)
|
|
418
|
+
return f"{fitted_label}{' ' * gap}{fitted_description}".ljust(width)
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def _fill_rule(character: str, width: int) -> str:
|
|
422
|
+
return character * width
|
|
423
|
+
|
|
424
|
+
@staticmethod
|
|
425
|
+
def _fit_panel_text(content: str, width: int) -> str:
|
|
426
|
+
if len(content) <= width:
|
|
427
|
+
return content.ljust(width)
|
|
428
|
+
if width <= 3:
|
|
429
|
+
return content[:width]
|
|
430
|
+
return f"{content[: width - 3]}..."
|
rpr/ui/renderers.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from rpr.ui.console import console
|
|
12
|
+
from rpr.ui.markdown import render_markdown as render_markdown_content
|
|
13
|
+
|
|
14
|
+
R_LEFT = ["█████ ", "█ ██", "█ ██", "█████ ", "█ █ ", "█ █ ", "█ █"]
|
|
15
|
+
|
|
16
|
+
P_MID = ["█████ ", "█ ██", "█ ██", "█████ ", "█ ", "█ ", "█ "]
|
|
17
|
+
|
|
18
|
+
# This is a perfect mathematical mirror of R_LEFT
|
|
19
|
+
R_RIGHT = [" █████", "██ █", "██ █", " █████", " █ █", " █ █", "█ █"]
|
|
20
|
+
|
|
21
|
+
DEEP_ORANGE_STYLE = "#ff8c00"
|
|
22
|
+
RED_STYLE = "#ff3b30"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _double_width(text: str) -> str:
|
|
26
|
+
return "".join(char * 2 for char in text)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_heading(text: str) -> None:
|
|
30
|
+
console.print(f"\n[heading]{text}[/]\n")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_success(message: str) -> None:
|
|
34
|
+
console.print(f"[success]✓[/success] {message}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_warning(message: str) -> None:
|
|
38
|
+
console.print(f"[warning]⚠[/warning] {message}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_error(message: str) -> None:
|
|
42
|
+
console.print(f"[error]✗[/error] {message}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_info(message: str) -> None:
|
|
46
|
+
console.print(f"[info]ℹ[/info] {message}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_session_header(
|
|
50
|
+
*, project_root: Path, provider: str, model: str, approval_mode: str
|
|
51
|
+
) -> None:
|
|
52
|
+
for i in range(len(R_LEFT)):
|
|
53
|
+
row_text = Text()
|
|
54
|
+
row_text.append(_double_width(R_LEFT[i]), style=DEEP_ORANGE_STYLE)
|
|
55
|
+
row_text.append(_double_width(" "))
|
|
56
|
+
row_text.append(_double_width(P_MID[i]), style=RED_STYLE)
|
|
57
|
+
row_text.append(_double_width(" "))
|
|
58
|
+
row_text.append(_double_width(R_RIGHT[i]), style=DEEP_ORANGE_STYLE)
|
|
59
|
+
console.print(row_text)
|
|
60
|
+
# add new line
|
|
61
|
+
console.print()
|
|
62
|
+
console.print(
|
|
63
|
+
Panel(
|
|
64
|
+
(
|
|
65
|
+
f"[bold]Project Root:[/] [path]{project_root}[/]\n"
|
|
66
|
+
f"[bold]Provider:[/] {provider}\n"
|
|
67
|
+
f"[bold]Model:[/] {model}\n"
|
|
68
|
+
f"[bold]Approval Mode:[/] {approval_mode}"
|
|
69
|
+
),
|
|
70
|
+
title="RPR Chat Session",
|
|
71
|
+
border_style="dim",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_tool_call(
|
|
77
|
+
tool_name: str, arguments: dict[str, object], *, is_mutating: bool
|
|
78
|
+
) -> None:
|
|
79
|
+
tool_kind = "mutation" if is_mutating else "read-only"
|
|
80
|
+
payload = (
|
|
81
|
+
json.dumps(arguments, indent=2, sort_keys=True, default=str)
|
|
82
|
+
if arguments
|
|
83
|
+
else "{}"
|
|
84
|
+
)
|
|
85
|
+
console.print(
|
|
86
|
+
Panel(
|
|
87
|
+
escape(payload),
|
|
88
|
+
title=f"Tool Call: {tool_name} ({tool_kind})",
|
|
89
|
+
border_style="dim",
|
|
90
|
+
padding=(0, 1),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_tool_result(tool_name: str, output: str, *, success: bool) -> None:
|
|
96
|
+
outcome = "success" if success else "failure"
|
|
97
|
+
title = f"Tool Result: {tool_name} ({outcome})"
|
|
98
|
+
border_style = "dim"
|
|
99
|
+
console.print(
|
|
100
|
+
Panel(escape(output), title=title, border_style=border_style, padding=(0, 1))
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_approval_prompt(tool_name: str) -> None:
|
|
105
|
+
console.print(
|
|
106
|
+
f"[warning]Approval required:[/] {tool_name} wants to modify the repository."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_chat_hint(message: str) -> None:
|
|
111
|
+
console.print(f"[muted]{message}[/]")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_command_start(cmd: str, cwd: str, is_dry_run: bool = False) -> None:
|
|
115
|
+
prefix = (
|
|
116
|
+
"[dry_run][DRY RUN][/] Would run" if is_dry_run else "[info]\\[rpr] Running:[/]"
|
|
117
|
+
)
|
|
118
|
+
console.print(f"{prefix} [command]{cmd}[/]", soft_wrap=True)
|
|
119
|
+
if is_dry_run:
|
|
120
|
+
console.print(f" in: [path]{cwd}[/]", soft_wrap=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_file_preview(
|
|
124
|
+
path: Path | str, content: str, is_dry_run: bool = False, is_update: bool = False
|
|
125
|
+
) -> None:
|
|
126
|
+
action = "update" if is_update else "create"
|
|
127
|
+
|
|
128
|
+
if is_dry_run:
|
|
129
|
+
prefix = f"[dry_run][DRY RUN][/] Would {action}: "
|
|
130
|
+
console.print(f"\n{prefix}[path]{path}[/]", soft_wrap=True)
|
|
131
|
+
console.print(f"[border]{'─' * 60}[/]")
|
|
132
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
133
|
+
console.print(f" [line_number]{i:>4}[/] [border]│[/] {escape(line)}")
|
|
134
|
+
console.print(f"[border]{'─' * 60}[/]")
|
|
135
|
+
else:
|
|
136
|
+
done_action = "Updated" if is_update else "Created"
|
|
137
|
+
console.print(f"[success]{done_action}:[/][path] {path}[/]")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render_markdown(content: str) -> None:
|
|
141
|
+
render_markdown_content(content, mode="preview", use_panel=False, max_lines=None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def render_table(title: str, columns: list[str], rows: list[list[str]]) -> None:
|
|
145
|
+
table = Table(title=title, box=None, show_header=True, header_style="bold")
|
|
146
|
+
for col in columns:
|
|
147
|
+
table.add_column(col)
|
|
148
|
+
for row in rows:
|
|
149
|
+
table.add_row(*row)
|
|
150
|
+
console.print(table)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_status_row(
|
|
154
|
+
icon_char: str, area: str, path: str, status: bool, suggestion: str | None = None
|
|
155
|
+
) -> None:
|
|
156
|
+
icon_style = "success" if status else "error"
|
|
157
|
+
icon = f"[{icon_style}][{icon_char}][/{icon_style}]"
|
|
158
|
+
area_display = f"{area:<20}"
|
|
159
|
+
|
|
160
|
+
if status:
|
|
161
|
+
console.print(f" {icon} {area_display} [muted]{path}[/]")
|
|
162
|
+
else:
|
|
163
|
+
status_text = "[error]missing[/error]"
|
|
164
|
+
suggestion_text = (
|
|
165
|
+
f" → [warning]run: {suggestion}[/warning]" if suggestion else ""
|
|
166
|
+
)
|
|
167
|
+
console.print(f" {icon} {area_display} {status_text}{suggestion_text}")
|