agent-bios 0.17.1 → 0.19.0

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 (55) hide show
  1. package/DEPENDENCIES.md +236 -80
  2. package/INSTALL.md +112 -0
  3. package/README.md +184 -486
  4. package/claude/CLAUDE.md +1 -1
  5. package/claude/guides/cli-multi-model-workflow.md +1 -1
  6. package/claude/guides/learning-flow.md +23 -12
  7. package/claude/guides/session-distill-workflow.md +22 -12
  8. package/codex/AGENTS.md +1 -1
  9. package/codex/guides/cli-multi-model-workflow.md +1 -1
  10. package/codex/guides/learning-flow.md +23 -12
  11. package/codex/guides/session-distill-workflow.md +22 -12
  12. package/compose/app_bridge/SKILL.md +75 -0
  13. package/compose/app_bridge/agents/openai.yaml +2 -0
  14. package/compose/app_bridge/scripts/bridge.py +76 -0
  15. package/compose/bootstrap/SKILL.md +23 -2
  16. package/compose/corpus.py +31 -9
  17. package/compose/corpus_app.py +456 -0
  18. package/compose/corpus_import.py +529 -0
  19. package/compose/corpus_install.py +196 -18
  20. package/compose/corpus_session.py +27 -0
  21. package/compose/corpus_setup.py +674 -0
  22. package/compose/corpus_setup_cli.py +582 -0
  23. package/compose/corpus_setup_i18n.py +318 -0
  24. package/compose/corpus_setup_ui.py +633 -0
  25. package/compose/corpus_store.py +236 -34
  26. package/compose/corpus_transaction.py +43 -10
  27. package/compose/corpus_ui.py +279 -8
  28. package/compose/corpus_ui_runtime.py +278 -0
  29. package/compose/corpus_understand.py +6 -1
  30. package/compose/setup/START.md +147 -0
  31. package/compose/ui_runtime/linkify_it_py-2.2.0-py3-none-any.whl +0 -0
  32. package/compose/ui_runtime/manifest.json +238 -0
  33. package/compose/ui_runtime/markdown_it_py-4.2.0-py3-none-any.whl +0 -0
  34. package/compose/ui_runtime/mdit_py_plugins-0.6.1-py3-none-any.whl +0 -0
  35. package/compose/ui_runtime/mdurl-0.1.2-py3-none-any.whl +0 -0
  36. package/compose/ui_runtime/platformdirs-4.11.8-py3-none-any.whl +0 -0
  37. package/compose/ui_runtime/pygments-2.21.0-py3-none-any.whl +0 -0
  38. package/compose/ui_runtime/rich-15.0.0-py3-none-any.whl +0 -0
  39. package/compose/ui_runtime/textual-8.2.8-py3-none-any.whl +0 -0
  40. package/compose/ui_runtime/typing_extensions-4.16.0-py3-none-any.whl +0 -0
  41. package/docs/advanced-launch.md +131 -0
  42. package/docs/assets/corpus-studio.svg +227 -0
  43. package/docs/corpus.md +117 -0
  44. package/docs/recovery.md +201 -0
  45. package/docs/session-model.md +120 -0
  46. package/docs/setup.md +190 -0
  47. package/docs/understand.md +40 -0
  48. package/install.sh +75 -46
  49. package/launch/agent-launch.py +91 -47
  50. package/launch/provision-venv.sh +44 -13
  51. package/learn/collect-learning.py +14 -5
  52. package/learn/learning.schema.json +2 -2
  53. package/package.json +14 -2
  54. package/provenance.json +1 -1
  55. package/wrappers/claude-run.sh +10 -13
@@ -18,6 +18,7 @@ from rich.text import Text
18
18
  from textual.app import App, ComposeResult
19
19
  from textual.binding import Binding
20
20
  from textual.containers import Horizontal, Vertical
21
+ from textual.events import Key
21
22
  from textual.screen import ModalScreen
22
23
  from textual.widgets import (
23
24
  Button,
@@ -43,6 +44,10 @@ SURFACES = ("always", "relevant", "requested", "event", "delegated")
43
44
  VIEWS = ("effective", "installed", "change", "diff", "history")
44
45
 
45
46
 
47
+ def availability_label(state: str) -> str:
48
+ return "available" if state == "active" else state
49
+
50
+
46
51
  def guide_pointers(item: dict[str, Any], inventory: list[dict[str, Any]]) -> list[dict[str, Any]]:
47
52
  """Display literal guide references, not inferred dependencies or new routing."""
48
53
  if item.get("kind") != "rule":
@@ -63,6 +68,8 @@ def guide_pointers(item: dict[str, Any], inventory: list[dict[str, Any]]) -> lis
63
68
  def library_label(item: dict[str, Any], inventory: list[dict[str, Any]]) -> Text:
64
69
  pointers = guide_pointers(item, inventory)
65
70
  label = Text()
71
+ mark = "-" if item.get("state") == "removed" else "x" if item.get("enabled", True) else " "
72
+ label.append(f"[{mark}]" + ("* " if item.get("enabled_pending") else " "))
66
73
  if pointers:
67
74
  label.append("→ GUIDE ", style="bold cyan")
68
75
  label.append(", ".join(PurePosixPath(pointer["path"]).stem for pointer in pointers))
@@ -112,6 +119,31 @@ class Confirmation(ModalScreen[bool]):
112
119
  class CorpusMarkdownViewer(MarkdownViewer):
113
120
  """Keep all links inside Studio; never hand a URL to the operating system."""
114
121
 
122
+ BINDINGS = [
123
+ Binding("left", "left_or_library", "Library", show=False),
124
+ Binding("up", "up_or_controls", "Controls", show=False),
125
+ Binding("down", "down_or_pending", "Pending choices", show=False),
126
+ ]
127
+
128
+ def action_left_or_library(self) -> None:
129
+ if self.scroll_x <= 0:
130
+ self.app.action_focus_library()
131
+ else:
132
+ self.action_scroll_left()
133
+
134
+ def action_up_or_controls(self) -> None:
135
+ if self.scroll_y <= 0:
136
+ if not self.app._focus_id("member-select"):
137
+ self.app._focus_controls()
138
+ else:
139
+ self.action_scroll_up()
140
+
141
+ def action_down_or_pending(self) -> None:
142
+ if self.scroll_y >= self.max_scroll_y:
143
+ self.app._focus_id("enablement-preview")
144
+ else:
145
+ self.action_scroll_down()
146
+
115
147
  async def _on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
116
148
  # stop() prevents bubbling, not the inherited MarkdownViewer handler.
117
149
  # Its default go() would try to load corpus:// as a filesystem path.
@@ -124,6 +156,57 @@ class CorpusMarkdownViewer(MarkdownViewer):
124
156
  self.app.notify("External links are disabled in Corpus Studio.", severity="warning")
125
157
 
126
158
 
159
+ class CorpusSearch(Input):
160
+ """Leave search with arrows without taking editing keys away from text."""
161
+
162
+ BINDINGS = [
163
+ Binding("down", "focus_controls", "Controls", show=False),
164
+ Binding("right", "right_or_view", "View", show=False),
165
+ ]
166
+
167
+ def action_focus_controls(self) -> None:
168
+ self.app._focus_controls()
169
+
170
+ def action_right_or_view(self) -> None:
171
+ if self.selection.start == self.selection.end == len(self.value):
172
+ self.app._focus_id("view-select")
173
+ else:
174
+ self.action_cursor_right()
175
+
176
+
177
+ class CorpusTree(Tree):
178
+ """Space is local to the library, never intercepted in text inputs."""
179
+
180
+ BINDINGS = [
181
+ Binding("space", "toggle_item", "On/off"),
182
+ Binding("up", "up_or_controls", "Controls", show=False),
183
+ Binding("down", "down_or_pending", "Pending choices", show=False),
184
+ Binding("right", "focus_document", "Document", show=False),
185
+ ]
186
+
187
+ def action_up_or_controls(self) -> None:
188
+ if self.cursor_line <= 0:
189
+ self.app._focus_controls()
190
+ else:
191
+ self.action_cursor_up()
192
+
193
+ def action_down_or_pending(self) -> None:
194
+ before = self.cursor_node
195
+ self.action_cursor_down()
196
+ if self.cursor_node is before:
197
+ self.app._focus_id("enablement-preview")
198
+
199
+ def action_focus_document(self) -> None:
200
+ self.app._focus_document()
201
+
202
+ async def action_toggle_item(self) -> None:
203
+ node = self.cursor_node
204
+ if node is not None and isinstance(node.data, str):
205
+ await self.app.toggle_enabled(node.data)
206
+ else:
207
+ self.action_toggle_node()
208
+
209
+
127
210
  class CorpusStudio(App[None]):
128
211
  """Searchable corpus library, editor, and revision-bound Preview → Apply flow."""
129
212
 
@@ -169,6 +252,9 @@ class CorpusStudio(App[None]):
169
252
  .actions { height: 3; align-horizontal: right; }
170
253
  .actions Button { margin-left: 1; }
171
254
  #status-line { height: 1; padding: 0 1; color: $text-muted; }
255
+ #enablement-bar { height: 3; display: none; }
256
+ #enablement-summary { width: 1fr; padding: 1; }
257
+ #enablement-bar Button { margin-right: 1; }
172
258
  """
173
259
 
174
260
  def __init__(self, store: Any) -> None:
@@ -183,13 +269,16 @@ class CorpusStudio(App[None]):
183
269
  self.view_item: dict[str, Any] | None = None
184
270
  self.member_drafts: dict[str, str] = {}
185
271
  self.pending_plan: dict[str, Any] | None = None
272
+ self.toggle_drafts: dict[str, bool] = {}
273
+ self.toggle_original: dict[str, bool] = {}
274
+ self.toggle_revision: str | None = None
186
275
  self.mode = "view"
187
276
 
188
277
  def compose(self) -> ComposeResult:
189
278
  yield Header()
190
279
  with Vertical(id="toolbar"):
191
280
  with Horizontal(id="search-row"):
192
- yield Input(placeholder="Search title, content, ref, package, domain, or state", id="search")
281
+ yield CorpusSearch(placeholder="Search title, content, ref, package, domain, or state (↓ controls)", id="search")
193
282
  yield Select([(view.title(), view) for view in VIEWS], value="effective",
194
283
  allow_blank=False, id="view-select")
195
284
  with Horizontal(id="action-row"):
@@ -200,7 +289,7 @@ class CorpusStudio(App[None]):
200
289
  yield Button("Recover", id="recover", compact=True)
201
290
  yield Button("Reset", id="reset", variant="error", compact=True)
202
291
  with Horizontal(id="workspace"):
203
- yield Tree("Corpus", id="library")
292
+ yield CorpusTree("Corpus", id="library")
204
293
  with Vertical(id="detail"):
205
294
  yield Select([], prompt="Bundle document", allow_blank=True, id="member-select")
206
295
  yield CorpusMarkdownViewer(
@@ -240,6 +329,10 @@ class CorpusStudio(App[None]):
240
329
  with Horizontal(classes="actions"):
241
330
  yield Button("Cancel plan", id="plan-cancel")
242
331
  yield Button("Apply", id="apply", variant="success")
332
+ with Horizontal(id="enablement-bar"):
333
+ yield Static("", id="enablement-summary", markup=False)
334
+ yield Button("Preview on/off", id="enablement-preview", variant="primary")
335
+ yield Button("Discard on/off", id="enablement-discard")
243
336
  yield Static("Stored privately; no host session is activated by this screen.", id="status-line")
244
337
  yield Footer()
245
338
 
@@ -247,6 +340,63 @@ class CorpusStudio(App[None]):
247
340
  await self.refresh_library()
248
341
  self.query_one("#search", Input).focus()
249
342
 
343
+ @staticmethod
344
+ def _focusable(widget: Any) -> bool:
345
+ return widget.focusable and all(getattr(node, "display", True) for node in [widget, *widget.ancestors])
346
+
347
+ def _focus_id(self, *ids: str) -> bool:
348
+ # Query the current screen only: arrows must never escape a modal.
349
+ for ident in ids:
350
+ for widget in self.screen.query(f"#{ident}"):
351
+ if self._focusable(widget):
352
+ widget.focus()
353
+ return True
354
+ return False
355
+
356
+ def _focus_controls(self) -> None:
357
+ for button in self.screen.query("#action-row Button"):
358
+ if self._focusable(button):
359
+ button.focus()
360
+ return
361
+ self._focus_id("library" if getattr(self.focused, "id", None) == "search" else "search")
362
+
363
+ def _focus_document(self) -> None:
364
+ if self.mode != "view" or self._focus_id("member-select"):
365
+ return
366
+ for viewer in self.screen.query("#wiki"):
367
+ if self._focusable(viewer.document):
368
+ viewer.document.focus()
369
+
370
+ def on_key(self, event: Key) -> None:
371
+ focused = self.focused
372
+ moved = False
373
+ if isinstance(focused, Button) and focused.parent is not None:
374
+ siblings = [button for button in focused.parent.query(Button)
375
+ if button.parent is focused.parent and self._focusable(button)]
376
+ if event.key in {"left", "right"} and focused in siblings:
377
+ index = siblings.index(focused) + (-1 if event.key == "left" else 1)
378
+ if 0 <= index < len(siblings):
379
+ siblings[index].focus()
380
+ moved = True
381
+ elif event.key in {"up", "down"} and len(self.screen_stack) == 1:
382
+ if focused.parent.id == "action-row":
383
+ moved = self._focus_id("search" if event.key == "up" else "library")
384
+ elif focused.parent.id == "enablement-bar":
385
+ moved = self._focus_id("library")
386
+ elif self.mode == "editor" and event.key == "up":
387
+ moved = self._focus_id("editor-body")
388
+ elif isinstance(focused, Select) and not focused.expanded and self.mode == "view":
389
+ if event.key == "left" and focused.id in {"view-select", "member-select"}:
390
+ moved = self._focus_id("search" if focused.id == "view-select" else "library")
391
+ elif event.key == "right" and focused.id == "member-select":
392
+ for viewer in self.screen.query("#wiki"):
393
+ if self._focusable(viewer.document):
394
+ viewer.document.focus()
395
+ moved = True
396
+ if moved:
397
+ event.prevent_default()
398
+ event.stop()
399
+
250
400
  def _set_mode(self, mode: str) -> None:
251
401
  self.mode = mode
252
402
  self.query_one("#member-select", Select).disabled = mode == "preview"
@@ -257,6 +407,83 @@ class CorpusStudio(App[None]):
257
407
  self.query_one("#wiki").display = mode == "view"
258
408
  self.query_one("#editor-panel").display = mode == "editor"
259
409
  self.query_one("#preview-panel").display = mode == "preview"
410
+ self._toggle_controls()
411
+ # Hiding a panel does not reliably release its focused child. Choose a
412
+ # visible, non-destructive entry point for the next screen state.
413
+ if mode == "preview":
414
+ self._focus_id("plan-cancel")
415
+ elif mode == "view":
416
+ self._focus_id("enablement-preview", "library")
417
+
418
+ def _display_items(self) -> list[dict[str, Any]]:
419
+ return [dict(row, enabled=self.toggle_drafts[row["ref"]], enabled_pending=True)
420
+ if row.get("ref") in self.toggle_drafts else row for row in self.items]
421
+
422
+ def _toggle_controls(self) -> None:
423
+ pending = bool(self.toggle_drafts)
424
+ self.query_one("#enablement-bar").display = pending and self.mode == "view"
425
+ self.query_one("#enablement-summary", Static).update(
426
+ f"{len(self.toggle_drafts)} unapplied on/off change(s) · * = pending")
427
+ if self.mode == "view":
428
+ for selector in ("#create", "#reset"):
429
+ self.query_one(selector).disabled = pending
430
+ if pending:
431
+ for selector in ("#edit", "#remove", "#restore", "#recover"):
432
+ self.query_one(selector).disabled = True
433
+
434
+ async def _repaint_enablement(self) -> None:
435
+ displayed = self._display_items()
436
+ by_ref = {row["ref"]: row for row in displayed}
437
+ nodes = [self.query_one("#library", Tree).root]
438
+ while nodes:
439
+ node = nodes.pop()
440
+ if isinstance(node.data, str) and node.data in by_ref:
441
+ node.set_label(library_label(by_ref[node.data], displayed))
442
+ nodes.extend(node.children)
443
+ self._toggle_controls()
444
+ if self.current_ref:
445
+ await self.select_ref(self.current_ref)
446
+
447
+ async def toggle_enabled(self, ref: str) -> None:
448
+ if self.mode != "view":
449
+ return
450
+ row = self._row(ref)
451
+ if row is None or row.get("state") == "removed":
452
+ self.notify("Recover or restore this item before changing its use.", severity="warning")
453
+ return
454
+ if not self.toggle_drafts:
455
+ self.toggle_revision = row["revision"]
456
+ original = self.toggle_original.setdefault(ref, row["enabled"])
457
+ value = not self.toggle_drafts.get(ref, row["enabled"])
458
+ if value == original:
459
+ self.toggle_drafts.pop(ref, None)
460
+ self.toggle_original.pop(ref, None)
461
+ else:
462
+ self.toggle_drafts[ref] = value
463
+ if not self.toggle_drafts:
464
+ self.toggle_revision = None
465
+ await self._repaint_enablement()
466
+
467
+ def _clear_toggle_drafts(self) -> None:
468
+ self.toggle_drafts.clear()
469
+ self.toggle_original.clear()
470
+ self.toggle_revision = None
471
+
472
+ async def _discard_toggles(self) -> None:
473
+ self._clear_toggle_drafts()
474
+ self._toggle_controls()
475
+ await self.refresh_library(self.query_one("#search", Input).value)
476
+ if self.current_ref:
477
+ await self.select_ref(self.current_ref)
478
+ self._focus_id("library")
479
+
480
+ def _preview_toggles(self) -> None:
481
+ if not self.toggle_drafts or self.mode != "view":
482
+ return
483
+ summary = "Use in future sessions (no content is deleted):\n" + "\n".join(
484
+ f"{'ON' if enabled else 'OFF'} {ref}" for ref, enabled in self.toggle_drafts.items())
485
+ self._stage_plan({"operation": "enable", "items": dict(self.toggle_drafts),
486
+ "expected_revision": self.toggle_revision}, summary)
260
487
 
261
488
  async def refresh_library(self, query: str = "") -> None:
262
489
  if self.mode != "view":
@@ -272,20 +499,22 @@ class CorpusStudio(App[None]):
272
499
  self.query_one("#status-line", Static).update(str(exc))
273
500
  return
274
501
  visible = [item for item in self.items if self._matches(item, query)]
502
+ displayed = self._display_items()
503
+ display_by_ref = {item["ref"]: item for item in displayed}
275
504
  groups: dict[tuple[str, str], Any] = {}
276
505
  state_nodes: dict[str, Any] = {}
277
506
  for item in visible:
278
507
  state = str(item.get("state", "active"))
279
508
  package = str(item.get("package_id", "unknown"))
280
509
  if state not in state_nodes:
281
- state_nodes[state] = tree.root.add(state.title())
510
+ state_nodes[state] = tree.root.add(availability_label(state).title())
282
511
  state_nodes[state].expand()
283
512
  key = (state, package)
284
513
  if key not in groups:
285
514
  groups[key] = state_nodes[state].add(package)
286
515
  groups[key].expand()
287
516
  groups[key].add_leaf(
288
- library_label(item, self.items),
517
+ library_label(display_by_ref[item["ref"]], displayed),
289
518
  data=item.get("ref"),
290
519
  )
291
520
  tree.root.expand()
@@ -296,6 +525,7 @@ class CorpusStudio(App[None]):
296
525
  )
297
526
  if visible and (self.current_ref is None or not any(i.get("ref") == self.current_ref for i in visible)):
298
527
  await self.select_ref(str(visible[0]["ref"]))
528
+ self._toggle_controls()
299
529
 
300
530
  @staticmethod
301
531
  def _matches(item: dict[str, Any], query: str) -> bool:
@@ -342,6 +572,7 @@ class CorpusStudio(App[None]):
342
572
  self.query_one("#remove", Button).disabled = row.get("state") == "removed"
343
573
  self.query_one("#restore", Button).disabled = not bool(row.get("baseline_ref"))
344
574
  self.query_one("#recover", Button).disabled = not self._recoverable(row)
575
+ self._toggle_controls()
345
576
 
346
577
  def _select_members(self, item: dict[str, Any] | None, preferred: str | None = None) -> None:
347
578
  selector = self.query_one("#member-select", Select)
@@ -376,7 +607,9 @@ class CorpusStudio(App[None]):
376
607
  + (f" Available members: {members}." if members else "") + "\n"
377
608
  )
378
609
  body = render_member_body(item.get("body", ""), item.get("primary_member"), item.get("kind"))
379
- pointers = guide_pointers(item, self.items)
610
+ displayed = self._display_items()
611
+ display_row = next((entry for entry in displayed if entry.get("ref") == item.get("ref")), row)
612
+ pointers = guide_pointers(item, displayed)
380
613
  guide_links = ""
381
614
  if pointers:
382
615
  lines = []
@@ -386,8 +619,9 @@ class CorpusStudio(App[None]):
386
619
  lines.append(f"- `{pointer['path']}` — {pointer['problem']}")
387
620
  else:
388
621
  destination = quote(target["ref"], safe="@/:")
622
+ use = "ON" if target.get("enabled", True) else "OFF — linked guide disabled"
389
623
  lines.append(f"- [{pointer['path']}](corpus://{destination}) — "
390
- f"**{target['surface']}** · {target.get('state', 'active')}")
624
+ f"**{target['surface']}** · {availability_label(target.get('state', 'active'))} · {use}")
391
625
  guide_links = (
392
626
  "## Guide pointer\n\nThis rule explicitly references the following guide(s). "
393
627
  "The rule keeps its own consumption surface; these links do not change "
@@ -395,8 +629,11 @@ class CorpusStudio(App[None]):
395
629
  )
396
630
  return (
397
631
  f"# {item.get('title', item.get('ref'))}\n\n"
398
- f"`{item.get('ref')}` · **{row.get('state', 'active')}** · "
632
+ f"`{item.get('ref')}` · **{availability_label(row.get('state', 'active'))}** · "
399
633
  f"{item.get('surface')} · {item.get('kind')}\n\n"
634
+ f"Use in future sessions: **{'ON' if display_row.get('enabled', True) else 'OFF'}**"
635
+ + (" (pending; not applied)" if display_row.get("enabled_pending") else " (saved selection)")
636
+ + ". Inclusion is not proof of loading or permission to execute.\n\n"
400
637
  f"{guide_links}{reconciliation}\n{body}\n\n## Dependencies\n\n{links}\n"
401
638
  + ("\n## Native consumption\n\nRequires explicit `agent-launch --corpus-native`.\n"
402
639
  if item.get("kind") == "hook" else "")
@@ -408,6 +645,26 @@ class CorpusStudio(App[None]):
408
645
  if isinstance(event.node.data, str):
409
646
  await self.select_ref(event.node.data)
410
647
 
648
+ async def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
649
+ tree = self.query_one("#library", Tree)
650
+ # Ignore queued highlights superseded by another cursor move or tree rebuild.
651
+ if self.mode != "view" or event.node is not tree.cursor_node:
652
+ return
653
+ if isinstance(event.node.data, str):
654
+ await self.select_ref(event.node.data)
655
+ return
656
+ # A group is not the previously displayed item: never leave its edit/delete
657
+ # actions live while the cursor points at a different kind of node.
658
+ self.current_ref = None
659
+ self.view_item = None
660
+ self._select_members(None)
661
+ for selector in ("#edit", "#remove", "#restore", "#recover"):
662
+ self.query_one(selector, Button).disabled = True
663
+ label = str(event.node.label.plain)
664
+ await self.query_one("#wiki", CorpusMarkdownViewer).document.update(
665
+ f"# Corpus group\n\n{label}\n\nMove the cursor to a corpus item to read it."
666
+ )
667
+
411
668
  async def on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
412
669
  # CorpusMarkdownViewer normally handles this before it bubbles. Keeping
413
670
  # the app handler makes direct Markdown messages safe in tests and future layouts.
@@ -459,6 +716,9 @@ class CorpusStudio(App[None]):
459
716
  def _open_editor(self, item: dict[str, Any] | None) -> None:
460
717
  if self.mode != "view":
461
718
  return
719
+ if self.toggle_drafts:
720
+ self.notify("Apply or discard on/off changes first.", severity="warning")
721
+ return
462
722
  preferred = self.current_member if item and item.get("ref") == self.current_ref else None
463
723
  self.editor_item = item
464
724
  self.editor_ref = str(item["ref"]) if item else None
@@ -577,6 +837,8 @@ class CorpusStudio(App[None]):
577
837
  async def action_request_quit(self) -> None:
578
838
  if self.editor_dirty():
579
839
  self.push_screen(Confirmation("Discard the unsaved draft and exit Studio?"), self._discard_and_exit)
840
+ elif self.toggle_drafts:
841
+ self.push_screen(Confirmation("Discard unapplied on/off changes and exit Studio?"), self._discard_and_exit)
580
842
  else:
581
843
  self.exit()
582
844
 
@@ -637,6 +899,9 @@ class CorpusStudio(App[None]):
637
899
  }, diff + hook_summary
638
900
 
639
901
  def _stage_plan(self, payload: dict[str, Any], summary: str) -> None:
902
+ if self.toggle_drafts and payload.get("operation") != "enable":
903
+ self.notify("Apply or discard on/off changes first.", severity="warning")
904
+ return
640
905
  try:
641
906
  plan = self.store.plan(payload)
642
907
  except Exception as exc:
@@ -665,6 +930,8 @@ class CorpusStudio(App[None]):
665
930
  self.notify(str(exc), severity="error")
666
931
  return
667
932
  ref = result.get("details", {}).get("ref")
933
+ if result.get("details", {}).get("operation") == "enable":
934
+ self._clear_toggle_drafts()
668
935
  self.pending_plan = None
669
936
  self._set_mode("view")
670
937
  await self.refresh_library(self.query_one("#search", Input).value)
@@ -674,7 +941,11 @@ class CorpusStudio(App[None]):
674
941
 
675
942
  async def on_button_pressed(self, event: Button.Pressed) -> None:
676
943
  button = event.button.id
677
- if button == "create":
944
+ if button == "enablement-preview":
945
+ self._preview_toggles()
946
+ elif button == "enablement-discard":
947
+ await self._discard_toggles()
948
+ elif button == "create":
678
949
  self.action_create()
679
950
  elif button == "edit":
680
951
  self.action_edit()