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
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
@@ -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}")