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/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
|