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