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
rlmagent/tui/app.py ADDED
@@ -0,0 +1,711 @@
1
+ """Textual application for rlm-agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import argparse
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+
11
+ from textual.app import App, ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Horizontal, Vertical
14
+ from textual.content import Content
15
+ from textual.css.query import NoMatches
16
+ from textual.widgets import Footer, Header
17
+
18
+ from rlmagent.tui.adapter import (
19
+ AgentNote,
20
+ RetryNotice,
21
+ SessionBridge,
22
+ StepChanged,
23
+ TextDelta,
24
+ ToolFinished,
25
+ ToolStarted,
26
+ TurnFinished,
27
+ )
28
+ from rlmagent.tui.completions import CompletionBar, match_commands
29
+ from rlmagent.tui.composer import Composer, ModelBadge
30
+ from rlmagent.tui.messages import ACCENT, QueuedStrip
31
+ from rlmagent.tui.model_screen import ModelChoice, ModelScreen
32
+ from rlmagent.tui.prompt import PromptInput
33
+ from rlmagent.tui.sidebar import Sidebar
34
+ from rlmagent.tui.status import StatusBar
35
+ from rlmagent.tui.transcript import TranscriptView
36
+
37
+ _CSS_PATH = Path(__file__).with_name("rlm_agent.tcss")
38
+
39
+
40
+ def build_id() -> str:
41
+ """Short fingerprint of the UI source actually loaded."""
42
+ import hashlib
43
+
44
+ digest = hashlib.sha256()
45
+ for path in sorted(Path(__file__).parent.glob("*.py")):
46
+ digest.update(path.read_bytes())
47
+ if _CSS_PATH.exists():
48
+ digest.update(_CSS_PATH.read_bytes())
49
+ return digest.hexdigest()[:7]
50
+
51
+
52
+
53
+ def _is_registered_command(text: str) -> bool:
54
+ """Whether *text* names a slash command rather than being a prompt."""
55
+ from rlmagent.conversation import COMMAND_REGISTRY
56
+ from rlmagent.directives import parse_command
57
+
58
+ parsed = parse_command(text)
59
+ return parsed is not None and COMMAND_REGISTRY.get(parsed[0]) is not None
60
+
61
+ class RlmAgentApp(App[None]):
62
+ """rlm-agent's terminal UI."""
63
+
64
+ CSS_PATH = _CSS_PATH
65
+ TITLE = "rlm-agent"
66
+
67
+ BINDINGS = [
68
+ Binding("escape", "escape", "Stop / Quit", id="stop"),
69
+ Binding("ctrl+c", "cancel", "Stop / Quit", id="cancel"),
70
+ Binding("ctrl+n", "new_session", "New session", id="new_session"),
71
+ Binding("ctrl+q", "quit", "Quit", id="quit"),
72
+ Binding("shift+tab", "toggle_auto", "Auto-accept", priority=True, id="toggle_auto"),
73
+ Binding("f2", "agent_tree", "Agents", id="agents", priority=True),
74
+ Binding("f3", "timeline", "Timeline", id="timeline", priority=True),
75
+ Binding("f4", "workers", "Workers", id="workers", priority=True),
76
+ Binding("f1", "show_help", "Help", id="help", priority=True),
77
+ Binding("ctrl+shift+y", "copy_answer", "Copy answer", id="copy_answer", priority=True),
78
+ ]
79
+
80
+ def __init__(self, bridge: SessionBridge | None = None) -> None:
81
+ super().__init__()
82
+ self._bridge = bridge
83
+ self._busy = False
84
+ #: Messages typed while the agent was working, sent in order afterwards.
85
+ self._queued: list[str] = []
86
+ #: Set by Ctrl+C so updates still in flight are not rendered.
87
+ self._cancelled = False
88
+ #: When Ctrl+C was last pressed; a second press soon after quits outright.
89
+ self._last_interrupt = 0.0
90
+
91
+ # -- composition --------------------------------------------------------
92
+
93
+ def format_title(self, title: str, sub_title: str) -> Content:
94
+ """Render the header title with a violet accent and no build suffix."""
95
+ return Content.from_markup(f"[{ACCENT}]rlm[/]-agent")
96
+
97
+ def compose(self) -> ComposeResult:
98
+ yield Header()
99
+ with Horizontal(id="body"):
100
+ yield Sidebar()
101
+ with Vertical(id="main"):
102
+ yield TranscriptView()
103
+ yield CompletionBar()
104
+ yield QueuedStrip()
105
+ yield Composer()
106
+ yield StatusBar()
107
+ yield Footer()
108
+
109
+ async def on_mount(self) -> None:
110
+ import json
111
+
112
+ from rlmagent.plugins import rlm_agent_home
113
+
114
+ try:
115
+ keymap = json.loads((rlm_agent_home() / "keybindings.json").read_text(encoding="utf-8"))
116
+ known = {binding.id for binding in self.BINDINGS}
117
+ if not isinstance(keymap, dict) or any(
118
+ key not in known or not isinstance(value, str) for key, value in keymap.items()
119
+ ):
120
+ raise ValueError("Keybindings must map known action IDs to key strings")
121
+ assigned = {}
122
+ for binding in self.BINDINGS:
123
+ for key in keymap.get(binding.id, binding.key).split(","):
124
+ key = key.strip().lower()
125
+ if key and key in assigned and assigned[key] != binding.id:
126
+ raise ValueError(f"Shortcut {key} is assigned to multiple actions")
127
+ assigned[key] = binding.id
128
+ self.set_keymap(keymap)
129
+ except FileNotFoundError:
130
+ pass
131
+ except (ValueError, OSError) as exc:
132
+ self.notify(f"Could not load keybindings: {exc}", severity="error")
133
+ await self._refresh_sidebar()
134
+ self._refresh_status()
135
+ if self._bridge is not None:
136
+ self.query_one(TranscriptView).replay(self._bridge.session.transcript)
137
+ tree = getattr(self._bridge.session, "agent_tree", None)
138
+ if tree is not None and tree.approver.policy in ("ask", "auto"):
139
+ tree.approver.ask = self._ask_approval
140
+ if tree is not None:
141
+ tree.approver.question = self._ask_question
142
+
143
+ def action_toggle_auto(self) -> None:
144
+ """Shift+Tab: stop or resume asking before file changes and commands, this session only."""
145
+ tree = getattr(self._bridge.session, "agent_tree", None) if self._bridge else None
146
+ if tree is None:
147
+ return
148
+ from rlmagent.tui.approval_screen import ApprovalScreen
149
+
150
+ if isinstance(self.screen, ApprovalScreen):
151
+ # On a waiting approval it lets that call through and turns auto-accept on.
152
+ self.screen.action_auto()
153
+ return
154
+ on = tree.approver.toggle_auto()
155
+ self.notify("auto-accept on: file changes and commands go ahead unasked" if on
156
+ else "auto-accept off")
157
+ self._refresh_status()
158
+
159
+ async def _ask_question(self, question: str, choices: list[str] | None,
160
+ multiple: bool = False):
161
+ """Show a question from the agent and wait for the answer."""
162
+ from rlmagent.tui.question_screen import QuestionScreen
163
+
164
+ answer = asyncio.get_running_loop().create_future()
165
+ self.push_screen(QuestionScreen(question, choices, multiple),
166
+ lambda text: answer.done() or answer.set_result(text))
167
+ return await answer
168
+
169
+ async def _ask_approval(self, request) -> str:
170
+ """Show what the sandbox wants to change and wait for the answer."""
171
+ from rlmagent.agents.approval import AUTO, DENY
172
+ from rlmagent.tui.approval_screen import ApprovalScreen
173
+
174
+ answer = asyncio.get_running_loop().create_future()
175
+ self.push_screen(ApprovalScreen(request),
176
+ lambda choice: answer.done() or answer.set_result(choice or DENY))
177
+ choice = await answer
178
+ if choice == AUTO:
179
+ self._refresh_status()
180
+ return choice
181
+
182
+ # -- user actions -------------------------------------------------------
183
+
184
+ def on_prompt_input_text_changed(self, event: PromptInput.TextChanged) -> None:
185
+ """Offer slash-command completions as the user types."""
186
+ event.stop()
187
+ commands = (
188
+ self._bridge.session.COMMANDS if self._bridge is not None else ()
189
+ )
190
+ bar = self.query_one(CompletionBar)
191
+ bar.offer(match_commands(event.text, commands))
192
+
193
+ def _is_exact_command(self, text: str) -> bool:
194
+ """Whether *text* is already a complete slash command name."""
195
+ if self._bridge is None or not text.startswith("/"):
196
+ return False
197
+ name = text[1:].strip().lower()
198
+ return any(name == cmd for cmd, _ in self._bridge.session.COMMANDS)
199
+
200
+ def on_prompt_input_navigate(self, event: PromptInput.Navigate) -> None:
201
+ """Arrow keys move the completion highlight when the bar is open."""
202
+ event.stop()
203
+ self.query_one(CompletionBar).move(event.delta)
204
+
205
+ def on_prompt_input_complete(self, event: PromptInput.Complete) -> None:
206
+ """Tab accepts the highlighted completion."""
207
+ event.stop()
208
+ bar = self.query_one(CompletionBar)
209
+ chosen = bar.selected
210
+ if chosen is None:
211
+ return
212
+ prompt = self.query_one(PromptInput)
213
+ prompt.text = chosen.insertion
214
+ prompt.cursor_location = (0, len(prompt.text))
215
+ bar.dismiss()
216
+
217
+ async def on_prompt_input_submitted(self, event: PromptInput.Submitted) -> None:
218
+ """Handle Enter in the composer."""
219
+ event.stop()
220
+ bar = self.query_one(CompletionBar)
221
+ if bar.active and not self._is_exact_command(event.text):
222
+ # Still a partial word — Enter fills it in. A fully typed command
223
+ # submits straight away rather than needing a second Enter.
224
+ self.on_prompt_input_complete(PromptInput.Complete())
225
+ return
226
+ bar.dismiss()
227
+ text = event.text
228
+ if self._busy:
229
+ if text.strip() and not _is_registered_command(text):
230
+ self.query_one(PromptInput).clear_prompt()
231
+ self._queued.append(text)
232
+ self._show_queue()
233
+ return
234
+ self.query_one(PromptInput).clear_prompt()
235
+
236
+ if self._bridge is None:
237
+ self.query_one(TranscriptView).add_notice(
238
+ "No session attached — running in layout-only mode.", error=True,
239
+ )
240
+ return
241
+
242
+ if _is_registered_command(text):
243
+ await self._run_command(text)
244
+ return
245
+
246
+ self.query_one(TranscriptView).add_user(text)
247
+ self.run_worker(self._stream(text), exclusive=True)
248
+
249
+ def on_step_card_inspect(self, event) -> None:
250
+ from rlmagent.tui.inspect import StepScreen
251
+
252
+ self.push_screen(StepScreen(event.step))
253
+
254
+ def action_agent_tree(self) -> None:
255
+ from rlmagent.tui.inspect import AgentTreeScreen
256
+ from rlmagent.tui.steps import StepBook
257
+
258
+ book = self._bridge.steps if self._bridge is not None else StepBook()
259
+ self.push_screen(AgentTreeScreen(book))
260
+
261
+ def action_timeline(self) -> None:
262
+ from rlmagent.tui.inspect import TimelineScreen
263
+ from rlmagent.tui.steps import StepBook
264
+
265
+ self.push_screen(TimelineScreen(self._bridge.steps if self._bridge else StepBook()))
266
+
267
+ def action_workers(self) -> None:
268
+ from rlmagent.tui.inspect import WorkersScreen
269
+
270
+ self.push_screen(WorkersScreen())
271
+
272
+ def action_show_help(self) -> None:
273
+ from rlmagent.tui.inspect import HelpScreen
274
+
275
+ self.push_screen(HelpScreen())
276
+
277
+ def action_copy_answer(self) -> None:
278
+ if self._bridge is None:
279
+ return
280
+ answer = next((entry for entry in reversed(list(self._bridge.session.transcript))
281
+ if entry.role == "assistant"), None)
282
+ if answer is None or not answer.text:
283
+ self.notify("There is no answer to copy.")
284
+ return
285
+ from rlmagent.clipboard import copy
286
+
287
+ try:
288
+ def write(value):
289
+ if self._driver is None:
290
+ raise ValueError("No terminal driver is attached")
291
+ self._driver.write(value)
292
+ self.notify(copy(answer.text, write))
293
+ except ValueError as error:
294
+ self.notify(str(error), severity="error")
295
+
296
+ def action_escape(self) -> None:
297
+ """Esc stops a running turn; when idle with an empty prompt it leaves the app."""
298
+ if not self.action_stop() and not self.query_one(PromptInput).text.strip():
299
+ self.exit()
300
+
301
+ def action_stop(self) -> bool:
302
+ """Stop the running generation. Returns whether anything was stopped."""
303
+ if not self._busy or self._bridge is None:
304
+ return False
305
+ self._cancelled = True
306
+ self._queued.clear()
307
+ self._show_queue()
308
+ self._bridge.cancel()
309
+ transcript = self.query_one(TranscriptView)
310
+ transcript.finish_assistant(transcript.live_text)
311
+ transcript.add_notice("Stopped.")
312
+ self.query_one(PromptInput).focus()
313
+ self._refresh_status()
314
+ return True
315
+
316
+ def action_cancel(self) -> None:
317
+ """Ctrl+C: stop the generation if running, otherwise quit.
318
+
319
+ Pressed twice within a second and a half, it quits even mid-run.
320
+ """
321
+ import time
322
+
323
+ now = time.monotonic()
324
+ twice = now - self._last_interrupt < 1.5
325
+ self._last_interrupt = now
326
+ if twice:
327
+ self.exit()
328
+ return
329
+ if self.action_stop():
330
+ self.notify("Stopped. Press Ctrl+C again to quit.", timeout=1.5)
331
+ else:
332
+ self.exit()
333
+
334
+ def _show_queue(self) -> None:
335
+ with suppress(NoMatches):
336
+ self.screen_stack[0].query_one(QueuedStrip).show(self._queued)
337
+
338
+ def action_new_session(self) -> None:
339
+ """Ctrl+N: start a fresh session."""
340
+ self.post_message(Sidebar.NewRequested())
341
+
342
+ async def on_sidebar_switch_requested(
343
+ self, event: Sidebar.SwitchRequested,
344
+ ) -> None:
345
+ """Switch to an existing session."""
346
+ event.stop()
347
+ await self._swap(lambda b: b.switch_to(event.session_id))
348
+
349
+ async def on_sidebar_new_requested(self, event: Sidebar.NewRequested) -> None:
350
+ """Create a fresh session."""
351
+ event.stop()
352
+ await self._swap(lambda b: b.start_new())
353
+
354
+ async def _swap(self, action) -> None:
355
+ """Run a session change, then repaint every dependent surface."""
356
+ if self._bridge is None:
357
+ return
358
+ transcript = self.query_one(TranscriptView)
359
+ if self._busy:
360
+ self._bridge.cancel()
361
+ self._busy = False
362
+
363
+ try:
364
+ await action(self._bridge)
365
+ except Exception as exc: # noqa: BLE001 - keep the UI alive on failure
366
+ transcript.add_notice(f"Could not switch session: {exc}", error=True)
367
+ return
368
+
369
+ # Stale state must not survive the swap.
370
+ transcript.replay(self._bridge.session.transcript)
371
+ self.query_one(PromptInput).clear_prompt()
372
+ await self._refresh_sidebar()
373
+ self._refresh_status()
374
+
375
+ # -- runtime plumbing ---------------------------------------------------
376
+
377
+ async def _run_command(self, text: str) -> None:
378
+ """Delegate a slash command to the runtime."""
379
+ transcript = self.query_one(TranscriptView)
380
+ if text in {"/quit", "/exit", "/q"}:
381
+ self.exit()
382
+ return
383
+ if text.strip() == "/version":
384
+ from rlmagent.cli.main import get_version
385
+
386
+ transcript.add_notice(f"rlm-agent v{get_version()}")
387
+ return
388
+ if text.strip() in {"/continue", "/resume"}:
389
+ if self._busy:
390
+ return
391
+ self.run_worker(self._stream("", resume=True), exclusive=True)
392
+ return
393
+ if text.strip() in {"/model", "/provider"}:
394
+ # push_screen_wait requires a worker context.
395
+ self.run_worker(self._open_model_picker())
396
+ return
397
+ login = text.strip().split(maxsplit=1)
398
+ if login and login[0] == "/login":
399
+ # One sign-in page for every provider: keys, endpoints and
400
+ # subscription accounts all go through the provider dialog.
401
+ self.run_worker(self._open_model_picker(
402
+ start=login[1].strip().lower() if len(login) > 1 else "", title="Sign in: choose a provider"))
403
+ return
404
+ assert self._bridge is not None
405
+ from rlmagent.conversation import COMMAND_REGISTRY
406
+ from rlmagent.directives import parse_command
407
+
408
+ name, arg = parse_command(text) or ("", "")
409
+ command = COMMAND_REGISTRY.get(name)
410
+ primary = command.name if command is not None else name
411
+ transcript.add_command(text)
412
+ if primary == "shell" and arg and hasattr(self._bridge.session, "run_shell"):
413
+ try:
414
+ entry = await self._bridge.session.run_shell(arg)
415
+ except Exception as exc: # noqa: BLE001
416
+ transcript.add_notice(f"Shell failed: {exc}", error=True)
417
+ else:
418
+ transcript.add_shell(arg, entry.output, entry.exit_code)
419
+ return
420
+ if primary == "effort" or (primary == "think" and not arg):
421
+ self.run_worker(self._open_effort_picker())
422
+ return
423
+ if primary == "btw" and arg and arg.lower() not in ("clear", "new"):
424
+ response = await self._bridge.handle_command(text)
425
+ transcript.add_side_answer(arg, response or "")
426
+ return
427
+ try:
428
+ response = await self._bridge.handle_command(text)
429
+ except Exception as exc: # noqa: BLE001 - a bad command must not kill the UI
430
+ transcript.add_notice(f"Command failed: {exc}", error=True)
431
+ return
432
+ if response is not None:
433
+ transcript.add_notice(response)
434
+ if not self._busy and text.split() in (["/autonomous"], ["/autonomous", "on"]):
435
+ pilot = getattr(self._bridge.session, "autopilot", None)
436
+ if pilot is not None and (following := pilot.next_prompt()):
437
+ transcript.add_notice(f"autonomous turn {pilot.turns}")
438
+ self.run_worker(self._stream(following), exclusive=True)
439
+ await self._refresh_sidebar()
440
+ self._refresh_status()
441
+
442
+ async def _open_effort_picker(self) -> None:
443
+ """Pick a thinking level from a list instead of typing it."""
444
+ from rlmagent.reasoning import DEFAULT_THINKING_LEVEL, THINKING_LEVELS
445
+ from rlmagent.tui.choice_screen import ChoiceScreen
446
+
447
+ if self._bridge is None:
448
+ return
449
+ current = self._bridge.session.thinking_level or DEFAULT_THINKING_LEVEL
450
+ options = ["off", *THINKING_LEVELS]
451
+ level = await self.push_screen_wait(ChoiceScreen("Reasoning effort", options, current))
452
+ if level is None:
453
+ return
454
+ response = await self._bridge.handle_command(f"/think {level}")
455
+ # A setting change is a passing confirmation, not part of the conversation.
456
+ self.notify(response or f"effort: {level}", timeout=3)
457
+ self._refresh_status()
458
+
459
+ async def _open_model_picker(self, start: str = "", title: str = "Select provider") -> None:
460
+ """Show the provider/model dialog and apply whatever comes back."""
461
+ if self._bridge is None:
462
+ return
463
+ session = self._bridge.session
464
+ choice = await self.push_screen_wait(
465
+ ModelScreen(session.provider_name, session.model, start_provider=start, title=title)
466
+ )
467
+ if choice is None:
468
+ return
469
+ await self._apply_model_choice(choice)
470
+
471
+ async def _apply_model_choice(self, choice: ModelChoice) -> None:
472
+ """Persist the selection and switch the runtime onto it."""
473
+ assert self._bridge is not None
474
+ transcript = self.query_one(TranscriptView)
475
+ try:
476
+ summary = await self._bridge.apply_model_choice(
477
+ choice.provider, choice.model, choice.api_key, **({"base_url": choice.base_url} if choice.base_url else {}),
478
+ )
479
+ except Exception as exc: # noqa: BLE001 - a bad key must not kill the UI
480
+ transcript.add_notice(f"Could not switch: {exc}", error=True)
481
+ return
482
+ transcript.add_notice(summary)
483
+ self._refresh_status()
484
+
485
+ async def _stream(self, text: str, *, resume: bool = False) -> None:
486
+ """Consume the runtime's event stream and render it."""
487
+ assert self._bridge is not None
488
+ transcript = self.screen_stack[0].query_one(TranscriptView)
489
+ transcript.new_run()
490
+ self._busy = True
491
+ self._cancelled = False
492
+ self._refresh_status()
493
+ try:
494
+ source = (
495
+ self._bridge.resume() if resume else self._bridge.submit(text)
496
+ )
497
+ async for update in source:
498
+ if self._cancelled:
499
+ # Ctrl+C already closed the block and posted a notice;
500
+ # trailing updates must not reopen it.
501
+ break
502
+ if isinstance(update, TextDelta):
503
+ transcript.stream_delta(update.text)
504
+ elif isinstance(update, ToolStarted):
505
+ transcript.start_tool(update.call_id, update.summary, update.code)
506
+ elif isinstance(update, ToolFinished):
507
+ transcript.finish_tool(
508
+ update.call_id, update.output, is_error=update.is_error,
509
+ )
510
+ elif isinstance(update, StepChanged):
511
+ transcript.show_step(update.step)
512
+ self._refresh_status()
513
+ elif isinstance(update, RetryNotice):
514
+ transcript.add_notice(update.message)
515
+ elif isinstance(update, AgentNote):
516
+ transcript.add_notice(update.text)
517
+ elif isinstance(update, TurnFinished):
518
+ transcript.finish_assistant(update.text)
519
+ if update.error:
520
+ transcript.add_notice(update.error, error=True)
521
+ except Exception as exc: # noqa: BLE001 - surface provider errors in-UI
522
+ transcript.finish_assistant("")
523
+ transcript.add_notice(str(exc), error=True)
524
+ finally:
525
+ self._busy = False
526
+ # The app may be tearing down (quit during generation), in which
527
+ # case these widgets are already gone.
528
+ with suppress(NoMatches):
529
+ await self._refresh_sidebar()
530
+ self._refresh_status()
531
+ pilot = getattr(self._bridge.session, "autopilot", None)
532
+ if self._queued and not self._cancelled:
533
+ following = self._queued.pop(0)
534
+ self._show_queue()
535
+ with suppress(NoMatches):
536
+ transcript.add_user(following)
537
+ self.run_worker(self._stream(following), exclusive=True)
538
+ elif pilot is not None and self._cancelled:
539
+ pilot.stop_autonomous("stopped by the user")
540
+ elif pilot is not None and (following := pilot.next_prompt()):
541
+ with suppress(NoMatches):
542
+ transcript.add_notice(f"autonomous turn {pilot.turns}")
543
+ self.run_worker(self._stream(following), exclusive=True)
544
+ elif pilot is not None and pilot.stopped_because:
545
+ with suppress(NoMatches):
546
+ transcript.add_notice(f"autonomous mode stopped: {pilot.stopped_because}")
547
+ pilot.stopped_because = ""
548
+
549
+ def _refresh_status(self) -> None:
550
+ """Update the footer readout and the composer's model/effort badge."""
551
+ if self._bridge is None:
552
+ return
553
+ from rlmagent.reasoning import DEFAULT_THINKING_LEVEL
554
+
555
+ session = self._bridge.session
556
+ main = self.screen_stack[0]
557
+ pilot = getattr(session, "autopilot", None)
558
+ goal = getattr(pilot, "goal", None)
559
+ goal_text = goal.objective if goal is not None and goal.status == "active" else ""
560
+ main.query_one(StatusBar).show(self._bridge.status(), busy=self._busy, goal=goal_text)
561
+ tasks = getattr(session, "tasks", None)
562
+ if tasks is not None:
563
+ main.query_one(Sidebar).show_tasks(tasks.items)
564
+ effort = session.thinking_level or DEFAULT_THINKING_LEVEL
565
+ tree = getattr(session, "agent_tree", None)
566
+ auto = tree is not None and tree.approver.policy == "auto"
567
+ main.query_one(ModelBadge).show(session.model, effort, auto)
568
+
569
+ async def _refresh_sidebar(self) -> None:
570
+ """Repaint the session list from the catalog."""
571
+ if self._bridge is None:
572
+ return
573
+ await self.screen_stack[0].query_one(Sidebar).show(self._bridge.sessions(busy=self._busy))
574
+
575
+
576
+ def needs_setup(ns: argparse.Namespace) -> bool:
577
+ """No usable provider yet, so a first run should open the setup page."""
578
+ from rlmagent.cli.main import _resolve_provider_name
579
+ from rlmagent.config.config import resolve_provider
580
+ from rlmagent.config.loader import apply_config
581
+ from rlmagent.model.settings import ConfigError
582
+
583
+ if getattr(ns, "provider", None):
584
+ return False
585
+ try:
586
+ name = _resolve_provider_name(None)
587
+ apply_config(name)
588
+ resolve_provider(name)
589
+ except (ConfigError, KeyError, ValueError):
590
+ return True
591
+ return False
592
+
593
+
594
+ WELCOME = (
595
+ "Welcome to rlm-agent.\n\n"
596
+ "It works in this folder: it reads and edits files, runs code in a sandbox,\n"
597
+ "and asks before changing anything outside it.\n\n"
598
+ "First, connect a model provider. You can add more now or later with /login."
599
+ )
600
+
601
+
602
+ class SetupApp(App):
603
+ """First run: a welcome, a provider, then the option to connect more."""
604
+
605
+ CSS_PATH = _CSS_PATH
606
+ choice = None
607
+
608
+ def __init__(self) -> None:
609
+ super().__init__()
610
+ #: Further providers connected during setup; their keys are saved,
611
+ #: the first choice stays the default.
612
+ self.extra: list = []
613
+
614
+ def on_mount(self) -> None:
615
+ from rlmagent.tui.choice_screen import ChoiceScreen
616
+
617
+ def welcomed(answer) -> None:
618
+ if answer is None:
619
+ self.exit()
620
+ else:
621
+ self._pick(first=True)
622
+
623
+ self.push_screen(ChoiceScreen(WELCOME, ["Connect a provider"]), welcomed)
624
+
625
+ def _pick(self, first: bool) -> None:
626
+ from rlmagent.tui.choice_screen import ChoiceScreen
627
+ from rlmagent.tui.model_screen import ModelScreen
628
+
629
+ def picked(choice) -> None:
630
+ if choice is not None:
631
+ if first:
632
+ self.choice = choice
633
+ else:
634
+ self.extra.append(choice)
635
+ if self.choice is None:
636
+ self.exit()
637
+ return
638
+ self.push_screen(ChoiceScreen("Connected. Anything else?",
639
+ [START_NOW, CONNECT_MORE]), next_step)
640
+
641
+ def next_step(answer) -> None:
642
+ if answer == CONNECT_MORE:
643
+ self._pick(first=False)
644
+ else:
645
+ self.exit()
646
+
647
+ title = "Select provider" if first else "Connect another provider"
648
+ self.push_screen(ModelScreen(title=title), picked)
649
+
650
+
651
+ START_NOW = "Start"
652
+ CONNECT_MORE = "Connect another provider"
653
+
654
+
655
+ def save_setup(choice, extra=()) -> None:
656
+ """Save the default provider and model, and the keys of any extra providers."""
657
+ from rlmagent.config.store import load_config, remember_model, save_config
658
+
659
+ config = load_config()
660
+ for item in (*extra, choice):
661
+ if item.api_key:
662
+ config.api_keys[item.provider] = item.api_key
663
+ if item.base_url:
664
+ config.base_urls[item.provider] = item.base_url
665
+ config.provider, config.model = choice.provider, choice.model
666
+ save_config(config)
667
+ for item in (*extra, choice):
668
+ remember_model(item.provider, item.model)
669
+
670
+
671
+ async def setup_if_needed(ns: argparse.Namespace) -> bool:
672
+ if needs_setup(ns):
673
+ setup = SetupApp()
674
+ await setup.run_async()
675
+ if setup.choice is None:
676
+ return False
677
+ save_setup(setup.choice, setup.extra)
678
+ return True
679
+
680
+
681
+ async def run_app(ns: argparse.Namespace) -> int:
682
+ """Build the shared runtime session and run the TUI over it."""
683
+ from rlmagent.runtime import build_session, settle_project_trust
684
+ from rlmagent.tui.trust_dialog import ask_in_dialog
685
+
686
+ # Settled before the session exists: its answer decides what gets loaded.
687
+ await settle_project_trust(ns, ask=ask_in_dialog)
688
+ if not await setup_if_needed(ns):
689
+ return 1
690
+ bridge = SessionBridge(await build_session(ns))
691
+ app = RlmAgentApp(bridge)
692
+ try:
693
+ await app.run_async()
694
+ finally:
695
+ # The bridge's session changes when the user switches; shut down
696
+ # whichever one is current.
697
+ await bridge.shutdown()
698
+ return 0
699
+
700
+
701
+ def run_tui(ns: argparse.Namespace | None = None) -> int:
702
+ """Launch the TUI. Returns a process exit code."""
703
+ import asyncio
704
+
705
+ if ns is None:
706
+ RlmAgentApp().run()
707
+ return 0
708
+ return asyncio.run(run_app(ns))
709
+
710
+
711
+ __all__ = ["RlmAgentApp", "run_app", "run_tui"]