co-agent-coder 0.1.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 (51) hide show
  1. co_agent_coder-0.1.0.dist-info/METADATA +396 -0
  2. co_agent_coder-0.1.0.dist-info/RECORD +51 -0
  3. co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
  4. co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
  5. co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
  7. coagent/__init__.py +84 -0
  8. coagent/__main__.py +6 -0
  9. coagent/agent/__init__.py +27 -0
  10. coagent/agent/budget.py +220 -0
  11. coagent/agent/loop.py +1102 -0
  12. coagent/agent/prompts.py +228 -0
  13. coagent/agent/state.py +181 -0
  14. coagent/cli.py +351 -0
  15. coagent/config.py +307 -0
  16. coagent/context/__init__.py +26 -0
  17. coagent/context/artifacts.py +431 -0
  18. coagent/context/repo_map.py +590 -0
  19. coagent/context/retrieval.py +375 -0
  20. coagent/context/tokens.py +93 -0
  21. coagent/evals/__init__.py +0 -0
  22. coagent/gitx/__init__.py +52 -0
  23. coagent/gitx/commit.py +91 -0
  24. coagent/gitx/durable.py +147 -0
  25. coagent/gitx/git.py +351 -0
  26. coagent/gitx/lock.py +157 -0
  27. coagent/gitx/log.py +85 -0
  28. coagent/gitx/resume.py +199 -0
  29. coagent/gitx/session.py +233 -0
  30. coagent/llm/__init__.py +83 -0
  31. coagent/llm/base.py +235 -0
  32. coagent/llm/grammar.py +306 -0
  33. coagent/llm/registry.py +147 -0
  34. coagent/llm/transformers_backend.py +326 -0
  35. coagent/py.typed +0 -0
  36. coagent/safety/__init__.py +24 -0
  37. coagent/safety/denylist.py +146 -0
  38. coagent/safety/policy.py +219 -0
  39. coagent/safety/redact.py +146 -0
  40. coagent/templates/config.toml +104 -0
  41. coagent/tools/__init__.py +155 -0
  42. coagent/tools/artifacts.py +307 -0
  43. coagent/tools/base.py +226 -0
  44. coagent/tools/fs.py +507 -0
  45. coagent/tools/notebooks.py +875 -0
  46. coagent/tools/pyt.py +240 -0
  47. coagent/tools/shell.py +558 -0
  48. coagent/ui/__init__.py +50 -0
  49. coagent/ui/gradio_app.py +353 -0
  50. coagent/ui/notebook.py +342 -0
  51. coagent/ui/render.py +422 -0
@@ -0,0 +1,353 @@
1
+ """Optional Gradio surface for coAgent (DESIGN §8.3).
2
+
3
+ Scope, deliberately small: this is a *nicety*. The notebook UI (§8.1) must be
4
+ good enough that the product works without Gradio, so nothing here is required
5
+ by the loop and every import of `gradio` is guarded.
6
+
7
+ Served inside Colab with::
8
+
9
+ from google.colab.output import serve_kernel_port_as_window
10
+ app = GradioApp()
11
+ app.launch() # starts on cfg.ui.gradio_port
12
+ serve_kernel_port_as_window(app.port) # opens it in the Colab tab
13
+
14
+ `share=True` and ngrok are out of scope by design: flaky, rate-limited, and a
15
+ security surface nobody asked for. A bare `server_name="127.0.0.1"` is the whole
16
+ of the exposure model.
17
+
18
+ The interesting logic lives in `ui/render.py` (`Transcript`, `Row`); this module
19
+ is the thin widget layer over it, which is why it is the only file that imports
20
+ Gradio and the only file without unit tests beyond the guarded-import path.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import threading
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ from .render import Transcript, render_status_line, summarise_verify
31
+
32
+ __all__ = ["GradioApp", "GradioUnavailable", "gradio_available"]
33
+
34
+
35
+ class GradioUnavailable(RuntimeError):
36
+ """Gradio is not installed. The message says how to get it."""
37
+
38
+
39
+ def gradio_available() -> bool:
40
+ """True when `gradio` can be imported. Cheap enough to call per launch."""
41
+ try:
42
+ import gradio # noqa: F401
43
+ except Exception: # noqa: BLE001 - any import failure means "not available"
44
+ return False
45
+ return True
46
+
47
+
48
+ @dataclass
49
+ class _PendingApproval:
50
+ """One approval request waiting on a human, plus the handles to resolve it."""
51
+
52
+ request: Any
53
+ decision: threading.Event = field(default_factory=threading.Event)
54
+ approved: bool = False
55
+ decided: bool = False
56
+
57
+
58
+ def _require_gradio():
59
+ try:
60
+ import gradio as gr
61
+ except Exception as exc: # noqa: BLE001
62
+ raise GradioUnavailable(
63
+ "the Gradio UI needs the optional dependency:\n"
64
+ " pip install 'coagent[ui]' # or: pip install gradio\n"
65
+ "The notebook surface (coagent.ui.ChatCell) works without it."
66
+ ) from exc
67
+ return gr
68
+
69
+
70
+ class GradioApp:
71
+ """A chat pane, a transcript, and an approval queue, on one port.
72
+
73
+ Usage::
74
+
75
+ app = GradioApp(agent=agent) # or: app = GradioApp(); app.attach(agent)
76
+ app.launch()
77
+ app.chat("add a chunked CSV loader") # from a notebook cell, optionally
78
+
79
+ The agent is optional at construction because the usual pattern in a Colab
80
+ cell is to build the UI first and wire the agent in once the model is loaded.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ agent: Any = None,
87
+ port: int | None = None,
88
+ title: str = "coAgent",
89
+ max_rows: int = 200,
90
+ approval_timeout_s: float = 120.0,
91
+ ) -> None:
92
+ self.gr = _require_gradio()
93
+ self.transcript = Transcript(max_rows=max_rows)
94
+ self.agent = agent
95
+ self.title = title
96
+ self.approval_timeout_s = approval_timeout_s
97
+ self._pending: _PendingApproval | None = None
98
+ self._lock = threading.Lock()
99
+ self._running = False
100
+
101
+ # The port comes from config when not passed: ui.gradio_port in config.toml.
102
+ self.port = int(port) if port else self._port_from_config(agent)
103
+
104
+ self._ui: Any = None
105
+ self.block = None
106
+
107
+ # ------------------------------------------------------------- config
108
+
109
+ @staticmethod
110
+ def _port_from_config(agent: Any) -> int:
111
+ cfg = getattr(agent, "cfg", None)
112
+ ui = getattr(cfg, "ui", None) if cfg is not None else None
113
+ return int(getattr(ui, "gradio_port", 7860) or 7860)
114
+
115
+ # -------------------------------------------------------------- agent
116
+
117
+ def attach(self, agent: Any) -> None:
118
+ """Bind the agent and take over its event and approval hooks.
119
+
120
+ The hooks are replaced rather than chained because two surfaces drawing
121
+ the same run is a rare intent and always the wrong one: the transcript
122
+ would render every event twice.
123
+ """
124
+ self.agent = agent
125
+ agent.on_event = self.on_event
126
+ agent.renderer = self.render_artifact
127
+ agent.approver = self.approver
128
+ if self.port == 7860:
129
+ self.port = self._port_from_config(agent)
130
+
131
+ # ------------------------------------------------------------- events
132
+
133
+ def on_event(self, kind: str, data: dict[str, Any]) -> None:
134
+ """`coAgent(on_event=app.on_event)`. Never raises into the loop."""
135
+ try:
136
+ self.transcript.handle(kind, data)
137
+ if kind == "task_end":
138
+ self._running = False
139
+ self._clear_pending()
140
+ self._refresh()
141
+ except Exception as exc: # noqa: BLE001 - a broken UI must not end the run
142
+ print(f"[ui] could not update the Gradio transcript: {exc}")
143
+
144
+ def render_artifact(self, kind: str, payload: str, mime: str) -> None:
145
+ """`coAgent(renderer=app.render_artifact)`.
146
+
147
+ Gradio can show an image or a file but has no inline "artifact" concept,
148
+ so the transcript records the path and the notebook cell stays the place
149
+ where artifacts are actually displayed. This keeps the two surfaces
150
+ honest about their different capabilities instead of faking parity.
151
+ """
152
+ self.transcript.note_artifacts([payload] if payload else [])
153
+ self._refresh()
154
+
155
+ # ---------------------------------------------------------- approvals
156
+
157
+ def approver(self, request: Any) -> bool:
158
+ """Block the loop's thread until a button is pressed, or time out to deny.
159
+
160
+ Timeout denies, matching the policy's non-interactive default: the safe
161
+ answer to "nobody is looking" is no (DESIGN §9.1).
162
+ """
163
+ pending = _PendingApproval(request=request)
164
+ with self._lock:
165
+ self._pending = pending
166
+ self._refresh()
167
+ got = pending.decision.wait(timeout=self.approval_timeout_s)
168
+ with self._lock:
169
+ if self._pending is pending:
170
+ self._pending = None
171
+ self._refresh()
172
+ return bool(got and pending.approved)
173
+
174
+ def _resolve(self, approved: bool) -> None:
175
+ with self._lock:
176
+ pending = self._pending
177
+ if pending is None:
178
+ return
179
+ pending.approved = approved
180
+ pending.decided = True
181
+ self._pending = None
182
+ pending.decision.set()
183
+
184
+ def _clear_pending(self) -> None:
185
+ with self._lock:
186
+ pending = self._pending
187
+ self._pending = None
188
+ if pending is not None:
189
+ # Unblock the loop thread rather than leaving it waiting on a run
190
+ # that has already ended.
191
+ pending.approved = False
192
+ pending.decision.set()
193
+
194
+ # ---------------------------------------------------------------- chat
195
+
196
+ def chat(self, task: str) -> Any:
197
+ """Run a task on the attached agent, off the UI thread.
198
+
199
+ The prompt is cleared as soon as it is submitted so a second click cannot
200
+ submit the same text twice while the first run is still going.
201
+ """
202
+ if self.agent is None:
203
+ raise RuntimeError("GradioApp has no agent: pass agent=... or call attach(agent).")
204
+ self._running = True
205
+ try:
206
+ return self.agent.chat(task)
207
+ finally:
208
+ self._running = False
209
+ self._refresh()
210
+
211
+ def submit(self, task: str, _history: Any = None) -> tuple[str, str, str, str]:
212
+ """The callback behind the Send button.
213
+
214
+ Returns ``(transcript_markdown, status, prompt_value, approval_html)`` so
215
+ Gradio can update four components from one event. Errors are reported in
216
+ the transcript and not raised: a traceback in the UI tells the user
217
+ nothing they can act on.
218
+ """
219
+ task = (task or "").strip()
220
+ if not task:
221
+ return self._view()
222
+ if self.agent is None:
223
+ self.transcript.handle("info", {"notes": "no agent attached yet"})
224
+ return self._view()
225
+
226
+ # Run synchronously on the Gradio worker: the loop emits approval
227
+ # requests mid-run and the buttons must stay live, which they do because
228
+ # `approver` blocks the loop's own thread, not the server's.
229
+ try:
230
+ self.chat(task)
231
+ except Exception as exc: # noqa: BLE001 - surface it, do not crash the server
232
+ self.transcript.handle("info", {"notes": f"run failed: {exc}"})
233
+ return self._view()
234
+
235
+ def _view(self) -> tuple[str, str, str, str]:
236
+ status = self._status_line()
237
+ return (
238
+ self.transcript.render(status_line=status),
239
+ status,
240
+ "",
241
+ self._approval_html(),
242
+ )
243
+
244
+ def _status_line(self) -> str:
245
+ agent = self.agent
246
+ if agent is None:
247
+ return "coAgent · no agent attached"
248
+ budget = ""
249
+ state = getattr(agent, "state", None)
250
+ if state is not None:
251
+ summary = getattr(state, "plan_summary", "") or ""
252
+ if summary:
253
+ budget = summary
254
+ return render_status_line(
255
+ model_id=getattr(agent, "model_id", "?"),
256
+ ctx_tokens=int(getattr(agent, "ctx_tokens", 0) or 0),
257
+ session_id=getattr(agent, "session_id", "?"),
258
+ budget=budget,
259
+ )
260
+
261
+ def _approval_html(self) -> str:
262
+ """The pending request, or empty. HTML because it must not render a plan."""
263
+ with self._lock:
264
+ pending = self._pending
265
+ if pending is None:
266
+ return ""
267
+ req = pending.request
268
+ command = str(getattr(req, "command", ""))
269
+ reason = str(getattr(req, "reason", ""))
270
+ kind = str(getattr(req, "kind", "action"))
271
+ # Escape before interpolating: `command` is model output, and this is HTML.
272
+ import html as _html
273
+
274
+ return (
275
+ f"<div style='border-left:3px solid #d97706;padding:8px 12px'>"
276
+ f"<b>Approval needed</b> — <code>{_html.escape(kind)}</code><br>"
277
+ f"<code>{_html.escape(command)}</code><br>"
278
+ f"<small>{_html.escape(reason)}</small></div>"
279
+ )
280
+
281
+ def verify_line(self, summary: str) -> str:
282
+ """One-line verify verdict, shared with the notebook surface."""
283
+ return summarise_verify(summary)
284
+
285
+ def _refresh(self) -> None:
286
+ """Push the current view to the widgets, if the UI is built."""
287
+ if self._ui is None:
288
+ return
289
+ try:
290
+ body, status, prompt, approval = self._view()
291
+ self._ui["transcript"].value = body
292
+ self._ui["status"].value = status
293
+ self._ui["prompt"].value = prompt
294
+ self._ui["approval"].value = approval
295
+ self._ui["buttons_row"].visible = bool(approval)
296
+ except Exception: # noqa: BLE001 - widget may not exist yet
297
+ pass
298
+
299
+ # --------------------------------------------------------------- build
300
+
301
+ def build(self) -> Any:
302
+ """Construct the Blocks UI. Idempotent: repeated calls return the same app."""
303
+ if self.block is not None:
304
+ return self.block
305
+ gr = self.gr
306
+
307
+ with gr.Blocks(title=self.title) as block:
308
+ gr.Markdown(f"# {self.title}")
309
+ self._ui = {}
310
+ self._ui["status"] = gr.Markdown(self._status_line())
311
+ self._ui["transcript"] = gr.Markdown("coAgent is idle.")
312
+ self._ui["approval"] = gr.HTML("")
313
+ with gr.Row(visible=False) as buttons_row:
314
+ approve = gr.Button("Approve", variant="primary", size="sm")
315
+ deny = gr.Button("Deny", variant="stop", size="sm")
316
+ self._ui["buttons_row"] = buttons_row
317
+ with gr.Row():
318
+ prompt = gr.Textbox(
319
+ placeholder="Describe the task, e.g. 'add a chunked CSV loader with tests'",
320
+ show_label=False,
321
+ scale=8,
322
+ lines=1,
323
+ )
324
+ send = gr.Button("Send", variant="primary", scale=1, size="sm")
325
+ self._ui["prompt"] = prompt
326
+
327
+ outputs = [self._ui["transcript"], self._ui["status"], prompt, self._ui["approval"]]
328
+ send.click(self.submit, inputs=[prompt], outputs=outputs)
329
+ prompt.submit(self.submit, inputs=[prompt], outputs=outputs)
330
+ approve.click(lambda: (self._resolve(True), self._view())[1], outputs=outputs)
331
+ deny.click(lambda: (self._resolve(False), self._view())[1], outputs=outputs)
332
+
333
+ self.block = block
334
+ return block
335
+
336
+ def launch(self, *, open_browser: bool = False, **kwargs: Any) -> Any:
337
+ """Start the server on 127.0.0.1 only.
338
+
339
+ Loopback deliberately: in Colab the tab reaches it through the kernel's
340
+ port proxy, so there is no reason to bind a public interface.
341
+ """
342
+ block = self.build()
343
+ kwargs.setdefault("server_name", "127.0.0.1")
344
+ kwargs.setdefault("server_port", self.port)
345
+ kwargs.setdefault("share", False) # §8.3: share=True is out of scope
346
+ kwargs.setdefault("quiet", True)
347
+ return block.launch(open_browser=open_browser, **kwargs)
348
+
349
+ def close(self) -> None:
350
+ self._clear_pending()
351
+ if self.block is not None:
352
+ with contextlib.suppress(Exception):
353
+ self.block.close()
coagent/ui/notebook.py ADDED
@@ -0,0 +1,342 @@
1
+ """The notebook chat cell — the guaranteed surface (DESIGN §8.1, PRD §7.8).
2
+
3
+ What this file is allowed to assume
4
+ -----------------------------------
5
+ `ipywidgets` and `IPython.display` may be absent (CLI, tests, a bare script), so
6
+ every import of them is inside a function. When they are missing, `ChatCell` still
7
+ constructs and still records the transcript; it just does not draw it. That is why
8
+ the interesting logic lives in `render.py` and this module is mostly plumbing.
9
+
10
+ The one genuinely hard part
11
+ ---------------------------
12
+ Approvals are a *synchronous* callable in the loop (`Approver` returns a bool), but
13
+ a notebook cannot block its own cell waiting for a click without freezing the
14
+ front end. The mechanism here is a `threading.Event` that a button callback sets:
15
+
16
+ * the loop thread (which is the cell's thread) blocks on `event.wait()`;
17
+ * Colab delivers widget messages on a separate thread, so button callbacks still
18
+ run and can set the event.
19
+
20
+ A timeout is mandatory. If the browser is gone, a disconnected runtime, or a
21
+ message that never arrives, `wait()` returns False and the action is **denied** —
22
+ the safe default, consistent with non-interactive mode (DESIGN §9.1).
23
+
24
+ FR-UX-7 (reconnect-safe)
25
+ ------------------------
26
+ Widget state can be lost when a Colab tab reconnects. So the cell also accepts
27
+ plain text commands (`approve`, `deny`, `stop`, `resume`) through `command()`,
28
+ which sets the same pending decision. A stuck session is recoverable from the
29
+ keyboard.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import contextlib
35
+ import html
36
+ import threading
37
+ from typing import Any
38
+
39
+ from .render import Transcript, render_status_line
40
+
41
+ __all__ = ["ChatCell", "ApprovalTimeout", "DEFAULT_APPROVAL_TIMEOUT_S"]
42
+
43
+ #: Ten minutes: long enough for a human to read a diff, short enough that an
44
+ #: abandoned tab does not hold a session lock until the heartbeat expires.
45
+ DEFAULT_APPROVAL_TIMEOUT_S = 600
46
+
47
+
48
+ class ApprovalTimeout(RuntimeError):
49
+ """No decision arrived in time. Treated as a denial by the caller."""
50
+
51
+
52
+ class ChatCell:
53
+ """Renders a coAgent run into a Colab output cell.
54
+
55
+ Usage from a cell::
56
+
57
+ cell = ChatCell()
58
+ agent = coAgent(repo=".", on_event=cell.on_event, approver=cell.approver,
59
+ renderer=cell.render_artifact)
60
+ cell.attach(agent)
61
+ cell.chat("add a chunked CSV loader")
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ title: str = "",
68
+ approval_timeout_s: float = DEFAULT_APPROVAL_TIMEOUT_S,
69
+ max_rows: int = 200,
70
+ stream: bool = True,
71
+ ) -> None:
72
+ self.transcript = Transcript(max_rows=max_rows)
73
+ self.approval_timeout_s = approval_timeout_s
74
+ self.stream = stream
75
+ self.agent: Any = None
76
+ self.title = title
77
+ #: Set by a button or by `command()`; the loop thread waits on it.
78
+ self._decision: threading.Event = threading.Event()
79
+ self._decision_value = False
80
+ self._pending_request: Any = None
81
+ self._lock = threading.Lock()
82
+
83
+ self._handle: Any = None
84
+ self._details_handle: Any = None
85
+ self._decide_event: threading.Event | None = None
86
+ self._buttons: dict[str, Any] = {}
87
+ self._widgets_ok = False
88
+ self._setup_surface()
89
+
90
+ # ------------------------------------------------------------- surface
91
+
92
+ def _setup_surface(self) -> None:
93
+ """Create the output handles. Failures here are not fatal (headless)."""
94
+ try:
95
+ from IPython.display import display
96
+
97
+ self._handle = display({"text/markdown": "coAgent is idle."}, raw=True, display_id=True)
98
+ self._details_handle = display({"text/html": ""}, raw=True, display_id=True)
99
+ self._widgets_ok = True
100
+ except Exception: # noqa: BLE001 - no IPython: stay silent, keep working
101
+ self._handle = None
102
+ self._details_handle = None
103
+ self._paint()
104
+
105
+ @property
106
+ def live(self) -> bool:
107
+ """True when there is something to draw on."""
108
+ return self._handle is not None
109
+
110
+ # -------------------------------------------------------------- events
111
+
112
+ def on_event(self, kind: str, data: dict[str, Any]) -> None:
113
+ """`coAgent(on_event=cell.on_event)`. Never raises into the loop."""
114
+ try:
115
+ self.transcript.handle(kind, data)
116
+ if kind == "task_end":
117
+ self._render_buttons(show=False)
118
+ self._paint()
119
+ except Exception as exc: # noqa: BLE001 - the UI must never break a run
120
+ # A broken renderer must not take the session with it; the log is the
121
+ # durable record and the loop keeps going.
122
+ self._safe_print(f"[ui] render failed: {exc}")
123
+
124
+ def _paint(self) -> None:
125
+ if self._handle is None:
126
+ return
127
+ try:
128
+ status = self._status_text()
129
+ body = self.transcript.render(status_line="")
130
+ if status:
131
+ body = f"```\n{status}\n```\n\n{body}"
132
+ # `display_id` + update is what §8.1 asks for: `clear_output` flickers
133
+ # and scrolls the pane, an in-place update does not.
134
+ self._handle.update({"text/markdown": body}, raw=True)
135
+ self._paint_buttons()
136
+ except Exception: # noqa: BLE001
137
+ pass
138
+
139
+ def _status_text(self) -> str:
140
+ """FR-UX-3: model, context, GPU, session, and budget on every repaint."""
141
+ agent = self.agent
142
+ if agent is None:
143
+ return self.transcript.status
144
+ try:
145
+ return render_status_line(
146
+ model_id=getattr(agent, "model_id", "?"),
147
+ ctx_tokens=agent.cfg.model.max_context,
148
+ session_id=agent.session_id,
149
+ budget=agent.budget.describe(),
150
+ gpu=_gpu_label(),
151
+ )
152
+ except Exception: # noqa: BLE001
153
+ return self.transcript.status
154
+
155
+ # ---------------------------------------------------------- approvals
156
+
157
+ def approver(self, request: Any) -> bool:
158
+ """The synchronous `Approver` the loop calls.
159
+
160
+ Blocks on a button click or a text command. On timeout it denies: a
161
+ missing human is not consent (DESIGN §9.1, non-interactive default).
162
+ """
163
+ with self._lock:
164
+ self._pending_request = request
165
+ self._decision.clear()
166
+ self._decision_value = False
167
+
168
+ self._render_buttons(show=True)
169
+ self._paint()
170
+ arrived = self._decision.wait(timeout=self.approval_timeout_s)
171
+
172
+ with self._lock:
173
+ decided = self._decision_value
174
+ self._pending_request = None
175
+ self._render_buttons(show=False)
176
+ self._paint()
177
+
178
+ if not arrived:
179
+ self.transcript.handle(
180
+ "info",
181
+ {
182
+ "notes": (
183
+ f"No decision for the {request.kind} request within "
184
+ f"{self.approval_timeout_s:.0f}s, so it was denied. "
185
+ "Send `approve` to retry the action, or `stop` to end the session."
186
+ )
187
+ },
188
+ )
189
+ self._paint()
190
+ return False
191
+ return decided
192
+
193
+ def command(self, text: str) -> bool | None:
194
+ """Accept a text decision when widgets are gone (FR-UX-7).
195
+
196
+ Returns True/False when the command resolved a pending request, or None
197
+ when the text was not a command.
198
+ """
199
+ word = text.strip().lower().split(maxsplit=1)[0] if text.strip() else ""
200
+ if word in {"approve", "yes", "y", "a"}:
201
+ return self._resolve(True)
202
+ if word in {"deny", "no", "n", "reject", "d"}:
203
+ return self._resolve(False)
204
+ if word in {"stop", "cancel"}:
205
+ if self.agent is not None:
206
+ self.agent.cancel()
207
+ return self._resolve(False)
208
+ return None
209
+
210
+ def _resolve(self, value: bool) -> bool | None:
211
+ with self._lock:
212
+ if self._pending_request is None:
213
+ return None
214
+ self._decision_value = value
215
+ self._decision.set()
216
+ return value
217
+
218
+ # ------------------------------------------------------------ buttons
219
+
220
+ def _render_buttons(self, *, show: bool) -> None:
221
+ """The plan/approval controls (FR-UX-2).
222
+
223
+ Widgets are created once and shown/hidden, because creating fresh widgets
224
+ on every approval is what makes a Colab output cell grow without bound.
225
+ """
226
+ if not self._widgets_ok:
227
+ return
228
+ if show and not self._buttons:
229
+ self._create_buttons()
230
+ box = self._buttons.get("box")
231
+ if box is None:
232
+ return
233
+ try:
234
+ from IPython.display import display
235
+
236
+ if show and getattr(box, "_coagent_shown", False) is False:
237
+ display(box)
238
+ box._coagent_shown = True # noqa: SLF001 - display bookkeeping
239
+ box.layout.display = "" if show else "none"
240
+ except Exception: # noqa: BLE001
241
+ pass
242
+
243
+ def _create_buttons(self) -> None:
244
+ try:
245
+ import ipywidgets as widgets
246
+ except Exception: # noqa: BLE001 - no widgets: text commands still work
247
+ self._widgets_ok = False
248
+ return
249
+
250
+ approve = widgets.Button(description="Approve", button_style="success")
251
+ deny = widgets.Button(description="Deny", button_style="danger")
252
+ stop = widgets.Button(description="Stop")
253
+
254
+ approve.on_click(lambda _b: self._resolve(True))
255
+ deny.on_click(lambda _b: self._resolve(False))
256
+ stop.on_click(self._on_stop)
257
+
258
+ self._buttons = {
259
+ "approve": approve,
260
+ "deny": deny,
261
+ "stop": stop,
262
+ "box": widgets.HBox([approve, deny, stop]),
263
+ }
264
+
265
+ def _on_stop(self, _button: Any) -> None:
266
+ if self.agent is not None:
267
+ with contextlib.suppress(Exception):
268
+ self.agent.cancel()
269
+ self._resolve(False)
270
+
271
+ # ---------------------------------------------------------- artifacts
272
+
273
+ def render_artifact(self, kind: str, payload: str, mime: str) -> None:
274
+ """`coAgent(renderer=cell.render_artifact)` — FR-UX-4.
275
+
276
+ Renders below the transcript so a figure does not push the log off screen.
277
+ `payload` is base64 for images and raw text otherwise, which is the
278
+ contract `display_artifact` documents.
279
+ """
280
+ if self._details_handle is None:
281
+ return
282
+ try:
283
+ if kind == "image":
284
+ import base64
285
+
286
+ from IPython.display import Image, display
287
+
288
+ display(Image(data=base64.b64decode(payload)))
289
+ return
290
+ if kind == "html":
291
+ from IPython.display import HTML, display
292
+
293
+ display(HTML(payload))
294
+ return
295
+ escaped = html.escape(payload[:4000])
296
+ self._details_handle.update(
297
+ {"text/markdown": f"```\n{escaped}\n```"}, raw=True
298
+ )
299
+ except Exception as exc: # noqa: BLE001
300
+ self._safe_print(f"[ui] could not render artifact: {exc}")
301
+
302
+ # --------------------------------------------------------------- chat
303
+
304
+ def attach(self, agent: Any) -> None:
305
+ """Bind the agent so the status line and Stop button have a target."""
306
+ self.agent = agent
307
+
308
+ def chat(self, task: str, agent: Any = None) -> Any:
309
+ """Run a task with this cell as the surface."""
310
+ target = agent or self.agent
311
+ if target is None:
312
+ raise RuntimeError(
313
+ "ChatCell.chat needs an agent: pass one, or call attach(agent) first."
314
+ )
315
+ self.attach(target)
316
+ return target.chat(task)
317
+
318
+ def _safe_print(self, text: str) -> None:
319
+ try:
320
+ from IPython.display import display
321
+
322
+ display({"text/plain": text}, raw=True)
323
+ except Exception: # noqa: BLE001
324
+ print(text)
325
+
326
+
327
+ def _gpu_label() -> str:
328
+ """`T4 16GB` when a GPU is visible, empty otherwise.
329
+
330
+ Deliberately tolerant: this runs on every repaint, and a CUDA query that
331
+ throws must not blank the status line.
332
+ """
333
+ try:
334
+ import torch
335
+
336
+ if not torch.cuda.is_available():
337
+ return ""
338
+ props = torch.cuda.get_device_properties(0)
339
+ gib = props.total_memory / 1024**3
340
+ return f"{props.name.split()[0]} {gib:.0f}GB"
341
+ except Exception: # noqa: BLE001
342
+ return ""