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/app.py ADDED
@@ -0,0 +1,949 @@
1
+ from __future__ import annotations
2
+ import random
3
+ import time
4
+
5
+ from contextlib import AsyncExitStack
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from rich.console import Group
10
+ from rich.syntax import Syntax
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from textual import on, work
15
+ from textual.app import App, ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.containers import Center, Horizontal, Vertical, VerticalScroll
18
+ from textual.message import Message
19
+ from textual.widget import Widget
20
+ from textual.widgets import Input, Static
21
+
22
+ from agent.agent import Agent
23
+ from agent.events import AgentEventType
24
+ from config.config import Config
25
+ from ui.format import (
26
+ diff_stat,
27
+ extract_read_code,
28
+ format_elapsed,
29
+ guess_language,
30
+ headline as headline_of,
31
+ ordered_args,
32
+ secondary_args,
33
+ summarise_value,
34
+ )
35
+ from ui.logo import RELAY_VERSION, gradient_logo, small_wordmark
36
+ from ui.theme import PALETTE, tool_colour
37
+ from utils.paths import display_path_relative_to_cwd
38
+ from utils.text import truncate_text
39
+ import time
40
+
41
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
42
+ SPINNER_INTERVAL = 0.08
43
+
44
+ COLLAPSED_SYMBOL = "▸"
45
+ EXPANDED_SYMBOL = "▾"
46
+ INTERRUPTED_SYMBOL = "⊘"
47
+
48
+ LOGO_MIN_WIDTH = 29
49
+
50
+ MAX_BLOCK_TOKENS = 240
51
+ MAX_DIFF_TOKENS = 4000
52
+
53
+ TEXT_OUTPUT_TOOLS = frozenset(
54
+ {
55
+ "shell",
56
+ "list_dir",
57
+ "grep",
58
+ "glob",
59
+ "search",
60
+ "fetch",
61
+ }
62
+
63
+ )
64
+
65
+
66
+ def _two_column(left: Text, right: Text) -> Table:
67
+ grid = Table.grid(expand=True)
68
+ grid.add_column(overflow="ellipsis", no_wrap=True)
69
+ grid.add_column(justify="right", no_wrap=True)
70
+ grid.add_row(left, right)
71
+ return grid
72
+
73
+
74
+ def _tilde(path: str) -> str:
75
+ home = str(Path.home())
76
+ return f"~{path[len(home):]}" if path.startswith(home) else path
77
+
78
+
79
+ def _labelled_rule(label: Text, width: int) -> Text:
80
+ lead = 2
81
+ gap = 2
82
+ trail = max(width - lead - gap - label.cell_len, 0)
83
+
84
+ rule = Text("─" * lead, style=PALETTE["slate"])
85
+ rule.append(" ")
86
+ rule.append_text(label)
87
+ rule.append(" ")
88
+ rule.append("─" * trail, style=PALETTE["slate"])
89
+ return rule
90
+
91
+
92
+ class Footer(Static):
93
+
94
+ def __init__(self, cwd: str) -> None:
95
+ super().__init__()
96
+ self._cwd = _tilde(cwd)
97
+
98
+ def on_mount(self) -> None:
99
+ right = Text("ctrl+t", style=PALETTE["slate"])
100
+ right.append(" details ", style=PALETTE["graphite"])
101
+ right.append(f"v{RELAY_VERSION}", style=PALETTE["graphite"])
102
+ self.update(_two_column(Text(self._cwd, style=PALETTE["graphite"]), right))
103
+
104
+
105
+ class PromptRule(Static):
106
+
107
+ def __init__(self, model: str) -> None:
108
+ super().__init__()
109
+ self._model = model
110
+ self._usage = ""
111
+
112
+ def on_mount(self) -> None:
113
+ self._draw()
114
+
115
+ def on_resize(self) -> None:
116
+ self._draw()
117
+
118
+ def set_usage(self, prompt_tokens: int, context_window: int) -> None:
119
+ if context_window:
120
+ self._usage = f"{prompt_tokens / context_window * 100:.0f}% ctx"
121
+ else:
122
+ self._usage = f"{prompt_tokens:,} tok"
123
+ self._draw()
124
+
125
+ def _label(self) -> Text:
126
+ label = Text(self._model, style=PALETTE["silver"])
127
+ if self._usage:
128
+ label.append(" · ", style=PALETTE["slate"])
129
+ label.append(self._usage, style=PALETTE["accent"])
130
+ return label
131
+
132
+ def _draw(self) -> None:
133
+ width = self.size.width
134
+ if not width:
135
+ return
136
+ self.update(_labelled_rule(self._label(), width))
137
+
138
+
139
+ class TodoPanel(Static):
140
+
141
+ MAX_ROWS = 8
142
+
143
+ MARKERS = {
144
+ "completed": ("✔", PALETTE["teal"], f"{PALETTE['graphite']} strike"),
145
+ "in_progress": ("▶", PALETTE["accent"], f"bold {PALETTE['bright']}"),
146
+ "pending": ("☐", PALETTE["slate"], PALETTE["silver"]),
147
+ }
148
+
149
+ def __init__(self) -> None:
150
+ super().__init__()
151
+ self._todos: list[dict[str, Any]] = []
152
+ self._completed = 0
153
+
154
+ def on_mount(self) -> None:
155
+ self.display = False
156
+
157
+ def on_resize(self) -> None:
158
+ if self._todos:
159
+ self._draw()
160
+
161
+ def set_todos(self, metadata: dict[str, Any]) -> None:
162
+ self._todos = [todo for todo in (metadata.get("todos") or []) if isinstance(todo, dict)]
163
+ self._completed = sum(
164
+ 1 for todo in self._todos if todo.get("status") == "completed"
165
+ )
166
+ self.display = bool(self._todos) and self._completed < len(self._todos)
167
+ if self.display:
168
+ self._draw()
169
+
170
+ def _window(self) -> tuple[list[dict[str, Any]], int, int]:
171
+ if len(self._todos) <= self.MAX_ROWS:
172
+ return self._todos, 0, 0
173
+
174
+ active = next(
175
+ (
176
+ index
177
+ for index, todo in enumerate(self._todos)
178
+ if todo.get("status") == "in_progress"
179
+ ),
180
+ 0,
181
+ )
182
+ start = min(max(active - 1, 0), len(self._todos) - self.MAX_ROWS)
183
+ end = start + self.MAX_ROWS
184
+ return self._todos[start:end], start, len(self._todos) - end
185
+
186
+ def _header(self) -> Text:
187
+ label = Text("todos", style=PALETTE["silver"])
188
+ label.append(" · ", style=PALETTE["slate"])
189
+ label.append(f"{self._completed}/{len(self._todos)}", style=PALETTE["accent"])
190
+ return label
191
+
192
+ def _draw(self) -> None:
193
+ width = self.size.width
194
+ if not width:
195
+ return
196
+
197
+ rows, above, below = self._window()
198
+
199
+ checklist = Table.grid(padding=(0, 1))
200
+ checklist.add_column(no_wrap=True)
201
+ checklist.add_column(overflow="ellipsis", no_wrap=True)
202
+
203
+ def add_elision(marker: str, count: int) -> None:
204
+ checklist.add_row(
205
+ Text(marker, style=PALETTE["graphite"]),
206
+ Text(f"{count} more", style=PALETTE["graphite"]),
207
+ )
208
+
209
+ if above:
210
+ add_elision("↑", above)
211
+
212
+ for todo in rows:
213
+ marker, marker_style, content_style = self.MARKERS.get(
214
+ str(todo.get("status")), self.MARKERS["pending"]
215
+ )
216
+ checklist.add_row(
217
+ Text(marker, style=marker_style),
218
+ Text(str(todo.get("content", "")), style=content_style),
219
+ )
220
+
221
+ if below:
222
+ add_elision("↓", below)
223
+
224
+ self.update(Group(_labelled_rule(self._header(), width), checklist))
225
+
226
+
227
+ class Caret(Static):
228
+ pass
229
+
230
+
231
+ class PromptRow(Horizontal):
232
+ pass
233
+
234
+
235
+ class ToolHeader(Static, can_focus=True):
236
+
237
+ BINDINGS = [Binding("enter", "toggle", "Toggle tool output", show=False)]
238
+
239
+ class Toggle(Message):
240
+ def __init__(self, header: ToolHeader) -> None:
241
+ super().__init__()
242
+ self.header = header
243
+
244
+ def on_click(self) -> None:
245
+ self.post_message(self.Toggle(self))
246
+
247
+ def action_toggle(self) -> None:
248
+ self.post_message(self.Toggle(self))
249
+
250
+
251
+ class ToolBody(Vertical):
252
+ pass
253
+
254
+
255
+ class ToolCall(Vertical):
256
+
257
+ def __init__(
258
+ self,
259
+ name: str,
260
+ tool_kind: str | None,
261
+ args: dict[str, Any],
262
+ cwd: Path | None = None,
263
+ ) -> None:
264
+ super().__init__()
265
+ self.tool_name = name
266
+ self.tool_kind = tool_kind
267
+ self.args = args
268
+ self.cwd = cwd
269
+ self.started_at = time.monotonic()
270
+ self.collapsed = True
271
+ self.done = False
272
+
273
+ self._frame = 0
274
+ self._timer = None
275
+ self._failed = False
276
+ self._interrupted = False
277
+ self._has_body = False
278
+ self._status = Text("running", style=PALETTE["graphite"])
279
+ self._header = ToolHeader()
280
+ self._body = ToolBody()
281
+
282
+ def compose(self) -> ComposeResult:
283
+ yield self._header
284
+ yield self._body
285
+
286
+ def on_mount(self) -> None:
287
+ self._body.display = False
288
+ self._body.styles.border_left = ("solid", tool_colour(self.tool_kind))
289
+ self._timer = self.set_interval(SPINNER_INTERVAL, self._tick)
290
+ self._render_header()
291
+
292
+ def _tick(self) -> None:
293
+ self._frame += 1
294
+ self._render_header()
295
+
296
+ def _symbol(self) -> tuple[str, str]:
297
+ if not self.done:
298
+ frame = SPINNER_FRAMES[self._frame % len(SPINNER_FRAMES)]
299
+ return frame, PALETTE["graphite"]
300
+ if self._interrupted:
301
+ return INTERRUPTED_SYMBOL, PALETTE["sand"]
302
+ if self._failed:
303
+ return "✖", PALETTE["red"]
304
+ if not self._has_body:
305
+ return "·", PALETTE["graphite"]
306
+ return COLLAPSED_SYMBOL if self.collapsed else EXPANDED_SYMBOL, PALETTE["teal"]
307
+
308
+ def _stop_timer(self) -> None:
309
+ if self._timer is not None:
310
+ self._timer.stop()
311
+ self._timer = None
312
+
313
+ def interrupt(self) -> None:
314
+ if self.done:
315
+ return
316
+ self._stop_timer()
317
+ self.done = True
318
+ self._interrupted = True
319
+ self._status = Text("interrupted", style=PALETTE["sand"])
320
+ self._render_header()
321
+
322
+ def _render_header(self) -> None:
323
+ symbol, symbol_colour = self._symbol()
324
+ left = Text.assemble(
325
+ (f"{symbol} ", symbol_colour),
326
+ (self.tool_name, f"bold {tool_colour(self.tool_kind)}"),
327
+ )
328
+ head = headline_of(self.args)
329
+ if head:
330
+ left.append(" ")
331
+ left.append(head[1], style=PALETTE["silver"])
332
+
333
+ self._header.update(_two_column(left, self._status))
334
+
335
+ def toggle(self) -> None:
336
+ if not self.done or not self._has_body:
337
+ return
338
+ self.collapsed = not self.collapsed
339
+ self._body.display = not self.collapsed
340
+ self._render_header()
341
+
342
+ async def complete(
343
+ self,
344
+ success: bool,
345
+ output: str,
346
+ error: str | None,
347
+ metadata: dict[str, Any] | None,
348
+ truncated: bool,
349
+ diff: str | None,
350
+ model_name: str,
351
+ exit_code: int | None = None,
352
+ ) -> None:
353
+ self._stop_timer()
354
+ self.done = True
355
+ self._failed = not success
356
+
357
+ elapsed = format_elapsed(time.monotonic() - self.started_at)
358
+ self._status = Text()
359
+ if not success:
360
+ self._status.append("failed", style=f"bold {PALETTE['red']}")
361
+ self._status.append(" · ", style=PALETTE["graphite"])
362
+ else:
363
+ if diff:
364
+ self._status.append(diff_stat(diff), style=PALETTE["silver"])
365
+ self._status.append(" · ", style=PALETTE["graphite"])
366
+ total = (metadata or {}).get("total")
367
+ completed = (metadata or {}).get("completed")
368
+ if self.tool_name == "todo" and isinstance(total, int) and isinstance(completed, int) and total:
369
+ self._status.append(f"{completed}/{total}", style=PALETTE["silver"])
370
+ self._status.append(" · ", style=PALETTE["graphite"])
371
+ self._status.append("✓ ", style=PALETTE["teal"])
372
+ self._status.append(elapsed, style=PALETTE["graphite"])
373
+
374
+ blocks = self._build_blocks(
375
+ success, output, error, metadata, truncated, diff, model_name, exit_code
376
+ )
377
+ self._has_body = bool(blocks)
378
+ if blocks:
379
+ await self._body.mount_all([Static(block) for block in blocks])
380
+
381
+ if self._has_body and (not success or self.tool_kind == "write"):
382
+ self.collapsed = False
383
+ self._body.display = True
384
+
385
+ self._render_header()
386
+
387
+ def _result_summary(
388
+ self, metadata: dict[str, Any], head: tuple[str, str] | None
389
+ ) -> list[str]:
390
+ summary: list[str] = []
391
+
392
+ path = metadata.get("path")
393
+ if not head and isinstance(path, str):
394
+ summary.append(display_path_relative_to_cwd(path, self.cwd))
395
+
396
+ entries = metadata.get("entries")
397
+ if isinstance(entries, int):
398
+ summary.append(f"{entries} entries")
399
+
400
+ matches = metadata.get("matches")
401
+ if isinstance(matches, int):
402
+ summary.append(f"{matches} matches")
403
+
404
+ files_searched = metadata.get("files_searched")
405
+ if isinstance(files_searched, int):
406
+ summary.append(f"searched {files_searched} files")
407
+
408
+ results = metadata.get("results")
409
+ if isinstance(results, int):
410
+ summary.append(f"{results} results")
411
+
412
+ status_code = metadata.get("status_code")
413
+ if isinstance(status_code, int):
414
+ summary.append(f"HTTP {status_code}")
415
+
416
+ content_length = metadata.get("content_length")
417
+ if isinstance(content_length, int):
418
+ summary.append(f"{content_length} bytes")
419
+
420
+ return summary
421
+
422
+ def _build_blocks(
423
+ self,
424
+ success: bool,
425
+ output: str,
426
+ error: str | None,
427
+ metadata: dict[str, Any] | None,
428
+ truncated: bool,
429
+ diff: str | None,
430
+ model_name: str,
431
+ exit_code: int | None = None,
432
+ ) -> list[Any]:
433
+ head = headline_of(self.args)
434
+ metadata = metadata or {}
435
+ primary_path = None
436
+ if isinstance(metadata.get("path"), str):
437
+ primary_path = metadata["path"]
438
+
439
+ if success and self.tool_name == "todo":
440
+ return []
441
+
442
+ blocks: list[Any] = []
443
+
444
+ secondary = secondary_args(self.args, head[0] if head else None)
445
+ if secondary:
446
+ grid = Table.grid(padding=(0, 1))
447
+ grid.add_column(style=PALETTE["graphite"], justify="right", no_wrap=True)
448
+ grid.add_column(style=PALETTE["silver"], overflow="fold")
449
+ for key, value in ordered_args(self.tool_name, secondary):
450
+ grid.add_row(key, summarise_value(key, value))
451
+ blocks.append(grid)
452
+
453
+ if not success:
454
+ blocks.append(Text(error or "Tool failed", style=f"bold {PALETTE['red']}"))
455
+ if output.strip():
456
+ blocks.append(
457
+ Text(truncate_text(output, "", MAX_BLOCK_TOKENS), style=PALETTE["graphite"])
458
+ )
459
+
460
+ elif self.tool_name == "read" and primary_path:
461
+ result = extract_read_code(output)
462
+ start_line, code = result if result else (1, output)
463
+ shown_start = metadata.get("shown_start")
464
+ shown_end = metadata.get("shown_end")
465
+ total_lines = metadata.get("total_lines")
466
+ if shown_start and shown_end and total_lines:
467
+ blocks.append(
468
+ Text(
469
+ f"lines {shown_start}–{shown_end} of {total_lines}",
470
+ style=PALETTE["graphite"],
471
+ )
472
+ )
473
+ blocks.append(
474
+ Syntax(
475
+ code,
476
+ guess_language(primary_path),
477
+ theme="nord",
478
+ line_numbers=True,
479
+ start_line=start_line,
480
+ word_wrap=False,
481
+ )
482
+ )
483
+
484
+ elif self.tool_name in {"write", "edit"}:
485
+ blocks.append(Text(output.strip() or "Completed", style=PALETTE["graphite"]))
486
+ if diff:
487
+ blocks.append(
488
+ Syntax(
489
+ truncate_text(diff, model_name, MAX_DIFF_TOKENS),
490
+ "diff",
491
+ theme="nord",
492
+ word_wrap=True,
493
+ )
494
+ )
495
+
496
+ elif self.tool_name in TEXT_OUTPUT_TOOLS:
497
+ summary = self._result_summary(metadata, head)
498
+ if summary:
499
+ blocks.append(Text(" ┈ ".join(summary), style=PALETTE["graphite"]))
500
+ if self.tool_name == "shell" and exit_code:
501
+ blocks.append(Text(f"exit code {exit_code}", style=PALETTE["sand"]))
502
+ if output.strip():
503
+ blocks.append(
504
+ Syntax(
505
+ truncate_text(output, model_name, MAX_BLOCK_TOKENS),
506
+ "text",
507
+ theme="nord",
508
+ word_wrap=True,
509
+ )
510
+ )
511
+
512
+ elif output.strip():
513
+ blocks.append(Text(truncate_text(output, "", MAX_BLOCK_TOKENS), style=PALETTE["silver"]))
514
+
515
+ if truncated:
516
+ blocks.append(Text("Tool output was truncated", style=PALETTE["sand"]))
517
+
518
+ return blocks
519
+
520
+
521
+ class UserMessage(Static):
522
+ pass
523
+
524
+
525
+ class AssistantMessage(Static):
526
+
527
+ def __init__(self) -> None:
528
+ super().__init__()
529
+ self._buffer = ""
530
+
531
+ def append(self, content: str) -> None:
532
+ self._buffer += content
533
+ self.update(Text(self._buffer, style=PALETTE["silver"]))
534
+
535
+
536
+ class Splash(Static):
537
+
538
+ def __init__(self, cwd: str, model: str) -> None:
539
+ super().__init__()
540
+ self._rows = [
541
+ ("cwd", _tilde(cwd)),
542
+ ("model", model),
543
+ ("commands", "/exit"),
544
+ ]
545
+
546
+ def on_mount(self) -> None:
547
+ self._draw()
548
+
549
+ def on_resize(self) -> None:
550
+ self._draw()
551
+
552
+ def _caption(self, width: int) -> Text:
553
+ label_width = max(len(label) for label, _ in self._rows)
554
+ gutter = label_width + 2
555
+ cells = [(label, Text(value, no_wrap=True)) for label, value in self._rows]
556
+ for _, value in cells:
557
+ value.truncate(max(width - gutter, 8), overflow="ellipsis")
558
+
559
+ block_width = gutter + max(value.cell_len for _, value in cells)
560
+ indent = " " * max((width - block_width) // 2, 0)
561
+
562
+ caption = Text(no_wrap=True)
563
+ for index, (label, value) in enumerate(cells):
564
+ caption.append(indent + label.ljust(label_width) + " ", style=PALETTE["slate"])
565
+ value.stylize_before(PALETTE["graphite"])
566
+ caption.append_text(value)
567
+ if index != len(cells) - 1:
568
+ caption.append("\n")
569
+ return caption
570
+
571
+ def _draw(self) -> None:
572
+ width = self.size.width
573
+ if not width:
574
+ return
575
+
576
+ if width < LOGO_MIN_WIDTH:
577
+ mark = Text("relay", style=f"bold {PALETTE['bright']}", justify="center")
578
+ else:
579
+ mark = Group(gradient_logo(justify="center"), Text(), small_wordmark(justify="center"))
580
+
581
+ self.update(Group(mark, Text(), self._caption(width)))
582
+
583
+
584
+ def _compact_tokens(count: int) -> str:
585
+ if count < 1000:
586
+ return str(count)
587
+ if count < 1_000_000:
588
+ return f"{count / 1000:.1f}k"
589
+ return f"{count / 1_000_000:.1f}M"
590
+
591
+
592
+ def _elapsed(seconds: float) -> str:
593
+ if seconds < 60:
594
+ return f"{seconds:.1f}s"
595
+ minutes, secs = divmod(int(seconds), 60)
596
+ return f"{minutes}m{secs:02d}s"
597
+
598
+
599
+ class Thinking(Static):
600
+
601
+ def __init__(self) -> None:
602
+ super().__init__()
603
+ self._frame = 0
604
+ self._timer = None
605
+ self._started_at = time.monotonic()
606
+ self._tokens = 0
607
+
608
+ def on_mount(self) -> None:
609
+ self._timer = self.set_interval(SPINNER_INTERVAL, self._tick)
610
+ self._tick()
611
+
612
+ def set_tokens(self, completion_tokens: int) -> None:
613
+ self._tokens = completion_tokens
614
+ self._tick()
615
+
616
+ def _tick(self) -> None:
617
+ self._frame += 1
618
+ frame = SPINNER_FRAMES[self._frame % len(SPINNER_FRAMES)]
619
+
620
+ line = Text(f"{frame} Thinking… ", style=PALETTE["graphite"])
621
+ line.append(_elapsed(time.monotonic() - self._started_at), style=PALETTE["graphite"])
622
+ if self._tokens:
623
+ line.append(" · ", style=PALETTE["slate"])
624
+ line.append(f"{_compact_tokens(self._tokens)} tokens", style=PALETTE["graphite"])
625
+ self.update(line)
626
+
627
+ def on_unmount(self) -> None:
628
+ if self._timer is not None:
629
+ self._timer.stop()
630
+
631
+
632
+ class RelayApp(App):
633
+ CSS = f"""
634
+ /* No painted background: relay sits on the terminal's own colours. */
635
+ Screen {{
636
+ background: transparent;
637
+ align-horizontal: center;
638
+ }}
639
+
640
+ /* Everything the user reads lives in one centred column. */
641
+ #column {{
642
+ width: 100%;
643
+ max-width: 96;
644
+ height: 1fr;
645
+ padding: 0 1;
646
+ }}
647
+
648
+ Footer {{
649
+ dock: bottom;
650
+ height: 1;
651
+ width: 100%;
652
+ padding: 0 2;
653
+ }}
654
+
655
+ #transcript {{
656
+ height: 1fr;
657
+ scrollbar-size-vertical: 1;
658
+ scrollbar-background: transparent;
659
+ scrollbar-color: {PALETTE['slate']};
660
+ }}
661
+
662
+ Splash {{
663
+ height: 1fr;
664
+ content-align: center middle;
665
+ }}
666
+
667
+ /* Sits between the transcript and the prompt, so it never scrolls away. */
668
+ TodoPanel {{
669
+ height: auto;
670
+ margin-top: 1;
671
+ }}
672
+
673
+ PromptRule {{
674
+ height: 1;
675
+ margin-top: 1;
676
+ }}
677
+
678
+ PromptRow {{
679
+ height: 1;
680
+ margin-bottom: 1;
681
+ }}
682
+
683
+ Caret {{
684
+ width: 2;
685
+ color: {PALETTE['slate']};
686
+ }}
687
+ PromptRow:focus-within Caret {{
688
+ color: {PALETTE['accent']};
689
+ text-style: bold;
690
+ }}
691
+
692
+ #prompt {{
693
+ height: 1;
694
+ width: 1fr;
695
+ border: none;
696
+ padding: 0;
697
+ background: transparent;
698
+ }}
699
+ #prompt:focus {{
700
+ border: none;
701
+ }}
702
+
703
+ UserMessage {{
704
+ color: {PALETTE['bright']};
705
+ text-style: bold;
706
+ margin: 1 0 0 0;
707
+ }}
708
+
709
+ AssistantMessage {{
710
+ height: auto;
711
+ margin: 1 0 0 0;
712
+ }}
713
+
714
+ ToolCall {{
715
+ height: auto;
716
+ margin: 1 0 0 0;
717
+ }}
718
+
719
+ ToolHeader {{
720
+ height: 1;
721
+ }}
722
+ ToolHeader:focus {{
723
+ background: $boost;
724
+ }}
725
+
726
+ ToolBody {{
727
+ height: auto;
728
+ padding-left: 1;
729
+ margin-top: 1;
730
+ }}
731
+
732
+ Thinking {{
733
+ height: 1;
734
+ margin: 1 0 0 0;
735
+ }}
736
+ """
737
+
738
+ BINDINGS = [
739
+ Binding("ctrl+c", "quit", "Quit"),
740
+ Binding("ctrl+d", "quit", "Quit", show=False),
741
+ Binding("ctrl+t", "toggle_all", "Toggle tool details"),
742
+ ]
743
+
744
+ RANDOM_WELCOME = [
745
+ 'Ask relay…',
746
+ 'Relay, describe this codebase…',
747
+ 'Ask relay about refactoring…',
748
+ 'Relay, is this thing on…',
749
+ 'Ask relay about /help…',
750
+ 'Contribute to relay: github.com/andrefetch/relay…',
751
+ ]
752
+
753
+ def __init__(self, config: Config) -> None:
754
+ super().__init__()
755
+ self.config = config
756
+ self._stack = AsyncExitStack()
757
+ self.agent: Agent | None = None
758
+ self._tools: dict[str, ToolCall] = {}
759
+ self._thinking: Thinking | None = None
760
+ self._busy = False
761
+
762
+ async def _confirm_tool(self, tool_name: str, arguments: dict[str, Any]) -> bool:
763
+ return True
764
+
765
+ def compose(self) -> ComposeResult:
766
+ with Vertical(id="column"):
767
+ yield VerticalScroll(
768
+ Splash(str(self.config.cwd), self.config.model_name),
769
+ id="transcript",
770
+ )
771
+ yield TodoPanel()
772
+ yield PromptRule(self.config.model_name)
773
+ with PromptRow():
774
+ yield Caret("❯")
775
+ yield Input(placeholder=random.choice(self.RANDOM_WELCOME), id="prompt")
776
+ yield Footer(str(self.config.cwd))
777
+
778
+ @property
779
+ def transcript(self) -> VerticalScroll:
780
+ return self.query_one("#transcript", VerticalScroll)
781
+
782
+ @property
783
+ def prompt_rule(self) -> PromptRule:
784
+ return self.query_one(PromptRule)
785
+
786
+ @property
787
+ def todo_panel(self) -> TodoPanel:
788
+ return self.query_one(TodoPanel)
789
+
790
+ async def on_mount(self) -> None:
791
+ self.agent = await self._stack.enter_async_context(
792
+ Agent(self.config, confirmation_handler=self._confirm_tool)
793
+ )
794
+ self.query_one("#prompt", Input).focus()
795
+
796
+ async def on_unmount(self) -> None:
797
+ await self._stack.aclose()
798
+
799
+ async def _append(self, widget: Widget) -> None:
800
+ if self._thinking is not None and self._thinking.is_mounted:
801
+ await self.transcript.mount(widget, before=self._thinking)
802
+ else:
803
+ await self.transcript.mount(widget)
804
+ self.transcript.scroll_end(animate=False)
805
+
806
+ @on(ToolHeader.Toggle)
807
+ def _toggle_tool(self, event: ToolHeader.Toggle) -> None:
808
+ event.stop()
809
+ tool = event.header.parent
810
+ if isinstance(tool, ToolCall):
811
+ tool.toggle()
812
+ if not tool.collapsed:
813
+ tool.scroll_visible(animate=False)
814
+
815
+ def action_toggle_all(self) -> None:
816
+ tools = [tool for tool in self.query(ToolCall) if tool.done and tool._has_body]
817
+ if not tools:
818
+ return
819
+ target_collapsed = any(not tool.collapsed for tool in tools)
820
+ for tool in tools:
821
+ if tool.collapsed != target_collapsed:
822
+ tool.toggle()
823
+
824
+ @on(Input.Submitted, "#prompt")
825
+ async def _submit(self, event: Input.Submitted) -> None:
826
+ message = event.value.strip()
827
+ if not message or self._busy:
828
+ return
829
+
830
+ if message in {"/exit", "/quit"}:
831
+ self.exit()
832
+ return
833
+
834
+ event.input.value = ""
835
+
836
+ for splash in self.query(Splash):
837
+ await splash.remove()
838
+
839
+ await self._append(UserMessage(Text(f"❯ {message}", style=PALETTE["bright"])))
840
+ self._run_turn(message)
841
+
842
+ @work(exclusive=True)
843
+ async def _run_turn(self, message: str) -> None:
844
+ assert self.agent is not None
845
+ self._busy = True
846
+ prompt_input = self.query_one("#prompt", Input)
847
+ prompt_input.disabled = True
848
+
849
+ thinking = Thinking()
850
+ await self._append(thinking)
851
+ self._thinking = thinking
852
+ assistant: AssistantMessage | None = None
853
+
854
+ try:
855
+ async for event in self.agent.run(message):
856
+ if event.type == AgentEventType.USAGE:
857
+ usage = event.data.get("usage") or {}
858
+ thinking.set_tokens(usage.get("completion_tokens", 0) or 0)
859
+
860
+ elif event.type == AgentEventType.TEXT_DELTA:
861
+ if assistant is None:
862
+ assistant = AssistantMessage()
863
+ await self._append(assistant)
864
+ assistant.append(event.data.get("content", ""))
865
+ self.transcript.scroll_end(animate=False)
866
+
867
+ elif event.type == AgentEventType.TEXT_COMPLETE:
868
+ assistant = None
869
+
870
+ elif event.type == AgentEventType.TOOL_CALL_START:
871
+ await self._tool_start(event.data)
872
+
873
+ elif event.type == AgentEventType.TOOL_CALL_COMPLETE:
874
+ await self._tool_complete(event.data)
875
+
876
+ elif event.type == AgentEventType.AGENT_END:
877
+ self._update_usage(event.data.get("usage"))
878
+
879
+ elif event.type == AgentEventType.AGENT_ERROR:
880
+ await self._append(
881
+ Static(Text(str(event.data.get("error")), style=f"bold {PALETTE['red']}"))
882
+ )
883
+ break
884
+ except Exception as exc:
885
+ await self._append(Static(Text(f"{type(exc).__name__}: {exc}", style=f"bold {PALETTE['red']}")))
886
+ finally:
887
+ self._thinking = None
888
+ if thinking.is_mounted:
889
+ await thinking.remove()
890
+ for pending in self._tools.values():
891
+ pending.interrupt()
892
+ self._tools.clear()
893
+ self._busy = False
894
+ prompt_input.disabled = False
895
+ prompt_input.focus()
896
+
897
+ def _tool_kind(self, tool_name: str) -> str | None:
898
+ assert self.agent is not None
899
+ tool = self.agent.session.tool_registery.get(tool_name)
900
+ return tool.kind.value if tool else None
901
+
902
+ def _relativise(self, arguments: dict[str, Any]) -> dict[str, Any]:
903
+ display_args = dict(arguments)
904
+ for key in ("path", "cwd"):
905
+ value = display_args.get(key)
906
+ if isinstance(value, str) and self.config.cwd:
907
+ display_args[key] = str(display_path_relative_to_cwd(value, self.config.cwd))
908
+ return display_args
909
+
910
+ async def _tool_start(self, data: dict[str, Any]) -> None:
911
+ call_id = data.get("call_id", "")
912
+ name = data.get("name", "unknown")
913
+ widget = ToolCall(
914
+ name,
915
+ self._tool_kind(name),
916
+ self._relativise(data.get("arguments", {})),
917
+ self.config.cwd,
918
+ )
919
+ self._tools[call_id] = widget
920
+ await self._append(widget)
921
+
922
+ async def _tool_complete(self, data: dict[str, Any]) -> None:
923
+ metadata = data.get("metadata") or {}
924
+ if data.get("name") == "todo" and data.get("success") and "todos" in metadata:
925
+ self.todo_panel.set_todos(metadata)
926
+
927
+ widget = self._tools.pop(data.get("call_id", ""), None)
928
+ if widget is None:
929
+ return
930
+
931
+ await widget.complete(
932
+ data.get("success", False),
933
+ data.get("output", ""),
934
+ data.get("error"),
935
+ data.get("metadata"),
936
+ data.get("truncated", False),
937
+ data.get("diff"),
938
+ self.config.model_name,
939
+ data.get("exit_code"),
940
+ )
941
+ self.transcript.scroll_end(animate=False)
942
+
943
+ def _update_usage(self, usage: dict[str, Any] | None) -> None:
944
+ if not usage:
945
+ return
946
+ self.prompt_rule.set_usage(
947
+ usage.get("prompt_tokens", 0) or 0,
948
+ self.config.model.context_window,
949
+ )