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.
- agent/__init__.py +6 -0
- agent/agent.py +162 -0
- agent/events.py +133 -0
- agent/session.py +71 -0
- client/__init__.py +23 -0
- client/llm_client.py +236 -0
- client/response.py +83 -0
- config/__init__.py +6 -0
- config/config.py +76 -0
- config/credentials.py +54 -0
- config/loader.py +108 -0
- config/oauth.py +137 -0
- context/__init__.py +5 -0
- context/manager.py +92 -0
- main.py +231 -0
- prompts/__init__.py +0 -0
- prompts/system.py +350 -0
- relay_code-0.1.0.dist-info/METADATA +64 -0
- relay_code-0.1.0.dist-info/RECORD +50 -0
- relay_code-0.1.0.dist-info/WHEEL +5 -0
- relay_code-0.1.0.dist-info/entry_points.txt +2 -0
- relay_code-0.1.0.dist-info/licenses/LICENSE +674 -0
- relay_code-0.1.0.dist-info/top_level.txt +9 -0
- tools/__init__.py +14 -0
- tools/base.py +188 -0
- tools/core/__init__.py +44 -0
- tools/core/directories.py +65 -0
- tools/core/edit.py +162 -0
- tools/core/glob.py +92 -0
- tools/core/grep.py +128 -0
- tools/core/read.py +129 -0
- tools/core/shell.py +162 -0
- tools/core/todo.py +135 -0
- tools/core/write.py +76 -0
- tools/memory/__init__.py +0 -0
- tools/memory/memory.py +191 -0
- tools/network/__init__.py +0 -0
- tools/network/fetch.py +72 -0
- tools/network/search.py +72 -0
- tools/registry.py +96 -0
- ui/__init__.py +5 -0
- ui/app.py +949 -0
- ui/format.py +145 -0
- ui/logo.py +46 -0
- ui/renderer.py +580 -0
- ui/theme.py +60 -0
- utils/__init__.py +17 -0
- utils/errors.py +47 -0
- utils/paths.py +44 -0
- utils/text.py +74 -0
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
|
+
)
|