rlmness-cli 0.2.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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/tui/messages.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Transcript block widgets: user turns, assistant turns, and tool lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.markdown import Markdown
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
from rich.syntax import Syntax
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.widgets import Collapsible, Static
|
|
11
|
+
|
|
12
|
+
_MAX_INLINE_OUTPUT = 400
|
|
13
|
+
|
|
14
|
+
RLM_AGENT_MARK = "›" # prompt marker for user turns
|
|
15
|
+
ACCENT = "#c9a4ff" # violet accent, shared by markup and rlm_agent.tcss
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def highlighted_code(code: str) -> Syntax:
|
|
19
|
+
"""Python as the terminal shows it: coloured, wrapped, on the app's background."""
|
|
20
|
+
return Syntax(code, "python", theme="monokai", word_wrap=True, background_color="default")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_message(text: str) -> Markdown:
|
|
24
|
+
"""Message text as formatted Markdown; never read as console markup."""
|
|
25
|
+
return Markdown(text, code_theme="monokai")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UserBlock(Static):
|
|
29
|
+
"""One user message."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, text: str) -> None:
|
|
32
|
+
super().__init__(f"{RLM_AGENT_MARK} {escape(text)}", classes="user-block")
|
|
33
|
+
self._text = text
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def text(self) -> str:
|
|
37
|
+
"""The message this block displays."""
|
|
38
|
+
return self._text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AssistantBlock(Static):
|
|
42
|
+
"""One assistant message, appended to incrementally while streaming."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, text: str = "") -> None:
|
|
45
|
+
super().__init__(render_message(text), classes="assistant-block")
|
|
46
|
+
self._buffer = text
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def text(self) -> str:
|
|
50
|
+
"""Accumulated text so far."""
|
|
51
|
+
return self._buffer
|
|
52
|
+
|
|
53
|
+
def append(self, delta: str) -> None:
|
|
54
|
+
"""Append streamed text and re-render."""
|
|
55
|
+
self._buffer += delta
|
|
56
|
+
self.update(render_message(self._buffer))
|
|
57
|
+
|
|
58
|
+
def finalize(self, text: str) -> None:
|
|
59
|
+
"""Replace the buffer with the completed message."""
|
|
60
|
+
# Progress streamed before the answer (code, output) stays on screen above it.
|
|
61
|
+
if self._buffer.strip() and text not in self._buffer:
|
|
62
|
+
text = f"{self._buffer.rstrip()}\n\n{text}" if text else self._buffer
|
|
63
|
+
elif text in self._buffer:
|
|
64
|
+
text = self._buffer
|
|
65
|
+
self._buffer = text
|
|
66
|
+
self.update(render_message(text))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ToolLine(Static):
|
|
70
|
+
"""A compact one-line record of a tool invocation."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, call_id: str, summary: str) -> None:
|
|
73
|
+
super().__init__(f"· {escape(summary)}", classes="tool-line")
|
|
74
|
+
self.call_id = call_id
|
|
75
|
+
self._summary = summary
|
|
76
|
+
|
|
77
|
+
def mark_done(self, *, is_error: bool) -> None:
|
|
78
|
+
"""Flip the pending marker to success or failure."""
|
|
79
|
+
if is_error:
|
|
80
|
+
self.update(f"✗ {escape(self._summary)}")
|
|
81
|
+
self.add_class("tool-error")
|
|
82
|
+
else:
|
|
83
|
+
self.update(f"✓ {escape(self._summary)}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ToolCode(Static):
|
|
87
|
+
"""The Python a tool call runs."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, code: str) -> None:
|
|
90
|
+
super().__init__(highlighted_code(code), classes="tool-code")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ToolOutput(Vertical):
|
|
94
|
+
"""Collapsible container for a tool's stdout when it is large."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, output: str) -> None:
|
|
97
|
+
super().__init__(classes="tool-output")
|
|
98
|
+
self._output = output
|
|
99
|
+
|
|
100
|
+
def compose(self) -> ComposeResult:
|
|
101
|
+
lines = self._output.splitlines()
|
|
102
|
+
title = f"{len(lines)} lines of output"
|
|
103
|
+
with Collapsible(title=title, collapsed=True):
|
|
104
|
+
yield Static(escape(self._output))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def should_collapse(output: str) -> bool:
|
|
108
|
+
"""Whether *output* is large enough to warrant a collapsible block."""
|
|
109
|
+
return len(output) > _MAX_INLINE_OUTPUT or output.count("\n") > 6
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class NoticeBlock(Static):
|
|
113
|
+
"""A non-conversational notice: command output, errors, cancellations."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, text: str, *, error: bool = False) -> None:
|
|
116
|
+
super().__init__(escape(text), classes="notice-block")
|
|
117
|
+
if error:
|
|
118
|
+
self.add_class("tool-error")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
#: Lines a shell result keeps on screen; earlier ones are counted, not shown.
|
|
122
|
+
SHELL_TAIL = 20
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ErrorBlock(Vertical):
|
|
126
|
+
"""An error: its first line always shown, the rest folded underneath."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, text: str) -> None:
|
|
129
|
+
super().__init__(classes="error-block")
|
|
130
|
+
self._text = text.strip() or "error"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def headline(self) -> str:
|
|
134
|
+
return self._text.splitlines()[0]
|
|
135
|
+
|
|
136
|
+
def compose(self) -> ComposeResult:
|
|
137
|
+
first, _, rest = self._text.partition("\n")
|
|
138
|
+
yield Static(f"✗ {escape(first)}", classes="tool-error")
|
|
139
|
+
if rest.strip():
|
|
140
|
+
with Collapsible(title=f"details ({len(rest.splitlines())} lines)", collapsed=True):
|
|
141
|
+
yield Static(escape(rest.strip()))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ShellBlock(Static):
|
|
145
|
+
"""A shell command's result in a box, showing only its last lines."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, command: str, output: str, exit_code: int | None = None) -> None:
|
|
148
|
+
lines = output.rstrip("\n").splitlines()
|
|
149
|
+
hidden = max(0, len(lines) - SHELL_TAIL)
|
|
150
|
+
body = "\n".join(lines[-SHELL_TAIL:]) or "(no output)"
|
|
151
|
+
if hidden:
|
|
152
|
+
body = f"… {hidden} earlier line{'s' if hidden != 1 else ''}\n{body}"
|
|
153
|
+
super().__init__(escape(body), classes="shell-block")
|
|
154
|
+
self.border_title = f"$ {command}"
|
|
155
|
+
self.border_subtitle = "" if exit_code is None else f"exit {exit_code}"
|
|
156
|
+
if exit_code not in (None, 0):
|
|
157
|
+
self.add_class("shell-failed")
|
|
158
|
+
self.hidden_lines = hidden
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CommandEcho(Static):
|
|
162
|
+
"""The slash command the user ran, with its name picked out."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, text: str) -> None:
|
|
165
|
+
name, _, rest = text.strip().partition(" ")
|
|
166
|
+
markup = f"[{ACCENT}]{escape(name)}[/]" + (f" {escape(rest)}" if rest else "")
|
|
167
|
+
super().__init__(f"{RLM_AGENT_MARK} {markup}", classes="command-echo")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class SideAnswer(Static):
|
|
171
|
+
"""An answer to a /btw side question, set apart from the main thread."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, question: str, answer: str) -> None:
|
|
174
|
+
super().__init__(classes="side-answer")
|
|
175
|
+
self.update(render_message(f"**btw** {question}\n\n{answer}"))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class KindNotice(Static):
|
|
179
|
+
"""A notice with a kind label: compaction, refinement, skill, injected."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, kind: str, text: str) -> None:
|
|
182
|
+
super().__init__(f"[{ACCENT}]{escape(kind)}[/] {escape(text)}",
|
|
183
|
+
classes=f"kind-notice kind-{kind.replace(' ', '-')}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class QueuedStrip(Static):
|
|
187
|
+
"""Messages waiting for the current run to end, shown above the prompt."""
|
|
188
|
+
|
|
189
|
+
def __init__(self) -> None:
|
|
190
|
+
super().__init__("", id="queued-strip")
|
|
191
|
+
self.display = False
|
|
192
|
+
|
|
193
|
+
def show(self, queued: list[str]) -> None:
|
|
194
|
+
self.display = bool(queued)
|
|
195
|
+
if queued:
|
|
196
|
+
rows = [f"{i}. {escape(text.splitlines()[0][:120])}" for i, text in enumerate(queued, 1)]
|
|
197
|
+
self.update(f"queued ({len(queued)}) — sent when this run ends\n" + "\n".join(rows))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = [
|
|
201
|
+
"AssistantBlock",
|
|
202
|
+
"CommandEcho",
|
|
203
|
+
"ErrorBlock",
|
|
204
|
+
"KindNotice",
|
|
205
|
+
"QueuedStrip",
|
|
206
|
+
"SHELL_TAIL",
|
|
207
|
+
"ShellBlock",
|
|
208
|
+
"SideAnswer",
|
|
209
|
+
"NoticeBlock",
|
|
210
|
+
"ToolCode",
|
|
211
|
+
"ToolLine",
|
|
212
|
+
"ToolOutput",
|
|
213
|
+
"UserBlock",
|
|
214
|
+
"should_collapse",
|
|
215
|
+
]
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Modal picker for switching provider and model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from rlmagent.tui.search_box import SearchBox
|
|
11
|
+
from textual.widgets import Input, Label, ListItem, ListView
|
|
12
|
+
|
|
13
|
+
from rlmagent.config.store import PROVIDERS, ProviderInfo, load_config, recent_models
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ModelChoice:
|
|
18
|
+
"""The result of a completed picker run."""
|
|
19
|
+
|
|
20
|
+
provider: str
|
|
21
|
+
label: str
|
|
22
|
+
model: str
|
|
23
|
+
api_key: str | None
|
|
24
|
+
base_url: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Row(ListItem):
|
|
28
|
+
"""A list row carrying an opaque value."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, text: str, value: str) -> None:
|
|
31
|
+
super().__init__(Label(text))
|
|
32
|
+
self.value = value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _explain(error: Exception) -> str:
|
|
36
|
+
"""A failed provider check, in words that say what to change."""
|
|
37
|
+
status = getattr(getattr(error, "response", None), "status_code", None)
|
|
38
|
+
if status in (401, 403):
|
|
39
|
+
return f"The provider rejected this key ({status}). Check it and try again."
|
|
40
|
+
if status == 404:
|
|
41
|
+
return "Nothing at that endpoint (404). Check the base URL with Ctrl+E."
|
|
42
|
+
if status:
|
|
43
|
+
return f"The provider answered {status}. Try again, or check the endpoint."
|
|
44
|
+
return f"Could not reach the provider ({type(error).__name__}). Check the endpoint."
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ModelScreen(ModalScreen[ModelChoice | None]):
|
|
48
|
+
"""Small centred dialog: pick a provider, a model, and a key if needed."""
|
|
49
|
+
|
|
50
|
+
BINDINGS = [
|
|
51
|
+
("escape", "cancel", "Cancel"),
|
|
52
|
+
("ctrl+f", "discover", "Fetch models"),
|
|
53
|
+
("ctrl+t", "test", "Test credentials"),
|
|
54
|
+
("ctrl+e", "endpoint", "Endpoint"),
|
|
55
|
+
("ctrl+r", "reveal", "Show key"),
|
|
56
|
+
("ctrl+d", "forget", "Remove recent"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
def __init__(self, current_provider: str = "", current_model: str = "",
|
|
60
|
+
start_provider: str = "", title: str = "Select provider") -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
#: Open straight on this provider's page (``/login openai``).
|
|
63
|
+
self._start_provider = start_provider
|
|
64
|
+
self._title = title
|
|
65
|
+
self._current_provider = current_provider
|
|
66
|
+
self._fetched: list[str] = []
|
|
67
|
+
self._current_model = current_model
|
|
68
|
+
self._info: ProviderInfo | None = None
|
|
69
|
+
self._model: str = ""
|
|
70
|
+
self._input_mode = "key"
|
|
71
|
+
self._endpoint = None
|
|
72
|
+
|
|
73
|
+
# -- composition --------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def compose(self) -> ComposeResult:
|
|
76
|
+
with Vertical(id="model-dialog"):
|
|
77
|
+
yield Label("Select provider", id="model-title")
|
|
78
|
+
yield ListView(id="model-list")
|
|
79
|
+
yield SearchBox(placeholder="Type to filter models", id="model-filter")
|
|
80
|
+
yield Input(placeholder="API key", password=True, id="model-key")
|
|
81
|
+
yield Label("", id="model-hint")
|
|
82
|
+
|
|
83
|
+
def on_mount(self) -> None:
|
|
84
|
+
self.query_one("#model-key", Input).display = False
|
|
85
|
+
self.query_one("#model-filter", Input).display = False
|
|
86
|
+
self._show_providers()
|
|
87
|
+
if self._start_provider:
|
|
88
|
+
self._on_provider_chosen(self._start_provider)
|
|
89
|
+
|
|
90
|
+
# -- steps --------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _show_providers(self) -> None:
|
|
93
|
+
"""Step 1 — list the supported providers."""
|
|
94
|
+
self.query_one("#model-title", Label).update(self._title)
|
|
95
|
+
listing = self.query_one("#model-list", ListView)
|
|
96
|
+
listing.clear()
|
|
97
|
+
for recent in recent_models():
|
|
98
|
+
listing.append(_Row(f"↺ {recent['model']} · {recent['provider']}",
|
|
99
|
+
f"recent:{recent['provider']}|{recent['model']}"))
|
|
100
|
+
for info in PROVIDERS:
|
|
101
|
+
marker = "●" if info.key == self._current_provider else " "
|
|
102
|
+
listing.append(_Row(f"{marker} {info.label}", info.key))
|
|
103
|
+
listing.index = 0
|
|
104
|
+
listing.focus()
|
|
105
|
+
|
|
106
|
+
def _show_models(self, info: ProviderInfo) -> None:
|
|
107
|
+
"""Step 2 — list that provider's models."""
|
|
108
|
+
self._info = info
|
|
109
|
+
self.query_one("#model-title", Label).update(f"{info.label} — select model")
|
|
110
|
+
listing = self.query_one("#model-list", ListView)
|
|
111
|
+
listing.clear()
|
|
112
|
+
from rlmagent.model.discovery import price_label
|
|
113
|
+
|
|
114
|
+
for name in info.models:
|
|
115
|
+
marker = "●" if name == self._current_model else " "
|
|
116
|
+
price = price_label(info.key, name)
|
|
117
|
+
listing.append(_Row(f"{marker} {name}" + (f" {price}" if price else ""), name))
|
|
118
|
+
listing.append(_Row("Enter a model identifier…", "__custom__"))
|
|
119
|
+
listing.index = 0
|
|
120
|
+
listing.focus()
|
|
121
|
+
self.query_one("#model-hint", Label).update(
|
|
122
|
+
"Ctrl+F fetch models · Ctrl+T test credentials · Ctrl+E endpoint"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _input(self, mode, title, placeholder, password=False):
|
|
126
|
+
self._input_mode = mode
|
|
127
|
+
self.query_one("#model-list", ListView).display = False
|
|
128
|
+
field = self.query_one("#model-key", Input)
|
|
129
|
+
field.password, field.placeholder, field.value = password, placeholder, ""
|
|
130
|
+
field.display = True
|
|
131
|
+
self.query_one("#model-title", Label).update(title)
|
|
132
|
+
field.focus()
|
|
133
|
+
|
|
134
|
+
async def _remote(self, show_models):
|
|
135
|
+
if self._info is None:
|
|
136
|
+
return
|
|
137
|
+
from rlmagent.model.discovery import discover_models, check_provider
|
|
138
|
+
|
|
139
|
+
hint = self.query_one("#model-hint", Label)
|
|
140
|
+
hint.update("Checking provider…")
|
|
141
|
+
try:
|
|
142
|
+
if not show_models:
|
|
143
|
+
hint.update(await check_provider(self._info.key, base_url=self._endpoint))
|
|
144
|
+
return
|
|
145
|
+
models = await discover_models(self._info.key, base_url=self._endpoint)
|
|
146
|
+
except Exception as error:
|
|
147
|
+
hint.update(_explain(error))
|
|
148
|
+
return
|
|
149
|
+
hint.update(f"Model endpoint accepted credentials; {len(models)} models returned.")
|
|
150
|
+
if show_models:
|
|
151
|
+
self._fetched = models
|
|
152
|
+
self._list_fetched("")
|
|
153
|
+
box = self.query_one("#model-filter", Input)
|
|
154
|
+
box.value, box.display = "", True
|
|
155
|
+
box.focus()
|
|
156
|
+
|
|
157
|
+
def _list_fetched(self, text: str) -> None:
|
|
158
|
+
listing = self.query_one("#model-list", ListView)
|
|
159
|
+
listing.clear()
|
|
160
|
+
from rlmagent.model.discovery import price_label
|
|
161
|
+
|
|
162
|
+
rows = [model for model in self._fetched if text.lower() in model.lower()]
|
|
163
|
+
provider = self._info.key if self._info is not None else ""
|
|
164
|
+
for model in rows:
|
|
165
|
+
price = price_label(provider, model)
|
|
166
|
+
listing.append(_Row(f"{model} {price}" if price else model, model))
|
|
167
|
+
listing.append(_Row("Enter a model identifier…", "__custom__"))
|
|
168
|
+
listing.index = rows.index(self._current_model) if self._current_model in rows else 0
|
|
169
|
+
|
|
170
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
171
|
+
if event.input.id == "model-filter":
|
|
172
|
+
event.stop()
|
|
173
|
+
self._list_fetched(event.value)
|
|
174
|
+
|
|
175
|
+
def action_forget(self):
|
|
176
|
+
from rlmagent.config.store import forget_model
|
|
177
|
+
|
|
178
|
+
row = self.query_one("#model-list", ListView).highlighted_child
|
|
179
|
+
value = getattr(row, "value", "")
|
|
180
|
+
if self._info is None and value.startswith("recent:"):
|
|
181
|
+
forget_model(*value.removeprefix("recent:").split("|", 1))
|
|
182
|
+
self._show_providers()
|
|
183
|
+
|
|
184
|
+
def action_reveal(self):
|
|
185
|
+
field = self.query_one("#model-key", Input)
|
|
186
|
+
field.password = not field.password
|
|
187
|
+
|
|
188
|
+
def action_discover(self):
|
|
189
|
+
self.run_worker(self._remote(True), exclusive=True, group="provider-check")
|
|
190
|
+
|
|
191
|
+
def action_test(self):
|
|
192
|
+
self.run_worker(self._remote(False), exclusive=True, group="provider-check")
|
|
193
|
+
|
|
194
|
+
def action_endpoint(self):
|
|
195
|
+
if self._info is not None and self._info.key not in ("vertex", "codex", "copilot", "claude-subscription", "xai-subscription"):
|
|
196
|
+
self._input("endpoint", "Compatible API base URL", "https://host/v1")
|
|
197
|
+
|
|
198
|
+
def _show_key_prompt(self) -> None:
|
|
199
|
+
"""Step 3 — ask for an API key, only when none is stored."""
|
|
200
|
+
assert self._info is not None
|
|
201
|
+
self._input_mode = "key"
|
|
202
|
+
self.query_one("#model-key", Input).password = True
|
|
203
|
+
self.query_one("#model-list", ListView).display = False
|
|
204
|
+
field = self.query_one("#model-key", Input)
|
|
205
|
+
field.display = True
|
|
206
|
+
field.focus()
|
|
207
|
+
self.query_one("#model-title", Label).update(f"{self._info.label} — API key")
|
|
208
|
+
self.query_one("#model-hint", Label).update("Enter to save · Esc to cancel")
|
|
209
|
+
|
|
210
|
+
def _stored_key(self, info: ProviderInfo) -> str | None:
|
|
211
|
+
"""Key already saved in config or the environment, if any."""
|
|
212
|
+
import os
|
|
213
|
+
|
|
214
|
+
return os.environ.get(info.key_env) or load_config().api_keys.get(info.key)
|
|
215
|
+
|
|
216
|
+
# -- events -------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
219
|
+
event.stop()
|
|
220
|
+
row = event.item
|
|
221
|
+
if not isinstance(row, _Row):
|
|
222
|
+
return
|
|
223
|
+
if row.value.startswith("recent:"):
|
|
224
|
+
provider, _, model = row.value.removeprefix("recent:").partition("|")
|
|
225
|
+
self._info = next((p for p in PROVIDERS if p.key == provider), None)
|
|
226
|
+
if self._info is None:
|
|
227
|
+
self._show_providers()
|
|
228
|
+
return
|
|
229
|
+
self._on_model_chosen(model)
|
|
230
|
+
elif self._info is None:
|
|
231
|
+
self._on_provider_chosen(row.value)
|
|
232
|
+
else:
|
|
233
|
+
self._on_model_chosen(row.value)
|
|
234
|
+
|
|
235
|
+
def _on_provider_chosen(self, key: str) -> None:
|
|
236
|
+
info = next((p for p in PROVIDERS if p.key == key), None)
|
|
237
|
+
if info is None:
|
|
238
|
+
return
|
|
239
|
+
self._show_models(info)
|
|
240
|
+
|
|
241
|
+
def _on_model_chosen(self, model: str) -> None:
|
|
242
|
+
assert self._info is not None
|
|
243
|
+
if model == "__custom__":
|
|
244
|
+
self._input("model", "Model identifier", "provider/model")
|
|
245
|
+
return
|
|
246
|
+
self._model = model
|
|
247
|
+
if not self._info.needs_key or self._stored_key(self._info):
|
|
248
|
+
self._finish(None)
|
|
249
|
+
return
|
|
250
|
+
self._show_key_prompt()
|
|
251
|
+
|
|
252
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
253
|
+
event.stop()
|
|
254
|
+
if event.input.id == "model-filter":
|
|
255
|
+
listing = self.query_one("#model-list", ListView)
|
|
256
|
+
event.input.display = False
|
|
257
|
+
listing.focus()
|
|
258
|
+
if listing.highlighted_child is not None:
|
|
259
|
+
self._on_model_chosen(listing.highlighted_child.value)
|
|
260
|
+
return
|
|
261
|
+
key = event.value.strip()
|
|
262
|
+
if self._input_mode in ("model", "endpoint"):
|
|
263
|
+
if not key:
|
|
264
|
+
return
|
|
265
|
+
if self._input_mode == "endpoint":
|
|
266
|
+
from urllib.parse import urlsplit
|
|
267
|
+
|
|
268
|
+
parsed = urlsplit(key)
|
|
269
|
+
if (
|
|
270
|
+
parsed.scheme not in ("http", "https")
|
|
271
|
+
or not parsed.netloc
|
|
272
|
+
or parsed.username
|
|
273
|
+
or parsed.password
|
|
274
|
+
):
|
|
275
|
+
self.query_one("#model-hint", Label).update(
|
|
276
|
+
"Enter an HTTP(S) base URL without credentials."
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
self._endpoint = key.rstrip("/")
|
|
280
|
+
self.query_one("#model-key", Input).display = False
|
|
281
|
+
self.query_one("#model-list", ListView).display = True
|
|
282
|
+
self._show_models(self._info)
|
|
283
|
+
if self._input_mode == "model":
|
|
284
|
+
self._on_model_chosen(key)
|
|
285
|
+
return
|
|
286
|
+
if not key:
|
|
287
|
+
self.query_one("#model-hint", Label).update("API key must not be empty.")
|
|
288
|
+
return
|
|
289
|
+
self._finish(key)
|
|
290
|
+
|
|
291
|
+
def _finish(self, api_key: str | None) -> None:
|
|
292
|
+
assert self._info is not None
|
|
293
|
+
self.dismiss(
|
|
294
|
+
ModelChoice(
|
|
295
|
+
provider=self._info.key,
|
|
296
|
+
label=self._info.label,
|
|
297
|
+
model=self._model,
|
|
298
|
+
api_key=api_key,
|
|
299
|
+
base_url=self._endpoint,
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def action_cancel(self) -> None:
|
|
304
|
+
self.dismiss(None)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
__all__ = ["ModelChoice", "ModelScreen"]
|
rlmagent/tui/prompt.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Bottom multiline prompt input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.message import Message
|
|
6
|
+
from textual.widgets import TextArea
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PromptInput(TextArea):
|
|
10
|
+
"""Multiline input pinned below the transcript."""
|
|
11
|
+
|
|
12
|
+
class Submitted(Message):
|
|
13
|
+
"""Posted when the user presses Enter on a non-empty prompt."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, text: str) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.text = text
|
|
18
|
+
|
|
19
|
+
class TextChanged(Message):
|
|
20
|
+
"""Posted whenever the composer contents change."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, text: str) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.text = text
|
|
25
|
+
|
|
26
|
+
class Navigate(Message):
|
|
27
|
+
"""Posted on up/down while a completion list is open."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, delta: int) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.delta = delta
|
|
32
|
+
|
|
33
|
+
class Complete(Message):
|
|
34
|
+
"""Posted on Tab to accept a completion."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, **kwargs: object) -> None:
|
|
37
|
+
super().__init__(id="prompt-input", **kwargs) # type: ignore[arg-type]
|
|
38
|
+
|
|
39
|
+
def on_mount(self) -> None:
|
|
40
|
+
"""Take focus so the user can type immediately."""
|
|
41
|
+
self.focus()
|
|
42
|
+
|
|
43
|
+
BINDINGS = [
|
|
44
|
+
("alt+b", "cursor_word_left", "Word left"),
|
|
45
|
+
("alt+f", "cursor_word_right", "Word right"),
|
|
46
|
+
("alt+d", "delete_word_right", "Delete word"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
#: Keys that insert a literal newline instead of submitting.
|
|
50
|
+
#: ``shift+enter`` only reaches the app under the kitty keyboard
|
|
51
|
+
#: protocol; ``ctrl+j`` is the universal fallback.
|
|
52
|
+
NEWLINE_KEYS = frozenset({"shift+enter", "alt+enter", "ctrl+j"})
|
|
53
|
+
|
|
54
|
+
async def _on_key(self, event) -> None:
|
|
55
|
+
"""Enter submits; newline keys break the line; Tab/arrows complete."""
|
|
56
|
+
key = event.key
|
|
57
|
+
if key in self.NEWLINE_KEYS:
|
|
58
|
+
event.prevent_default()
|
|
59
|
+
event.stop()
|
|
60
|
+
self.insert("\n")
|
|
61
|
+
self.post_message(self.TextChanged(self.text))
|
|
62
|
+
return
|
|
63
|
+
if key == "enter":
|
|
64
|
+
event.prevent_default()
|
|
65
|
+
event.stop()
|
|
66
|
+
self.submit()
|
|
67
|
+
return
|
|
68
|
+
if key == "tab" and self._completing:
|
|
69
|
+
event.prevent_default()
|
|
70
|
+
event.stop()
|
|
71
|
+
self.post_message(self.Complete())
|
|
72
|
+
return
|
|
73
|
+
if key in ("up", "down") and self._completing:
|
|
74
|
+
event.prevent_default()
|
|
75
|
+
event.stop()
|
|
76
|
+
self.post_message(self.Navigate(1 if key == "down" else -1))
|
|
77
|
+
return
|
|
78
|
+
await super()._on_key(event)
|
|
79
|
+
self.post_message(self.TextChanged(self.text))
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def _completing(self) -> bool:
|
|
83
|
+
"""Whether the current text is a bare slash command."""
|
|
84
|
+
text = self.text
|
|
85
|
+
return text.startswith("/") and " " not in text
|
|
86
|
+
|
|
87
|
+
def submit(self) -> None:
|
|
88
|
+
"""Emit the composer's contents if non-empty."""
|
|
89
|
+
text = self.text.strip()
|
|
90
|
+
if text:
|
|
91
|
+
self.post_message(self.Submitted(text))
|
|
92
|
+
|
|
93
|
+
def clear_prompt(self) -> None:
|
|
94
|
+
"""Empty the composer after a submission."""
|
|
95
|
+
self.text = ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["PromptInput"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Modal for a question the agent asks the user."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Input, Label, ListItem, ListView, SelectionList, Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Choice(ListItem):
|
|
12
|
+
def __init__(self, text: str) -> None:
|
|
13
|
+
super().__init__(Label(text, markup=False))
|
|
14
|
+
self.value = text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class QuestionScreen(ModalScreen[str | list[str] | None]):
|
|
18
|
+
BINDINGS = [("escape", "skip", "Don't answer")]
|
|
19
|
+
|
|
20
|
+
def __init__(self, question: str, choices: list[str] | None, multiple: bool = False) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.question = question
|
|
23
|
+
self.choices = choices or []
|
|
24
|
+
self.multiple = multiple and bool(self.choices)
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
with Vertical(id="approval-dialog"):
|
|
28
|
+
yield Label("The agent asks:", id="approval-title")
|
|
29
|
+
yield Static(self.question, id="approval-body", markup=False)
|
|
30
|
+
if self.multiple:
|
|
31
|
+
yield SelectionList(*[(c, c) for c in self.choices], id="approval-list")
|
|
32
|
+
yield Label("Space picks; Enter in the box below sends the picks or your text.")
|
|
33
|
+
elif self.choices:
|
|
34
|
+
yield ListView(*[_Choice(c) for c in self.choices], id="approval-list")
|
|
35
|
+
yield Input(placeholder="Type an answer, Esc to skip", id="question-answer")
|
|
36
|
+
|
|
37
|
+
def on_mount(self) -> None:
|
|
38
|
+
target = "#approval-list" if self.choices else "#question-answer"
|
|
39
|
+
self.query_one(target).focus()
|
|
40
|
+
|
|
41
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
42
|
+
self.dismiss(event.item.value)
|
|
43
|
+
|
|
44
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
45
|
+
typed = event.value.strip()
|
|
46
|
+
if self.multiple and not typed:
|
|
47
|
+
self.dismiss(list(self.query_one(SelectionList).selected) or None)
|
|
48
|
+
else:
|
|
49
|
+
self.dismiss(typed or None)
|
|
50
|
+
|
|
51
|
+
def action_skip(self) -> None:
|
|
52
|
+
self.dismiss(None)
|