6Bus → presenter bridge for the approval and sub-agent lanes — plan 188 T2.
10``run_turn`` and ``run_sub_loop`` already publish ``tool_call_proposed``,
11``permission_decision``, ``subagent_started`` and ``subagent_completed``
12on the M2 bus, and the NDJSON :class:`~oc_chat.core.event_writer.EventWriter`
13already persists them — but nothing ever put them on screen. The
14plan→approve→act lane the substrate models (synthesis §1) was invisible,
15and a sub-agent's *completion* reached only ``_dbg``.
17This module is the read side for the terminal: one subscriber that folds
18those kinds into :meth:`~oc_ui.presenter_base.Presenter.show_activity`
19calls. It is the direct analogue of
20:mod:`oc_chat.core.event_bootstrap` — same one-per-process, idempotent,
21attach-at-session-bootstrap shape — and it is deliberately a *subscriber*
22rather than call-site rendering:
24- the approval and sub-agent lanes exist ONLY as events (the loop's own
25 call sites hand the presenter nothing about them), so the bus is where
26 the information actually is;
27- it keeps the ReAct loop free of another presenter branch;
28- "a dashboard is just another subscriber" is the banked architecture —
29 this is that same seam used for the terminal, which is exactly the
30 reuse Phase 1's projection/reducer will inherit.
32**Nothing here publishes.** The bus stays observability, not control
33(synthesis §2), and no event kind or payload changes.
37- :class:`PresenterEventBridge` — the subscriber: event → activity line,
38 plus per-turn counters exposed via :meth:`PresenterEventBridge.snapshot`.
39- :func:`attach_presenter_bridge` — idempotently subscribe one bridge.
40- :func:`detach_presenter_bridge` — unsubscribe and clear the singleton.
46 L2 — attach / detach lifecycle.
47 L3 — per-event rendering decisions.
48 L4 — module-load coverage marker.
52- ``attach_presenter_bridge`` is idempotent: a second call returns the
53 same bridge and never double-subscribes (which would double-render).
54- A presenter without ``show_activity`` (one predating plan 188) is
55 tolerated: the bridge checks and skips. The bus already isolates
56 subscriber faults, but a renderer must not rely on that.
57- ``tool_call_proposed`` is COUNTED, never rendered: ``show_tool_call``
58 already announces every invocation, so a second inline line per
59 proposal would be pure duplication. The count is still tracked so the
60 proposed-vs-executed delta is available to
61 :meth:`PresenterEventBridge.snapshot` (and to Phase 1's reducer).
62- ``permission_decision`` carries no tool linkage in the M2 envelope, so
63 the rendered line names the decision and the decider ONLY. Guessing
64 which proposal it belongs to would be a false attribution; adding a
65 linkage field would be a Phase-1 substrate change.
69- oc_diagnostics (structured debug logging via _dbg)
70- oc_chat.core.event_bus (EventBus / default_bus — the subscription seam)
71- oc_chat.core.events (lazy; TurnEvent type hint for the event handlers)
72- oc_ui.presenter_base (lazy; Presenter type hint for the rendering target)
74from __future__
import annotations
76from typing
import TYPE_CHECKING, Optional
78from oc_diagnostics
import _dbg
86_dbg(
"OC_CHAT.MAIN",
"presenter_events",
"module", 4)
90_BAD_OUTCOMES: frozenset[str] = frozenset({
"error",
"cancelled",
"cap_hit"})
97 """Renders the approval + sub-agent lanes onto a presenter.
99 Construct via :func:`attach_presenter_bridge`; the bridge owns its
100 :class:`~oc_chat.core.event_bus.Subscription` handles and releases
101 them in :meth:`detach`.
104 def __init__(self, presenter:
"Presenter") ->
None:
116 """Subscribe to the four kinds this bridge renders (or counts)."""
120 bus.subscribe(
"permission_decision", self.
_on_decision),
125 _dbg(
"OC_CHAT.MAIN", func, f
"{len(self._subs)} subscription(s)", 2)
128 """Release every subscription. Safe to call twice."""
130 bus = getattr(self,
"_bus",
None)
132 for sub
in self.
_subs:
135 _dbg(
"OC_CHAT.MAIN", func,
"detached", 2)
139 def _emit(self, label: str, detail: str, tone: str) ->
None:
140 """Call ``show_activity`` if the presenter has it; skip otherwise."""
141 show = getattr(self.
_presenter,
"show_activity",
None)
142 if not callable(show):
144 show(label, detail, tone=tone)
147 """Count a proposal. Deliberately renders nothing — see Contracts."""
151 """The pending-approval lane: how a gated call was resolved."""
152 payload = event.payload
153 decision = str(getattr(payload,
"decision",
"")
or "?")
154 decided_by = str(getattr(payload,
"decided_by",
"")
or "?")
155 if decision ==
"allow":
167 if str(getattr(payload,
"authorization_mode",
"")
or "") ==
"auto_allow":
169 self.
_emit(
"approval", f
"{decision} · decided by {decided_by}", tone)
173 sid = str(getattr(event.payload,
"subagent_session_id",
"")
or "")
174 self.
_emit(
"sub-agent", f
"started · {sid[:_ID_PREFIX]}",
"info")
178 payload = event.payload
179 outcome = str(getattr(payload,
"outcome",
"")
or "?")
180 sid = str(getattr(payload,
"subagent_session_id",
"")
or "")
181 tone =
"warn" if outcome
in _BAD_OUTCOMES
else "ok"
182 self.
_emit(
"sub-agent", f
"{outcome} · {sid[:_ID_PREFIX]}", tone)
187 """Cumulative counters for the lanes this bridge observes.
189 The proposed-vs-decided delta is the pending-approval view Phase 1's
190 projection/reducer will formalise; here it is a plain counter so the
191 information is at least available.
205_active_bridge: Optional[PresenterEventBridge] =
None
209 presenter:
"Presenter",
211 bus: Optional[EventBus] =
None,
212) -> PresenterEventBridge:
213 """Attach a singleton :class:`PresenterEventBridge` (idempotent).
215 Returns the active bridge. A second call returns the SAME instance
216 without re-subscribing, so the rendered line count stays at one per
217 event. *bus* defaults to the process-wide
218 :func:`~oc_chat.core.event_bus.default_bus`; tests inject their own.
220 func =
"attach_presenter_bridge"
221 global _active_bridge
222 if _active_bridge
is not None:
223 return _active_bridge
225 bridge.attach(bus
if bus
is not None else default_bus())
226 _active_bridge = bridge
227 _dbg(
"OC_CHAT.MAIN", func,
"presenter bridge attached", 2)
232 """Unsubscribe the active bridge and clear the singleton. No-op when unattached."""
233 func =
"detach_presenter_bridge"
234 global _active_bridge
235 if _active_bridge
is not None:
236 _active_bridge.detach()
237 _active_bridge =
None
238 _dbg(
"OC_CHAT.MAIN", func,
"presenter bridge detached", 2)
242 "PresenterEventBridge",
243 "attach_presenter_bridge",
244 "detach_presenter_bridge",
None _on_subagent_started(self, "TurnEvent" event)
None __init__(self, "Presenter" presenter)
None _emit(self, str label, str detail, str tone)
None _on_decision(self, "TurnEvent" event)
None _on_proposed(self, "TurnEvent" event)
None _on_subagent_completed(self, "TurnEvent" event)
None attach(self, EventBus bus)
None detach_presenter_bridge()
PresenterEventBridge attach_presenter_bridge("Presenter" presenter, *, Optional[EventBus] bus=None)