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,182 @@
1
+ """Left sidebar — the session switcher, grouped by what each session is doing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.markup import escape
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Vertical
8
+ from textual.message import Message
9
+ from textual.widgets import Input, Label, ListItem, ListView, Static
10
+
11
+ from rlmagent.tui.adapter import SessionEntry
12
+ from rlmagent.tui.search_box import SearchBox
13
+
14
+ #: Section order and headings. Each session sits under exactly one.
15
+ SECTIONS: tuple[tuple[str, str], ...] = (
16
+ ("running", "Running"),
17
+ ("idle", "Idle"),
18
+ ("inactive", "Inactive"),
19
+ )
20
+
21
+
22
+ class SessionItem(ListItem):
23
+ """A session row. Identity is carried as an attribute, not a widget id."""
24
+
25
+ def __init__(self, label: str, session_id: str | None) -> None:
26
+ super().__init__(Label(label, markup=False))
27
+ self.session_id = session_id
28
+
29
+ @property
30
+ def is_new_action(self) -> bool:
31
+ """Whether this row creates a new session."""
32
+ return self.session_id is None
33
+
34
+
35
+ class SectionHeading(ListItem):
36
+ """A section title inside the list; it cannot be chosen."""
37
+
38
+ def __init__(self, title: str, count: int) -> None:
39
+ super().__init__(Label(f"{title} ({count})", markup=False), classes="session-heading")
40
+ self.disabled = True
41
+
42
+
43
+ class SessionList(ListView):
44
+ """Selectable list of sessions, grouped by status, newest first in each group."""
45
+
46
+ def __init__(self) -> None:
47
+ super().__init__(id="session-list")
48
+
49
+
50
+ def matches(entry: SessionEntry, query: str) -> bool:
51
+ """Whether *entry* fits a search by title or id, case-insensitively."""
52
+ query = query.strip().lower()
53
+ return not query or query in entry.label.lower() or query in entry.session_id.lower()
54
+
55
+
56
+ class Sidebar(Vertical):
57
+ """Fixed-width column holding the session switcher."""
58
+
59
+ class SwitchRequested(Message):
60
+ """User picked an existing session."""
61
+
62
+ def __init__(self, session_id: str) -> None:
63
+ super().__init__()
64
+ self.session_id = session_id
65
+
66
+ class NewRequested(Message):
67
+ """User asked for a fresh session."""
68
+
69
+ def __init__(self) -> None:
70
+ super().__init__()
71
+ #: What the list was last built from, so an unchanged refresh only restyles.
72
+ self._shape: tuple = ()
73
+ self._entries: list[SessionEntry] = []
74
+ #: Active session id at the last repaint, so the cursor only moves
75
+ #: when the active session actually changes.
76
+ self._active: str | None = None
77
+
78
+ def compose(self) -> ComposeResult:
79
+ yield Label("Sessions", id="sidebar-title")
80
+ yield SearchBox(placeholder="search sessions", id="session-search")
81
+ yield SessionList()
82
+ yield Static("", id="task-panel")
83
+
84
+ def show_tasks(self, items: list[dict]) -> None:
85
+ """The agent's task list under the sessions; hidden while it is empty."""
86
+ panel = self.query_one("#task-panel", Static)
87
+ lines = [("✓ " if item["done"] else "· ") + escape(item["text"]) for item in items]
88
+ panel.update("Tasks\n" + "\n".join(lines) if lines else "")
89
+ panel.display = bool(lines)
90
+
91
+ @property
92
+ def query_text(self) -> str:
93
+ return self.query_one("#session-search", Input).value
94
+
95
+ async def show(self, entries: list[SessionEntry]) -> None:
96
+ """Repaint the list, highlighting the active session."""
97
+ self._entries = list(entries)
98
+ await self._rebuild()
99
+
100
+ async def _rebuild(self) -> None:
101
+ listing = self.query_one(SessionList)
102
+ query = self.query_text
103
+ shown = [e for e in self._entries if matches(e, query)]
104
+ active = next((e.session_id for e in self._entries if e.is_active), None)
105
+ shape = (query, tuple((e.session_id, e.status) for e in shown))
106
+
107
+ if shape == self._shape:
108
+ self._restyle(listing, shown)
109
+ if active != self._active:
110
+ self._active = active
111
+ listing.index = self._active_index(listing)
112
+ return
113
+
114
+ # clear() removes children lazily, so await it or the appends race
115
+ # still-mounted widgets.
116
+ await listing.clear()
117
+ if not query:
118
+ await listing.append(SessionItem("+ New session", None))
119
+ for status, title in SECTIONS:
120
+ group = [e for e in shown if e.status == status]
121
+ if not group:
122
+ continue
123
+ await listing.append(SectionHeading(title, len(group)))
124
+ for entry in group:
125
+ await listing.append(SessionItem(self._label_for(entry), entry.session_id))
126
+ if query and not shown:
127
+ await listing.append(SectionHeading("No match", 0))
128
+ self._shape = shape
129
+ self._active = active
130
+ self._restyle(listing, shown)
131
+ listing.index = self._active_index(listing)
132
+
133
+ @staticmethod
134
+ def _label_for(entry: SessionEntry) -> str:
135
+ marker = "●" if entry.is_active else " "
136
+ return f"{marker} {entry.label}"
137
+
138
+ def _active_index(self, listing: SessionList) -> int:
139
+ for i, child in enumerate(listing.children):
140
+ if isinstance(child, SessionItem) and child.has_class("active-session"):
141
+ return i
142
+ return 0
143
+
144
+ def _restyle(self, listing: SessionList, entries: list[SessionEntry]) -> None:
145
+ """Refresh labels and the active marker without touching the cursor."""
146
+ by_id = {e.session_id: e for e in entries}
147
+ for child in listing.children:
148
+ if not isinstance(child, SessionItem) or child.is_new_action:
149
+ continue
150
+ entry = by_id.get(child.session_id or "")
151
+ if entry is None:
152
+ continue
153
+ child.set_class(entry.is_active, "active-session")
154
+ child.query(Label).first().update(self._label_for(entry))
155
+
156
+ async def on_input_changed(self, event: Input.Changed) -> None:
157
+ if event.input.id == "session-search":
158
+ event.stop()
159
+ await self._rebuild()
160
+
161
+ def on_input_submitted(self, event: Input.Submitted) -> None:
162
+ """Enter in the search box opens the first match."""
163
+ if event.input.id != "session-search":
164
+ return
165
+ event.stop()
166
+ first = next((e for e in self._entries if matches(e, event.value)), None)
167
+ if first is not None and not first.is_active:
168
+ self.post_message(self.SwitchRequested(first.session_id))
169
+
170
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
171
+ """Translate a row activation into an intent message."""
172
+ event.stop()
173
+ item = event.item
174
+ if not isinstance(item, SessionItem):
175
+ return
176
+ if item.is_new_action:
177
+ self.post_message(self.NewRequested())
178
+ else:
179
+ self.post_message(self.SwitchRequested(item.session_id or ""))
180
+
181
+
182
+ __all__ = ["SECTIONS", "SectionHeading", "SessionItem", "SessionList", "Sidebar", "matches"]
rlmagent/tui/status.py ADDED
@@ -0,0 +1,49 @@
1
+ """Footer status bar: provider, model, session, context usage, git branch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.widgets import Static
6
+
7
+ from rlmagent.tui.adapter import StatusSnapshot
8
+
9
+
10
+ class StatusBar(Static):
11
+ """One-line status readout pinned above the keybinding footer."""
12
+
13
+ def __init__(self) -> None:
14
+ super().__init__("", id="status-bar")
15
+ self._line = ""
16
+
17
+ @property
18
+ def line(self) -> str:
19
+ """The markup currently displayed."""
20
+ return self._line
21
+
22
+ def show(self, snapshot: StatusSnapshot, *, busy: bool = False, goal: str = "") -> None:
23
+ """Render *snapshot*."""
24
+ parts = [
25
+ snapshot.provider,
26
+ snapshot.model,
27
+ f"ctx {snapshot.context_percent}%",
28
+ ]
29
+ if snapshot.input_tokens or snapshot.output_tokens or snapshot.cost:
30
+ parts.append(f"{snapshot.input_tokens:,} in, {snapshot.cached_tokens:,} cached, "
31
+ f"{snapshot.output_tokens:,} out, ${snapshot.cost:.4f}")
32
+ if snapshot.budget:
33
+ parts.append(snapshot.budget)
34
+ if snapshot.branch:
35
+ parts.append(f"⎇ {snapshot.branch}")
36
+ if snapshot.plan_mode:
37
+ parts.append("PLAN")
38
+ if goal:
39
+ short = goal if len(goal) <= 40 else goal[:39] + "…"
40
+ parts.append(f"◎ {short}")
41
+ if busy:
42
+ parts.append("■ generating — press Esc to stop")
43
+ self._line = " · ".join(parts)
44
+ # Highlight the whole bar while a generation can be stopped.
45
+ self.set_class(busy, "busy")
46
+ self.update(self._line)
47
+
48
+
49
+ __all__ = ["StatusBar"]
rlmagent/tui/steps.py ADDED
@@ -0,0 +1,283 @@
1
+ """Step cards: one block per agent step, built from the engine's run events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from rich.markup import escape
8
+ from rich.text import Text
9
+ from textual.containers import Vertical
10
+ from textual.message import Message
11
+ from textual.widgets import Collapsible, Static
12
+
13
+ from rlmagent.tui.messages import highlighted_code
14
+
15
+ SHOWN_OUTPUT_LINES = 6
16
+ MAX_OUTPUT_CHARS = 20_000
17
+
18
+ GENERATING, RUNNING, DONE, FAILED = "generating", "running", "done", "failed"
19
+
20
+
21
+ @dataclass
22
+ class Step:
23
+ """What a card shows for one step of one agent."""
24
+
25
+ agent: str
26
+ number: int
27
+ depth: int = 0
28
+ model: str = ""
29
+ state: str = GENERATING
30
+ code: str = ""
31
+ output: str = ""
32
+ tokens: int = 0
33
+ cost: float | None = None
34
+ started: float | None = None
35
+ ended: float | None = None
36
+
37
+ @property
38
+ def key(self) -> tuple[str, int]:
39
+ return (self.agent, self.number)
40
+
41
+ @property
42
+ def short(self) -> str:
43
+ return short_id(self.agent)
44
+
45
+ @property
46
+ def seconds(self) -> float | None:
47
+ if self.started is None or self.ended is None:
48
+ return None
49
+ return max(0.0, self.ended - self.started)
50
+
51
+
52
+ def short_id(run_id: str) -> str:
53
+ """Six characters that tell agents apart; the root keeps its name."""
54
+ if run_id == "root" or len(run_id) <= 6:
55
+ return run_id
56
+ return run_id[-6:]
57
+
58
+
59
+ @dataclass
60
+ class Agent:
61
+ run_id: str
62
+ parent: str | None = None
63
+ depth: int = 0
64
+ model: str = ""
65
+ status: str = RUNNING
66
+ steps: list[int] = field(default_factory=list)
67
+ child_id: str | None = None
68
+
69
+
70
+ def _moment(value) -> float | None:
71
+ """Seconds since the epoch, from a number or the ISO text the engine records."""
72
+ if value is None or isinstance(value, (int, float)):
73
+ return value
74
+ from datetime import datetime
75
+
76
+ try:
77
+ return datetime.fromisoformat(str(value)).timestamp()
78
+ except ValueError:
79
+ return None
80
+
81
+
82
+ def _usage(value) -> tuple[int, float | None]:
83
+ if value is None:
84
+ return 0, None
85
+ if isinstance(value, dict):
86
+ tokens = value.get("total_tokens") or (
87
+ (value.get("prompt_tokens") or 0) + (value.get("completion_tokens") or 0))
88
+ return int(tokens or 0), value.get("cost")
89
+ tokens = getattr(value, "total_tokens", 0) or (
90
+ (getattr(value, "prompt_tokens", 0) or 0) + (getattr(value, "completion_tokens", 0) or 0))
91
+ return int(tokens or 0), getattr(value, "cost", None)
92
+
93
+
94
+ class StepBook:
95
+ """Folds engine events into steps and agents. Holds no widgets."""
96
+
97
+ def __init__(self) -> None:
98
+ self.steps: dict[tuple[str, int], Step] = {}
99
+ self.agents: dict[str, Agent] = {}
100
+ self.order: list[tuple[str, int]] = []
101
+ self.child_names: dict[str, str] = {}
102
+ self.child_notes: dict[str, str] = {}
103
+
104
+ def clear(self) -> None:
105
+ self.steps.clear()
106
+ self.agents.clear()
107
+ self.order.clear()
108
+ self.child_names.clear()
109
+ self.child_notes.clear()
110
+
111
+ def feed(self, event: dict) -> Step | None:
112
+ """Apply one event; return the step it changed, if it changed one worth showing."""
113
+ kind = event.get("kind")
114
+ run_id = event.get("run_id") or "root"
115
+ child_id = event.get("child_id")
116
+ if kind == "run_started":
117
+ agent = self.agents.setdefault(run_id, Agent(run_id))
118
+ agent.parent = event.get("parent_run_id")
119
+ agent.depth = int(event.get("depth") or 0)
120
+ agent.model = str(event.get("model") or "")
121
+ agent.status = RUNNING
122
+ if child_id:
123
+ agent.child_id = child_id
124
+ return None
125
+ if kind == "child_bound" and child_id:
126
+ self.agents.setdefault(run_id, Agent(run_id)).child_id = child_id
127
+ if kind in ("child_bound", "child_named") and child_id and event.get("name"):
128
+ self.child_names[child_id] = event["name"]
129
+ if kind == "child_progress" and child_id:
130
+ self.child_notes[child_id] = " ".join(str(event.get("text", ""))[:500].splitlines())
131
+ return None
132
+ if kind == "child_status":
133
+ if run_id in self.agents:
134
+ self.agents[run_id].status = event["status"]
135
+ return None
136
+ if kind in ("run_completed", "run_failed"):
137
+ agent = self.agents.get(run_id)
138
+ if agent is not None:
139
+ agent.status = DONE if kind == "run_completed" else FAILED
140
+ return None
141
+ number = event.get("step")
142
+ if not isinstance(number, int) or number <= 0:
143
+ return None # step 0 only loads the context
144
+ step = self._step(run_id, number)
145
+ if kind == "step_started":
146
+ step.state = GENERATING
147
+ step.started = _moment(event.get("started") or event.get("timestamp"))
148
+ elif kind == "code_generated":
149
+ step.code = str(event.get("code") or "")
150
+ step.state = RUNNING if step.code else FAILED
151
+ elif kind == "output_received":
152
+ step.output = str(event.get("output") or "")[-MAX_OUTPUT_CHARS:]
153
+ if event.get("error"):
154
+ step.state = FAILED
155
+ elif kind == "step_completed":
156
+ step.tokens, step.cost = _usage(event.get("usage"))
157
+ step.state = FAILED if event.get("error") else DONE
158
+ step.ended = _moment(event.get("ended") or event.get("timestamp"))
159
+ else:
160
+ return None
161
+ return step
162
+
163
+ def _step(self, run_id: str, number: int) -> Step:
164
+ key = (run_id, number)
165
+ step = self.steps.get(key)
166
+ if step is None:
167
+ agent = self.agents.get(run_id) or Agent(run_id)
168
+ self.agents.setdefault(run_id, agent)
169
+ step = Step(run_id, number, agent.depth, agent.model)
170
+ self.steps[key] = step
171
+ self.order.append(key)
172
+ agent.steps.append(number)
173
+ return step
174
+
175
+ def totals(self) -> dict:
176
+ steps = self.steps.values()
177
+ costs = [s.cost for s in steps if s.cost is not None]
178
+ return {
179
+ "steps": len(self.steps),
180
+ "tokens": sum(s.tokens for s in steps),
181
+ "cost": sum(costs) if costs else None,
182
+ "agents": len(self.agents),
183
+ "running": sum(1 for a in self.agents.values() if a.status == RUNNING),
184
+ "depth": max((a.depth for a in self.agents.values()), default=0),
185
+ }
186
+
187
+
188
+ def header_text(step: Step) -> str:
189
+ """The card's title line, e.g. ``RLM d0 · root · step 1 · running``."""
190
+ return f"RLM d{step.depth} · {step.short} · step {step.number} · {step.state}"
191
+
192
+
193
+ def footer_text(step: Step) -> str:
194
+ """Tokens, cost and time under a finished step."""
195
+ parts = [f"{step.tokens / 1000:.1f}k tokens" if step.tokens >= 1000 else f"{step.tokens} tokens"]
196
+ parts.append("cost unknown" if step.cost is None else f"${step.cost:.4f}")
197
+ if step.seconds is not None:
198
+ parts.append(f"{step.seconds:.1f}s")
199
+ return " · ".join(parts)
200
+
201
+
202
+ def output_preview(output: str) -> tuple[str, int]:
203
+ """The first lines of output and how many were left out."""
204
+ lines = output.rstrip("\n").splitlines()
205
+ hidden = max(0, len(lines) - SHOWN_OUTPUT_LINES)
206
+ return "\n".join(lines[:SHOWN_OUTPUT_LINES]), hidden
207
+
208
+
209
+ class StepCard(Vertical, can_focus=True):
210
+ """One step on screen: title, code, output preview, usage. Enter or a click opens it."""
211
+
212
+ BINDINGS = [("enter", "inspect", "Open step"), ("space", "toggle_details", "Toggle details")]
213
+
214
+ class Inspect(Message):
215
+ def __init__(self, step: Step) -> None:
216
+ super().__init__()
217
+ self.step = step
218
+
219
+ def action_inspect(self) -> None:
220
+ self.post_message(self.Inspect(self.step))
221
+
222
+ def action_toggle_details(self) -> None:
223
+ if self.step.state in (DONE, FAILED):
224
+ self._collapsed = not self._collapsed
225
+ self.refresh_step(self.step)
226
+
227
+ def on_click(self) -> None:
228
+ self.action_inspect()
229
+
230
+ def __init__(self, step: Step) -> None:
231
+ super().__init__(classes="step-card")
232
+ self.step = step
233
+ self._collapsed = False
234
+ self.styles.margin = (0, 0, 0, 2 * step.depth)
235
+ self._head = Static(classes="step-head")
236
+ self._code = Static(classes="step-code")
237
+ self._out = Static(classes="step-output")
238
+ self._more: Collapsible | None = None
239
+ self._foot = Static(classes="step-foot")
240
+
241
+ def compose(self):
242
+ yield self._head
243
+ yield self._code
244
+ yield self._out
245
+ yield self._foot
246
+
247
+ def on_mount(self) -> None:
248
+ self.refresh_step(self.step)
249
+
250
+ def refresh_step(self, step: Step) -> None:
251
+ self.step = step
252
+ self._head.update(Text(header_text(step)))
253
+ self.set_class(step.state == FAILED, "step-failed")
254
+ self.set_class(step.state in (GENERATING, RUNNING), "step-live")
255
+ self._code.update(highlighted_code(step.code) if step.code else "")
256
+ self._code.display = bool(step.code) and not self._collapsed
257
+ shown, hidden = output_preview(step.output)
258
+ self._out.update(escape(shown) if shown else "")
259
+ self._out.display = bool(shown) and not self._collapsed
260
+ if hidden and self._more is None:
261
+ self._more = Collapsible(Static(escape(step.output)), collapsed=True,
262
+ title=f"{hidden} more lines")
263
+ self.mount(self._more, before=self._foot)
264
+ elif self._more is not None:
265
+ self._more.title = f"{hidden} more lines"
266
+ self._more.query_one(Static).update(escape(step.output))
267
+ if self._more is not None:
268
+ self._more.display = bool(hidden) and not self._collapsed
269
+ done = step.state in (DONE, FAILED)
270
+ self._foot.update(footer_text(step) if done else "")
271
+ self._foot.display = done
272
+
273
+
274
+
275
+ __all__ = [
276
+ "Step",
277
+ "StepBook",
278
+ "StepCard",
279
+ "footer_text",
280
+ "header_text",
281
+ "output_preview",
282
+ "short_id",
283
+ ]