relay-code 0.1.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.
ui/renderer.py ADDED
@@ -0,0 +1,580 @@
1
+ from rich.console import Console, ConsoleOptions, Group, RenderResult
2
+ from rich.text import Text
3
+ from rich.segment import Segment
4
+ from rich.table import Table
5
+ from rich.live import Live
6
+ from rich.syntax import Syntax
7
+
8
+ from config.config import Config
9
+ from ui.format import (
10
+ extract_read_code,
11
+ format_elapsed,
12
+ guess_language,
13
+ headline as headline_of,
14
+ ordered_args,
15
+ secondary_args,
16
+ summarise_value,
17
+ )
18
+ from ui.theme import AGENT_THEME
19
+ from utils.paths import display_path_relative_to_cwd
20
+ from utils.text import truncate_text
21
+ from typing import Any, Callable
22
+ import asyncio
23
+ import random
24
+ import time
25
+
26
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
27
+ SPINNER_INTERVAL = 0.08
28
+
29
+ GUTTER_CHAR = "│"
30
+
31
+ MAX_BLOCK_TOKENS = 2400
32
+ MAX_DIFF_TOKENS = 4000
33
+
34
+ THINKING_WORDS = [
35
+ "Thinking…",
36
+ "Working…",
37
+ "Fluctuating…",
38
+ "Writing…",
39
+ "Typing…",
40
+ "Helping…",
41
+ ]
42
+
43
+
44
+ def random_thinking_text() -> str:
45
+ return random.choice(THINKING_WORDS)
46
+
47
+
48
+ class Gutter:
49
+
50
+ def __init__(self, renderable: Any, style: str = "border") -> None:
51
+ self.renderable = renderable
52
+ self.style = style
53
+
54
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
55
+ bar = Segment(f"{GUTTER_CHAR} ", console.get_style(self.style))
56
+ body_width = max(options.max_width - 2, 1)
57
+ lines = console.render_lines(
58
+ self.renderable,
59
+ options.update(width=body_width),
60
+ pad=False,
61
+ )
62
+ for line in lines:
63
+ yield bar
64
+ yield from line
65
+ yield Segment("\n")
66
+
67
+
68
+ _console: Console | None = None
69
+
70
+
71
+ def get_console() -> Console:
72
+ global _console
73
+ if _console is None:
74
+ _console = Console(theme=AGENT_THEME, highlight=False)
75
+ return _console
76
+
77
+
78
+ class TUI:
79
+ def __init__(self, config: Config, console: Console | None = None) -> None:
80
+ self.console = console or get_console()
81
+ self.config = config
82
+ self.cwd = config.cwd
83
+ self._assistant_stream_open = False
84
+ self.tool_args_by_call_id: dict[str, dict[str, Any]] = {}
85
+ self.tool_started_at: dict[str, float] = {}
86
+
87
+ self._spinner_live: Live | None = None
88
+ self._spinner_task: asyncio.Task | None = None
89
+ self._spinner_render: Callable[[], Text] | None = None
90
+ self._spinner_frame = 0
91
+ self._thinking_label = ""
92
+
93
+
94
+ def _spinner_char(self) -> str:
95
+ return SPINNER_FRAMES[self._spinner_frame % len(SPINNER_FRAMES)]
96
+
97
+ async def _animate_spinner(self) -> None:
98
+ try:
99
+ while True:
100
+ await asyncio.sleep(SPINNER_INTERVAL)
101
+ self._spinner_frame += 1
102
+ if self._spinner_live is not None and self._spinner_render is not None:
103
+ self._spinner_live.update(self._spinner_render())
104
+ except asyncio.CancelledError:
105
+ pass
106
+
107
+ def _start_spinner(self, render: Callable[[], Text]) -> None:
108
+ self._stop_spinner()
109
+ self._spinner_frame = 0
110
+ self._spinner_render = render
111
+ self._spinner_live = Live(
112
+ render(),
113
+ console=self.console,
114
+ refresh_per_second=1 / SPINNER_INTERVAL,
115
+ transient=True,
116
+ )
117
+ self._spinner_live.start()
118
+ self._spinner_task = asyncio.create_task(self._animate_spinner())
119
+
120
+ def _stop_spinner(self) -> None:
121
+ if self._spinner_task is not None:
122
+ self._spinner_task.cancel()
123
+ self._spinner_task = None
124
+ if self._spinner_live is not None:
125
+ self._spinner_live.stop()
126
+ self._spinner_live = None
127
+ self._spinner_render = None
128
+
129
+ def _thinking_renderable(self) -> Text:
130
+ return Text.assemble(
131
+ (f"{self._spinner_char()} ", "muted"), (self._thinking_label, "muted")
132
+ )
133
+
134
+ def start_thinking(self, label: str | None = None) -> None:
135
+ self._thinking_label = label if label is not None else random_thinking_text()
136
+ self._start_spinner(self._thinking_renderable)
137
+
138
+ def stop_thinking(self) -> None:
139
+ self._stop_spinner()
140
+
141
+
142
+ def begin_assistant(self) -> None:
143
+ self._stop_spinner()
144
+ self.console.print()
145
+ self._assistant_stream_open = True
146
+
147
+ def end_assistant(self) -> None:
148
+ if self._assistant_stream_open:
149
+ self.console.print()
150
+ self._assistant_stream_open = False
151
+
152
+ def stream_assistant_delta(self, content: str) -> None:
153
+ self.console.print(content, end="", markup=False)
154
+
155
+
156
+ def _relativise(self, arguments: dict[str, Any]) -> dict[str, Any]:
157
+ display_args = dict(arguments)
158
+ for key in ("path", "cwd"):
159
+ value = display_args.get(key)
160
+ if isinstance(value, str) and self.cwd:
161
+ display_args[key] = str(display_path_relative_to_cwd(value, self.cwd))
162
+ return display_args
163
+
164
+ def _render_todos(self, metadata: dict[str, Any]) -> Table | Text:
165
+ todos = metadata.get("todos") or []
166
+ if not todos:
167
+ return Text("No todos.", style="muted")
168
+
169
+ checklist = Table.grid(padding=(0, 1))
170
+ checklist.add_column(no_wrap=True)
171
+ checklist.add_column(overflow="fold")
172
+ checklist.add_column(style="muted", no_wrap=True, justify="right")
173
+
174
+ markers = {
175
+ "completed": ("✔", "success", "muted strike"),
176
+ "in_progress": ("▶", "info", "highlight"),
177
+ "pending": ("☐", "muted", "code"),
178
+ }
179
+
180
+ for todo in todos:
181
+ marker, marker_style, content_style = markers.get(
182
+ str(todo.get("status")), markers["pending"]
183
+ )
184
+ checklist.add_row(
185
+ Text(marker, style=marker_style),
186
+ Text(str(todo.get("content", "")), style=content_style),
187
+ Text(str(todo.get("id", ""))),
188
+ )
189
+ return checklist
190
+
191
+ def _render_memory(self, metadata: dict[str, Any]) -> Table | Text:
192
+ entries = metadata.get("entries") or []
193
+ if not entries:
194
+ return Text("No memory stored.", style="muted")
195
+
196
+ active_key = metadata.get("active_key")
197
+
198
+ table = Table.grid(padding=(0, 1))
199
+ table.add_column(no_wrap=True)
200
+ table.add_column(style="info", no_wrap=True)
201
+ table.add_column(overflow="fold")
202
+
203
+ for entry in entries:
204
+ key = str(entry.get("key", ""))
205
+ is_active = active_key is not None and key == active_key
206
+ marker_style = "highlight" if is_active else "tool.memory"
207
+ value_style = "highlight" if is_active else "code"
208
+ table.add_row(
209
+ Text("●", style=marker_style),
210
+ Text(key),
211
+ Text(str(entry.get("value", "")), style=value_style),
212
+ )
213
+ return table
214
+
215
+ def _render_args_table(self, tool_name: str, args: dict[str, Any]) -> Table:
216
+ table = Table.grid(padding=(0, 1))
217
+ table.add_column(style="muted", justify="right", no_wrap=True)
218
+ table.add_column(style="code", overflow="fold")
219
+ for key, value in ordered_args(tool_name, args):
220
+ table.add_row(key, summarise_value(key, value))
221
+ return table
222
+
223
+ def _tool_header(
224
+ self,
225
+ icon: str,
226
+ icon_style: str,
227
+ name: str,
228
+ headline: str | None,
229
+ status: Text,
230
+ ) -> Table:
231
+ left = Text.assemble((f"{icon} ", icon_style), (name, "tool"))
232
+ if headline:
233
+ left.append(" ")
234
+ left.append(headline, style="subtitle")
235
+
236
+ header = Table.grid(expand=True)
237
+ header.add_column(overflow="ellipsis", no_wrap=True)
238
+ header.add_column(justify="right", no_wrap=True)
239
+ header.add_row(left, status)
240
+ return header
241
+
242
+ def tool_call_start(
243
+ self,
244
+ call_id: str,
245
+ name: str,
246
+ tool_kind: str | None,
247
+ arguments: dict[str, Any],
248
+ ) -> None:
249
+ display_args = self._relativise(arguments)
250
+ self.tool_args_by_call_id[call_id] = display_args
251
+ self.tool_started_at[call_id] = time.monotonic()
252
+ head = headline_of(display_args)
253
+
254
+ def render() -> Text:
255
+ line = Text.assemble((f"{self._spinner_char()} ", "muted"), (name, "tool"))
256
+ if head:
257
+ line.append(" ")
258
+ line.append(head[1], style="muted")
259
+ return line
260
+
261
+ self.console.print()
262
+ self._start_spinner(render)
263
+
264
+ def tool_call_complete(
265
+ self,
266
+ call_id: str,
267
+ name: str,
268
+ tool_kind: str | None,
269
+ success: bool,
270
+ output: str,
271
+ error: str | None,
272
+ metadata: dict[str, Any] | None,
273
+ truncated: bool,
274
+ diff: str | None,
275
+ exit_code: int | None,
276
+ ) -> None:
277
+ self._stop_spinner()
278
+
279
+ border_style = f"tool.{tool_kind}" if tool_kind else "tool"
280
+ status_style = "success" if success else "error"
281
+ started_at = self.tool_started_at.pop(call_id, None)
282
+ elapsed = (
283
+ format_elapsed(time.monotonic() - started_at) if started_at is not None else None
284
+ )
285
+ display_args = self.tool_args_by_call_id.pop(call_id, {})
286
+ args = display_args
287
+
288
+ status = Text()
289
+ if not success:
290
+ status.append("failed", style=status_style)
291
+ if elapsed:
292
+ if status.plain:
293
+ status.append(" · ", style="muted")
294
+ status.append(elapsed, style="muted")
295
+
296
+ head = headline_of(display_args)
297
+ header = self._tool_header(
298
+ "✓" if success else "✖",
299
+ status_style,
300
+ name,
301
+ head[1] if head else None,
302
+ status,
303
+ )
304
+
305
+ metadata = metadata or {}
306
+ primary_path = None
307
+ if isinstance(metadata.get("path"), str):
308
+ primary_path = metadata["path"]
309
+
310
+ blocks: list[Any] = []
311
+
312
+ if not success:
313
+ blocks.append(Text(error or "Tool failed", style="error"))
314
+ if output.strip():
315
+ blocks.append(
316
+ Text(truncate_text(output, "", MAX_BLOCK_TOKENS), style="muted")
317
+ )
318
+
319
+ elif name == "read" and primary_path:
320
+ result = extract_read_code(output)
321
+ start_line, code = result if result else (1, output)
322
+
323
+ shown_start = metadata.get("shown_start")
324
+ shown_end = metadata.get("shown_end")
325
+ total_lines = metadata.get("total_lines")
326
+ if shown_start and shown_end and total_lines:
327
+ blocks.append(
328
+ Text(f"lines {shown_start}–{shown_end} of {total_lines}", style="muted")
329
+ )
330
+ blocks.append(
331
+ Syntax(
332
+ code,
333
+ guess_language(primary_path),
334
+ theme="nord",
335
+ line_numbers=True,
336
+ start_line=start_line,
337
+ word_wrap=False,
338
+ )
339
+ )
340
+
341
+ elif name in {"write", "edit"} and success:
342
+ blocks.append(Text(output.strip() or "Completed", style="muted"))
343
+ if diff:
344
+ blocks.append(
345
+ Syntax(
346
+ truncate_text(diff, self.config.model_name, MAX_DIFF_TOKENS),
347
+ "diff",
348
+ theme="nord",
349
+ word_wrap=True,
350
+ )
351
+ )
352
+
353
+ elif name == 'shell' and success:
354
+ command = args.get('command')
355
+ if isinstance(command, str) and command.strip():
356
+ blocks.append(Text(f'$ {command.strip()}', style='muted'))
357
+
358
+ if exit_code is not None:
359
+ blocks.append(Text(
360
+ f'exit_code={exit_code}', style='muted'
361
+ ))
362
+
363
+ output_display = truncate_text(
364
+ output,
365
+ self.config.model_name,
366
+ MAX_BLOCK_TOKENS,
367
+ )
368
+ blocks.append(
369
+ Syntax(
370
+ output_display,
371
+ "text",
372
+ theme="nord",
373
+ word_wrap=True,
374
+ )
375
+ )
376
+
377
+ elif name == 'list_dir' and success:
378
+
379
+ entries = metadata.get('entries')
380
+ path = metadata.get('path')
381
+ summary = []
382
+
383
+ if isinstance(path, str):
384
+ summary.append(path)
385
+
386
+ if isinstance(entries, int):
387
+ summary.append(f"{entries} entries")
388
+
389
+ if summary:
390
+ blocks.append(Text(' ┈ '.join(summary), style='muted'))
391
+
392
+ output_display = truncate_text(
393
+ output,
394
+ self.config.model_name,
395
+ MAX_BLOCK_TOKENS
396
+ )
397
+
398
+ blocks.append(
399
+ Syntax(
400
+ output_display,
401
+ "text",
402
+ theme="nord",
403
+ word_wrap=True,
404
+ )
405
+ )
406
+
407
+ elif name == 'grep' and success:
408
+
409
+ matches = metadata.get('matches')
410
+ files_searched = metadata.get('files_searched')
411
+
412
+ summary = []
413
+
414
+ if isinstance(matches, int):
415
+ summary.append(f"{matches} matches")
416
+ if isinstance(files_searched, int):
417
+ summary.append(f"searched {files_searched} files")
418
+
419
+ if summary:
420
+ blocks.append(Text(" ┈ ".join(summary), style='muted'))
421
+
422
+ output_display = truncate_text(output, self.config.model_name, MAX_BLOCK_TOKENS)
423
+ blocks.append(
424
+ Syntax(
425
+ output_display,
426
+ "text",
427
+ theme="nord",
428
+ word_wrap=True,
429
+ )
430
+ )
431
+
432
+ elif name == 'glob' and success:
433
+
434
+ matches = metadata.get('matches')
435
+
436
+ if isinstance(matches, int):
437
+ blocks.append(Text(f"{matches} matches", style='muted'))
438
+
439
+ output_display = truncate_text(output, self.config.model_name, MAX_BLOCK_TOKENS)
440
+ blocks.append(
441
+ Syntax(
442
+ output_display,
443
+ "text",
444
+ theme="nord",
445
+ word_wrap=True,
446
+ )
447
+ )
448
+
449
+ elif name == 'search' and success:
450
+
451
+ results = metadata.get('results')
452
+ query = args.get('query')
453
+
454
+ summary = []
455
+
456
+ if isinstance(query, str):
457
+ summary.append(
458
+ query
459
+ )
460
+ if isinstance(results, int):
461
+ summary.append(
462
+ f'{results} results'
463
+ )
464
+
465
+ blocks.append(Text(" ┈ ".join(summary), style='muted'))
466
+
467
+ output_display = truncate_text(output, self.config.model_name, MAX_BLOCK_TOKENS)
468
+ blocks.append(
469
+ Syntax(
470
+ output_display,
471
+ "text",
472
+ theme="nord",
473
+ word_wrap=True,
474
+ )
475
+ )
476
+
477
+ elif name == 'fetch' and success:
478
+
479
+ status_code = metadata.get('status_code')
480
+ content_length = metadata.get('content_length')
481
+ url = args.get('url')
482
+
483
+ summary = []
484
+
485
+ if isinstance(status_code, int):
486
+ summary.append(
487
+ status_code
488
+ )
489
+ if isinstance(content_length, int):
490
+ summary.append(
491
+ f'{content_length} bytes'
492
+ )
493
+ if isinstance(url, str):
494
+ summary.append(
495
+ url
496
+ )
497
+
498
+ if summary:
499
+ blocks.append(Text(" ┈ ".join(str(summary)), style='muted'))
500
+
501
+ output_display = truncate_text(output, self.config.model_name, MAX_BLOCK_TOKENS)
502
+ blocks.append(
503
+ Syntax(
504
+ output_display,
505
+ "text",
506
+ theme="nord",
507
+ word_wrap=True,
508
+ )
509
+ )
510
+
511
+ elif name == 'todo' and success:
512
+
513
+ completed = metadata.get('completed')
514
+ total = metadata.get('total')
515
+
516
+ if isinstance(completed, int) and isinstance(total, int) and total:
517
+ blocks.append(Text(f"{completed}/{total} completed", style='muted'))
518
+
519
+ blocks.append(self._render_todos(metadata))
520
+
521
+ elif name == 'memory' and success:
522
+
523
+ action = metadata.get('action')
524
+ count = metadata.get('count')
525
+
526
+ summary = []
527
+ if isinstance(action, str):
528
+ summary.append(action)
529
+ if isinstance(count, int):
530
+ summary.append(f"{count} stored")
531
+
532
+ if summary:
533
+ blocks.append(Text(" ┈ ".join(summary), style='muted'))
534
+
535
+ blocks.append(self._render_memory(metadata))
536
+
537
+ elif output.strip():
538
+ blocks.append(
539
+ Text(truncate_text(output, "", MAX_BLOCK_TOKENS), style="code")
540
+ )
541
+
542
+ if not blocks:
543
+ blocks.append(Text("(no output)", style="muted"))
544
+ if truncated:
545
+ blocks.append(Text("Tool output was truncated", style="warning"))
546
+
547
+ secondary = secondary_args(display_args, head[0] if head else None)
548
+ if secondary and name not in {"todo", "memory"}:
549
+ blocks.insert(0, self._render_args_table(name, secondary))
550
+
551
+ self.console.print()
552
+ self.console.print(header)
553
+ self.console.print(Gutter(Group(*blocks), style=border_style))
554
+
555
+
556
+ def render_usage(self, usage: dict[str, Any] | None) -> None:
557
+ if not usage:
558
+ return
559
+
560
+ prompt_tokens = usage.get("prompt_tokens", 0) or 0
561
+ completion_tokens = usage.get("completion_tokens", 0) or 0
562
+ cached_tokens = usage.get("cached_tokens", 0) or 0
563
+ context_window = self.config.model.context_window
564
+
565
+ line = Text()
566
+ line.append("context ", style="muted")
567
+ line.append(f"{prompt_tokens:,}", style="subtitle")
568
+ line.append(f" / {context_window:,}", style="muted")
569
+
570
+ if context_window:
571
+ line.append(f" ({prompt_tokens / context_window * 100:.1f}%)", style="info")
572
+
573
+ line.append(" · ", style="muted")
574
+ line.append(f"{completion_tokens:,} out", style="muted")
575
+ if cached_tokens:
576
+ line.append(" · ", style="muted")
577
+ line.append(f"{cached_tokens:,} cached", style="muted")
578
+
579
+ self.console.print()
580
+ self.console.print(line)
ui/theme.py ADDED
@@ -0,0 +1,60 @@
1
+ from rich.theme import Theme
2
+
3
+ PALETTE = {
4
+ "accent": "rgb(130,150,180)",
5
+ "sand": "rgb(200,170,120)",
6
+ "red": "rgb(200,90,90)",
7
+ "teal": "rgb(120,170,160)",
8
+ "graphite": "rgb(120,124,132)",
9
+ "silver": "rgb(176,180,188)",
10
+ "slate": "rgb(88,94,104)",
11
+ "bright": "rgb(224,226,232)",
12
+ "violet": "rgb(150,150,168)",
13
+ "read": "rgb(140,158,184)",
14
+ }
15
+
16
+ AGENT_THEME = Theme(
17
+ {
18
+ "info": PALETTE["accent"],
19
+ "warning": PALETTE["sand"],
20
+ "error": f"bold {PALETTE['red']}",
21
+ "success": PALETTE["teal"],
22
+ "dim": "dim",
23
+ "muted": PALETTE["graphite"],
24
+ "subtitle": PALETTE["silver"],
25
+ "border": PALETTE["slate"],
26
+ "highlight": f"bold {PALETTE['bright']}",
27
+
28
+ "user": f"bold {PALETTE['bright']}",
29
+ "assistant": PALETTE["silver"],
30
+
31
+ "tool": f"bold {PALETTE['accent']}",
32
+ "tool.read": PALETTE["read"],
33
+ "tool.write": PALETTE["silver"],
34
+ "tool.shell": PALETTE["graphite"],
35
+ "tool.network": PALETTE["teal"],
36
+ "tool.memory": PALETTE["violet"],
37
+ "tool.mcp": PALETTE["violet"],
38
+
39
+ "code": PALETTE["silver"],
40
+ }
41
+ )
42
+
43
+
44
+ def _css_rgb(value: str) -> str:
45
+ return value
46
+
47
+
48
+ def textual_variables() -> dict[str, str]:
49
+ return {f"relay-{name}": _css_rgb(value) for name, value in PALETTE.items()}
50
+
51
+
52
+ def tool_colour(tool_kind: str | None) -> str:
53
+ return {
54
+ "read": PALETTE["read"],
55
+ "write": PALETTE["silver"],
56
+ "shell": PALETTE["graphite"],
57
+ "network": PALETTE["teal"],
58
+ "memory": PALETTE["violet"],
59
+ "mcp": PALETTE["violet"],
60
+ }.get(tool_kind or "", PALETTE["accent"])
utils/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Utility modules for Relay."""
2
+
3
+ from utils.errors import AgentError, ConfigError
4
+ from utils.paths import resolve_path, display_path_relative_to_cwd, is_binary_file, ensure_parent_dir
5
+ from utils.text import count_tokens, estimate_tokens, truncate_text
6
+
7
+ __all__ = [
8
+ 'AgentError',
9
+ 'ConfigError',
10
+ 'resolve_path',
11
+ 'display_path_relative_to_cwd',
12
+ 'is_binary_file',
13
+ 'ensure_parent_dir',
14
+ 'count_tokens',
15
+ 'estimate_tokens',
16
+ 'truncate_text',
17
+ ]
utils/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ from typing import Any
2
+
3
+ class AgentError(Exception):
4
+ def __init__(
5
+ self,
6
+ message: str,
7
+ details: dict[str, Any] | None = None,
8
+ cause: Exception | None = None,
9
+ ) -> None:
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.details = details or {}
13
+ self.cause = cause
14
+
15
+ def __str__(self) -> str:
16
+ base = self.message
17
+ if self.details:
18
+ detail_str = ", ".join(f"{k}={v}" for k, v in self.details.items())
19
+ base = f"{base} ({detail_str})"
20
+ if self.cause:
21
+ base = f"{base} [caused by: {self.cause}]"
22
+ return base
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ return {
26
+ "type": self.__class__.__name__,
27
+ "message": self.message,
28
+ "details": self.details,
29
+ "cause": str(self.cause) if self.cause else None,
30
+ }
31
+
32
+ class ConfigError(AgentError):
33
+ def __init__(
34
+ self,
35
+ message: str,
36
+ config_key: str | None = None,
37
+ config_file: str | None = None,
38
+ **kwargs: Any,
39
+ ) -> None:
40
+ details = kwargs.pop("details", {}) or {}
41
+ if config_key:
42
+ details["config_key"] = config_key
43
+ if config_file:
44
+ details["config_file"] = config_file
45
+ super().__init__(message, details=details, **kwargs)
46
+ self.config_key = config_key
47
+ self.config_file = config_file