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,57 @@
1
+ """Modal that asks before the sandbox writes a file or runs a command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.syntax import Syntax
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Label, ListItem, ListView, Static
10
+
11
+ from rlmagent.agents.approval import ALWAYS, AUTO, DENY, ONCE, Request
12
+
13
+
14
+ class _Choice(ListItem):
15
+ def __init__(self, text: str, value: str) -> None:
16
+ super().__init__(Label(text))
17
+ self.value = value
18
+
19
+
20
+ class ApprovalScreen(ModalScreen[str]):
21
+ BINDINGS = [("escape", "deny", "Deny"), ("n", "deny", "Deny"), ("y", "once", "Allow once"),
22
+ ("shift+tab", "auto", "Auto-accept")]
23
+
24
+ def __init__(self, request: Request) -> None:
25
+ super().__init__()
26
+ self.request = request
27
+
28
+ def compose(self) -> ComposeResult:
29
+ always = "this command" if self.request.kind == "run" else "this file"
30
+ with Vertical(id="approval-dialog"):
31
+ yield Label("The agent wants to:", id="approval-title")
32
+ text = self.request.describe()
33
+ body = Syntax(text, "diff", theme="monokai", word_wrap=True,
34
+ background_color="default") if self.request.preview else text
35
+ yield Static(body, id="approval-body", markup=False)
36
+ yield ListView(
37
+ _Choice("Allow once", ONCE),
38
+ _Choice(f"Always allow {always} this session", ALWAYS),
39
+ _Choice("Allow, and auto-accept the rest of this session", AUTO),
40
+ _Choice("Deny", DENY),
41
+ id="approval-list",
42
+ )
43
+
44
+ def on_mount(self) -> None:
45
+ self.query_one("#approval-list", ListView).focus()
46
+
47
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
48
+ self.dismiss(event.item.value)
49
+
50
+ def action_once(self) -> None:
51
+ self.dismiss(ONCE)
52
+
53
+ def action_auto(self) -> None:
54
+ self.dismiss(AUTO)
55
+
56
+ def action_deny(self) -> None:
57
+ self.dismiss(DENY)
@@ -0,0 +1,47 @@
1
+ """A small modal list: pick one value with the arrows and Enter, Esc to leave."""
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 Label, ListItem, ListView
9
+
10
+
11
+ class _Option(ListItem):
12
+ def __init__(self, value: str, current: bool) -> None:
13
+ super().__init__(Label(("● " if current else " ") + value, markup=False))
14
+ self.value = value
15
+
16
+
17
+ class ChoiceScreen(ModalScreen[str | None]):
18
+ """Returns the chosen value, or ``None`` when dismissed."""
19
+
20
+ BINDINGS = [("escape", "leave", "Cancel")]
21
+
22
+ def __init__(self, title: str, options: list[str], current: str | None = None) -> None:
23
+ super().__init__()
24
+ self.title_text = title
25
+ self.options = list(options)
26
+ self.current = current
27
+
28
+ def compose(self) -> ComposeResult:
29
+ with Vertical(id="approval-dialog"):
30
+ yield Label(self.title_text, id="approval-title")
31
+ yield ListView(*[_Option(o, o == self.current) for o in self.options],
32
+ id="choice-list")
33
+
34
+ def on_mount(self) -> None:
35
+ view = self.query_one("#choice-list", ListView)
36
+ if self.current in self.options:
37
+ view.index = self.options.index(self.current)
38
+ view.focus()
39
+
40
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
41
+ self.dismiss(event.item.value)
42
+
43
+ def action_leave(self) -> None:
44
+ self.dismiss(None)
45
+
46
+
47
+ __all__ = ["ChoiceScreen"]
@@ -0,0 +1,116 @@
1
+ """Slash-command autocomplete bar shown above the composer."""
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.widgets import Static
10
+
11
+ from rlmagent.tui.messages import ACCENT
12
+
13
+ _MAX_VISIBLE = 6
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Completion:
18
+ """One selectable slash command."""
19
+
20
+ name: str
21
+ description: str
22
+
23
+ @property
24
+ def insertion(self) -> str:
25
+ """Text that replaces the composer contents when chosen."""
26
+ return f"/{self.name} "
27
+
28
+
29
+ def match_commands(
30
+ text: str, commands: tuple[tuple[str, str], ...],
31
+ ) -> list[Completion]:
32
+ """Return commands matching *text*, or ``[]`` when not completing."""
33
+ if not text.startswith("/") or " " in text:
34
+ return []
35
+ fragment = text[1:].lower()
36
+ matches = [
37
+ Completion(name, description)
38
+ for name, description in commands
39
+ if name.startswith(fragment)
40
+ ]
41
+ return matches[:_MAX_VISIBLE]
42
+
43
+
44
+ class CompletionBar(Vertical):
45
+ """Dropdown of slash-command candidates."""
46
+
47
+ def __init__(self) -> None:
48
+ super().__init__(id="completion-bar")
49
+ self._items: list[Completion] = []
50
+ self._index = 0
51
+ self.display = False
52
+
53
+ # -- state --------------------------------------------------------------
54
+
55
+ @property
56
+ def items(self) -> list[Completion]:
57
+ """Currently offered completions."""
58
+ return self._items
59
+
60
+ @property
61
+ def active(self) -> bool:
62
+ """Whether the bar is showing anything."""
63
+ return bool(self._items)
64
+
65
+ @property
66
+ def selected(self) -> Completion | None:
67
+ """The highlighted completion, if any."""
68
+ if not self._items:
69
+ return None
70
+ return self._items[self._index % len(self._items)]
71
+
72
+ # -- updates ------------------------------------------------------------
73
+
74
+ def offer(self, items: list[Completion]) -> None:
75
+ """Replace the candidate list and repaint."""
76
+ self._items = items
77
+ self._index = 0
78
+ self.display = bool(items)
79
+ self._repaint()
80
+
81
+ def dismiss(self) -> None:
82
+ """Hide the bar."""
83
+ self.offer([])
84
+
85
+ def move(self, delta: int) -> None:
86
+ """Move the highlight, wrapping at both ends."""
87
+ if not self._items:
88
+ return
89
+ self._index = (self._index + delta) % len(self._items)
90
+ self._repaint()
91
+
92
+ # -- rendering ----------------------------------------------------------
93
+
94
+ def compose(self) -> ComposeResult:
95
+ yield Static("", id="completion-body")
96
+
97
+ def _repaint(self) -> None:
98
+ try:
99
+ body = self.query_one("#completion-body", Static)
100
+ except Exception: # noqa: BLE001 - not mounted yet
101
+ return
102
+ if not self._items:
103
+ body.update("")
104
+ return
105
+ lines = []
106
+ for i, item in enumerate(self._items):
107
+ if i == self._index:
108
+ lines.append(
109
+ f"[{ACCENT}]› /{item.name:<10} {item.description}[/{ACCENT}]"
110
+ )
111
+ else:
112
+ lines.append(f" /{item.name:<10} {item.description}")
113
+ body.update("\n".join(lines))
114
+
115
+
116
+ __all__ = ["Completion", "CompletionBar", "match_commands"]
@@ -0,0 +1,25 @@
1
+ """Public widget surface for rlm-agent's TUI."""
2
+
3
+ from rlmagent.tui.messages import (
4
+ AssistantBlock,
5
+ NoticeBlock,
6
+ ToolLine,
7
+ ToolOutput,
8
+ UserBlock,
9
+ )
10
+ from rlmagent.tui.prompt import PromptInput
11
+ from rlmagent.tui.sidebar import Sidebar
12
+ from rlmagent.tui.status import StatusBar
13
+ from rlmagent.tui.transcript import TranscriptView
14
+
15
+ __all__ = [
16
+ "AssistantBlock",
17
+ "NoticeBlock",
18
+ "PromptInput",
19
+ "Sidebar",
20
+ "StatusBar",
21
+ "ToolLine",
22
+ "ToolOutput",
23
+ "TranscriptView",
24
+ "UserBlock",
25
+ ]
@@ -0,0 +1,40 @@
1
+ """The composer row: prompt marker, input, and a model/effort readout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Horizontal
7
+ from textual.widgets import Static
8
+
9
+ from rlmagent.tui.messages import RLM_AGENT_MARK
10
+ from rlmagent.tui.prompt import PromptInput
11
+
12
+
13
+ class ModelBadge(Static):
14
+ """Right-hand readout showing the active model and effort level."""
15
+
16
+ def __init__(self) -> None:
17
+ super().__init__("", id="model-badge")
18
+ self._line = ""
19
+
20
+ @property
21
+ def line(self) -> str:
22
+ """The text currently displayed."""
23
+ return self._line
24
+
25
+ def show(self, model: str, effort: str, auto_accept: bool = False) -> None:
26
+ """Render *model* and *effort*, and say so when nothing asks before changing."""
27
+ self._line = ("auto-accept · " if auto_accept else "") + f"{model} · {effort}"
28
+ self.update(self._line)
29
+
30
+
31
+ class Composer(Horizontal):
32
+ """Prompt marker + input + model badge on one row."""
33
+
34
+ def compose(self) -> ComposeResult:
35
+ yield Static(RLM_AGENT_MARK, id="prompt-mark")
36
+ yield PromptInput()
37
+ yield ModelBadge()
38
+
39
+
40
+ __all__ = ["Composer", "ModelBadge", "RLM_AGENT_MARK"]
@@ -0,0 +1,86 @@
1
+ """Read-only native view of a detached factory run."""
2
+
3
+ from pathlib import Path
4
+
5
+ from rich.text import Text
6
+ from textual.app import App, ComposeResult
7
+ from textual.widgets import DataTable, Footer, Static
8
+
9
+ from rlmagent.factory_graph import run_status
10
+
11
+
12
+ class FactoryGraphApp(App[None]):
13
+ CSS = """
14
+ #run-state { height: auto; }
15
+ DataTable { height: 1fr; }
16
+ #stage-result { height: 8; overflow-y: auto; }
17
+ """
18
+ BINDINGS = [("q", "quit", "Close")]
19
+
20
+ def __init__(self, directory: Path) -> None:
21
+ super().__init__()
22
+ self.directory = directory
23
+ self.snapshot = None
24
+ self.details: list[str] = []
25
+
26
+ def compose(self) -> ComposeResult:
27
+ yield Static("", id="run-state", markup=False)
28
+ yield DataTable(cursor_type="row")
29
+ yield Static("", id="stage-result", markup=False)
30
+ yield Footer()
31
+
32
+ def on_mount(self) -> None:
33
+ self.query_one(DataTable).add_columns("Stage", "Dependencies", "Guard", "Status")
34
+ self.refresh_run()
35
+ self.set_interval(0.5, self.refresh_run)
36
+
37
+ def refresh_run(self) -> None:
38
+ heading = self.query_one("#run-state", Static)
39
+ try:
40
+ state = run_status(self.directory)
41
+ except (OSError, ValueError, KeyError) as exc:
42
+ self.snapshot = None
43
+ heading.update(f"Could not read the run: {exc}")
44
+ return
45
+ if state == self.snapshot:
46
+ return
47
+ self.snapshot = state
48
+ reason = state.get("error") or state.get("stop_reason")
49
+ heading.update(f"Factory {state['id']} · {state['status']}"
50
+ + (f" · {reason}" if reason else ""))
51
+ graph, records = state.get("graph", {}), state.get("stages", {})
52
+ table = self.query_one(DataTable)
53
+ selected = table.cursor_row
54
+ table.clear()
55
+ self.details.clear()
56
+ for name in dict.fromkeys([*graph, *records]):
57
+ node = graph.get(name, {})
58
+ record = records.get(name, {})
59
+ dependencies = ", ".join(node.get("after", ()))
60
+ guard = node.get("when", "")
61
+ if source := node.get("foreach"):
62
+ guard += f" · foreach {source}"
63
+ table.add_row(Text(name), Text(dependencies), Text(guard),
64
+ Text(record.get("status", "waiting")))
65
+ self.details.append(self._output(record))
66
+ for index, item in enumerate(record.get("items", ()), 1):
67
+ table.add_row(Text(f" ↳ item {index}"), "", "", Text(item["status"]))
68
+ self.details.append(self._output(item))
69
+ if self.details:
70
+ selected = min(selected, len(self.details) - 1)
71
+ table.move_cursor(row=selected)
72
+ self._show_result(selected)
73
+
74
+ @staticmethod
75
+ def _output(record: dict) -> str:
76
+ output = record.get("output")
77
+ if isinstance(output, list):
78
+ return "\n".join(f"{index}. {item}" for index, item in enumerate(output, 1))
79
+ return str(output or "")
80
+
81
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
82
+ self._show_result(event.cursor_row)
83
+
84
+ def _show_result(self, row: int) -> None:
85
+ self.query_one("#stage-result", Static).update(
86
+ self.details[row] if 0 <= row < len(self.details) else "")
@@ -0,0 +1,193 @@
1
+ """Full views opened from the transcript: one step in full, and the agent tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.markup import escape
6
+ from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.containers import VerticalScroll
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import Static, Tree
11
+
12
+ from rlmagent.tui.messages import highlighted_code
13
+ from rlmagent.tui.steps import Step, StepBook, footer_text, header_text, short_id
14
+
15
+
16
+ class HelpScreen(ModalScreen[None]):
17
+ BINDINGS = [("escape", "dismiss", "Close"), ("f1", "dismiss", "Close")]
18
+
19
+ def __init__(self) -> None:
20
+ super().__init__(classes="inspect-screen")
21
+
22
+ def compose(self) -> ComposeResult:
23
+ with VerticalScroll(classes="inspect-body"):
24
+ yield Static("Keyboard shortcuts", classes="step-head")
25
+ for binding in self.app.BINDINGS:
26
+ keys = self.app._keymap.get(binding.id, binding.key)
27
+ if keys:
28
+ yield Static(f"{keys} {binding.description}", markup=False)
29
+ for line in (
30
+ "Esc Close this panel",
31
+ "Enter on a step Inspect its code and output",
32
+ "Space on a finished step Show or hide its details",
33
+ "Type / at the prompt to browse commands.",
34
+ ):
35
+ yield Static(line, markup=False)
36
+
37
+
38
+ class StepScreen(ModalScreen[None]):
39
+ """Everything one step has: code, the whole output, usage and time."""
40
+
41
+ BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")]
42
+
43
+ def __init__(self, step: Step) -> None:
44
+ super().__init__(classes="inspect-screen")
45
+ self.step = step
46
+
47
+ def compose(self) -> ComposeResult:
48
+ step = self.step
49
+ with VerticalScroll(classes="inspect-body"):
50
+ yield Static(Text(header_text(step)), classes="step-head")
51
+ yield Static(Text(f"agent {step.agent} · model {step.model or 'unknown'}"),
52
+ classes="step-foot")
53
+ yield Static(highlighted_code(step.code) if step.code else "(no code)",
54
+ classes="step-code")
55
+ yield Static(escape(step.output) if step.output else "(no output)",
56
+ classes="step-output")
57
+ yield Static(Text(footer_text(step)), classes="step-foot")
58
+ yield Static("Esc to close", classes="step-foot")
59
+
60
+
61
+ def agent_label(book: StepBook, run_id: str) -> str:
62
+ agent = book.agents[run_id]
63
+ steps = [book.steps[(run_id, n)] for n in agent.steps]
64
+ tokens = sum(s.tokens for s in steps)
65
+ costs = [s.cost for s in steps if s.cost is not None]
66
+ spent = f" · ${sum(costs):.4f}" if costs else ""
67
+ name = book.child_names.get(agent.child_id) or short_id(agent.child_id or run_id)
68
+ note = book.child_notes.get(agent.child_id)
69
+ progress = f" · {note}" if note else ""
70
+ return (f"{name} · d{agent.depth} · {agent.status} · "
71
+ f"{len(steps)} steps · {tokens} tokens{spent}{progress}")
72
+
73
+
74
+ class AgentTreeScreen(ModalScreen[None]):
75
+ """Who started whom, with each agent's state and spend."""
76
+
77
+ BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")]
78
+
79
+ def __init__(self, book: StepBook) -> None:
80
+ super().__init__(classes="inspect-screen")
81
+ self.book = book
82
+ self._agent_tree: Tree[str] = Tree("agents", classes="inspect-body")
83
+ self._agent_tree.show_root = False
84
+ self._agent_nodes = {}
85
+ self._empty_leaf = None
86
+
87
+ def compose(self) -> ComposeResult:
88
+ yield self._agent_tree
89
+ yield Static("Esc to close", classes="step-foot")
90
+
91
+ def on_mount(self) -> None:
92
+ self._agent_tree.root.expand()
93
+ self._refresh_tree()
94
+ self.set_interval(0.25, self._refresh_tree)
95
+
96
+ def _refresh_tree(self) -> None:
97
+ agents = self.book.agents
98
+ root = self._agent_tree.root
99
+ if self._agent_nodes.keys() - agents.keys():
100
+ root.remove_children()
101
+ self._agent_nodes.clear()
102
+ if not agents and self._empty_leaf is None:
103
+ self._empty_leaf = root.add_leaf("no agents have run yet")
104
+ elif agents and self._empty_leaf is not None:
105
+ self._empty_leaf.remove()
106
+ self._empty_leaf = None
107
+ for agent in sorted(agents.values(), key=lambda item: item.depth):
108
+ label = agent_label(self.book, agent.run_id)
109
+ node = self._agent_nodes.get(agent.run_id)
110
+ if node is None:
111
+ parent = self._agent_nodes.get(agent.parent, root)
112
+ self._agent_nodes[agent.run_id] = parent.add(Text(label), data=agent.run_id, expand=True)
113
+ elif node.label.plain != label:
114
+ node.set_label(Text(label))
115
+
116
+
117
+ BAR_WIDTH = 30
118
+
119
+
120
+ def timeline_rows(book: StepBook) -> list[str]:
121
+ """One line per step in start order: offset, duration, a bar to scale, and who ran it."""
122
+ steps = [s for s in book.steps.values() if s.started is not None]
123
+ if not steps:
124
+ return ["No timed steps yet."]
125
+ steps.sort(key=lambda s: s.started)
126
+ first = steps[0].started
127
+ last = max((s.ended or s.started) for s in steps)
128
+ span = max(last - first, 1e-6)
129
+ rows = []
130
+ for step in steps:
131
+ start = int((step.started - first) / span * BAR_WIDTH)
132
+ length = 1 if step.ended is None else max(1, round((step.ended - step.started) / span * BAR_WIDTH))
133
+ bar = " " * start + ("█" if step.ended is not None else "▒") * length
134
+ took = "running" if step.seconds is None else f"{step.seconds:6.1f}s"
135
+ rows.append(f"+{step.started - first:7.1f}s {took:>8} |{bar:<{BAR_WIDTH + 1}}| "
136
+ f"d{step.depth} {step.short} step {step.number} {step.state}")
137
+ return rows
138
+
139
+
140
+ class TimelineScreen(ModalScreen[None]):
141
+ """When each step ran and for how long, across every agent."""
142
+
143
+ BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")]
144
+
145
+ def __init__(self, book: StepBook) -> None:
146
+ super().__init__(classes="inspect-screen")
147
+ self.book = book
148
+
149
+ def compose(self) -> ComposeResult:
150
+ with VerticalScroll(classes="inspect-body"):
151
+ yield Static(Text("\n".join(timeline_rows(self.book))))
152
+ yield Static("Esc to close", classes="step-foot")
153
+
154
+
155
+ def worker_rows() -> list[str]:
156
+ """Every worker: whether it runs, its last heartbeat, and its waiting and scheduled jobs."""
157
+ import time
158
+
159
+ from rlmagent import worker
160
+
161
+ base = worker.root()
162
+ names = sorted(p.name for p in base.iterdir() if p.is_dir()) if base.is_dir() else []
163
+ if not names:
164
+ return ["No workers. Start one with: rlm-agent worker start NAME"]
165
+ rows = []
166
+ for name in names:
167
+ meta = worker.state_of(name)
168
+ state = meta.get("state", "?") if meta["alive"] else "not running"
169
+ beat = meta.get("beat")
170
+ ago = f"beat {time.time() - beat:.0f}s ago" if beat else "no heartbeat"
171
+ jobs = worker.jobs_of(name)
172
+ waiting = [j for j in jobs if j.get("status") == "waiting"]
173
+ rows.append(f"{name}: {state}, {ago}, {len(waiting)} waiting")
174
+ for job in waiting[:5]:
175
+ when = time.strftime("%m-%d %H:%M", time.localtime(job["due"]))
176
+ again = f", every {job['every']:.0f}s" if job.get("every") else ""
177
+ rows.append(f" {job['id']} at {when}{again}: {job['text'][:80]}")
178
+ return rows
179
+
180
+
181
+ class WorkersScreen(ModalScreen[None]):
182
+ """Background workers and their jobs."""
183
+
184
+ BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")]
185
+
186
+ def compose(self) -> ComposeResult:
187
+ with VerticalScroll(classes="inspect-body"):
188
+ yield Static(Text("\n".join(worker_rows())))
189
+ yield Static("Esc to close", classes="step-foot")
190
+
191
+
192
+ __all__ = ["AgentTreeScreen", "StepScreen", "TimelineScreen", "WorkersScreen", "agent_label",
193
+ "timeline_rows", "worker_rows"]