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.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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)