agent-bios 0.19.1 → 0.19.2
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.
- package/DEPENDENCIES.md +27 -27
- package/INSTALL.md +4 -4
- package/README.md +53 -26
- package/claude/CLAUDE.md +1 -1
- package/claude/guides/claude-prompting.md +1 -1
- package/claude/guides/cli-multi-model-workflow.md +3 -3
- package/claude/guides/documentation-hygiene.md +3 -0
- package/claude/guides/gpt-prompting.md +1 -1
- package/claude/guides/korean-writing.md +153 -0
- package/claude/guides/learning-flow.md +4 -4
- package/claude/guides/session-distill-workflow.md +8 -8
- package/claude/guides/slide-writing/RUNBOOK.md +5 -5
- package/claude/skills/repo-charter/SKILL.md +3 -3
- package/claude/skills/understand/SKILL.md +5 -5
- package/codex/AGENTS.md +1 -1
- package/codex/guides/claude-prompting.md +1 -1
- package/codex/guides/cli-multi-model-workflow.md +3 -3
- package/codex/guides/documentation-hygiene.md +3 -0
- package/codex/guides/gpt-prompting.md +1 -1
- package/codex/guides/korean-writing.md +153 -0
- package/codex/guides/learning-flow.md +4 -4
- package/codex/guides/session-distill-workflow.md +8 -8
- package/codex/guides/slide-writing/RUNBOOK.md +5 -5
- package/compose/app_bridge/SKILL.md +12 -12
- package/compose/app_bridge/scripts/bridge.py +15 -7
- package/compose/assemble.py +5 -5
- package/compose/bootstrap/SKILL.md +18 -18
- package/compose/canary.sh +4 -4
- package/compose/check-domains.py +6 -6
- package/compose/corpus-state.py +16 -1168
- package/compose/corpus.py +13 -402
- package/compose/corpus_app.py +14 -450
- package/compose/corpus_catalog.py +15 -926
- package/compose/corpus_import.py +14 -523
- package/compose/corpus_install.py +14 -1847
- package/compose/corpus_session.py +16 -848
- package/compose/corpus_setup.py +16 -672
- package/compose/corpus_setup_cli.py +15 -580
- package/compose/corpus_setup_i18n.py +20 -324
- package/compose/corpus_setup_ui.py +18 -645
- package/compose/corpus_store.py +16 -1664
- package/compose/corpus_transaction.py +15 -284
- package/compose/corpus_ui.py +17 -972
- package/compose/corpus_ui_runtime.py +16 -274
- package/compose/corpus_understand.py +13 -671
- package/compose/domains.json +2 -1
- package/compose/instructions-state.py +1175 -0
- package/compose/instructions.py +409 -0
- package/compose/instructions_app.py +464 -0
- package/compose/instructions_catalog.py +931 -0
- package/compose/instructions_import.py +529 -0
- package/compose/instructions_install.py +1866 -0
- package/compose/instructions_session.py +852 -0
- package/compose/instructions_setup.py +676 -0
- package/compose/instructions_setup_cli.py +586 -0
- package/compose/instructions_setup_i18n.py +324 -0
- package/compose/instructions_setup_ui.py +647 -0
- package/compose/instructions_store.py +1668 -0
- package/compose/instructions_transaction.py +306 -0
- package/compose/instructions_ui.py +975 -0
- package/compose/instructions_ui_runtime.py +278 -0
- package/compose/instructions_understand.py +678 -0
- package/compose/register-hooks.py +1 -1
- package/compose/setup/START.md +11 -11
- package/docs/advanced-launch.md +11 -11
- package/docs/instructions-compatibility.md +86 -0
- package/docs/{corpus.md → instructions.md} +35 -8
- package/docs/recovery.md +10 -10
- package/docs/releases/0.19.2.md +38 -0
- package/docs/session-model.md +23 -20
- package/docs/setup.md +27 -26
- package/docs/understand.md +6 -6
- package/install.sh +70 -69
- package/launch/agent-launch.py +298 -290
- package/launch/agent-launch.toml +2 -2
- package/launch/i18n/en.toml +55 -55
- package/launch/i18n/ja.toml +56 -56
- package/launch/i18n/ko.toml +56 -56
- package/launch/shell_integration.py +4 -4
- package/learn/collect-learning.py +10 -10
- package/learn/learning.schema.json +1 -1
- package/learn/migrate-learnings.py +51 -51
- package/package.json +27 -11
- package/provenance.json +1 -1
- /package/docs/assets/{corpus-studio.svg → instructions-studio.svg} +0 -0
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Textual Instructions Studio.
|
|
3
|
+
|
|
4
|
+
Imported only for an interactive launch. The module contains no persistence
|
|
5
|
+
logic: every read, plan, and apply goes through the supplied ``InstructionsStore``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote, unquote
|
|
15
|
+
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from textual.app import App, ComposeResult
|
|
19
|
+
from textual.binding import Binding
|
|
20
|
+
from textual.containers import Horizontal, Vertical
|
|
21
|
+
from textual.events import Key
|
|
22
|
+
from textual.screen import ModalScreen
|
|
23
|
+
from textual.widgets import (
|
|
24
|
+
Button,
|
|
25
|
+
Footer,
|
|
26
|
+
Header,
|
|
27
|
+
Input,
|
|
28
|
+
Label,
|
|
29
|
+
Markdown,
|
|
30
|
+
MarkdownViewer,
|
|
31
|
+
Select,
|
|
32
|
+
Static,
|
|
33
|
+
TextArea,
|
|
34
|
+
Tree,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from instructions_catalog import HOOK_EVENTS
|
|
39
|
+
except ImportError: # pragma: no cover - package import from repository root
|
|
40
|
+
from .instructions_catalog import HOOK_EVENTS
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SURFACES = ("always", "relevant", "requested", "event", "delegated")
|
|
44
|
+
VIEWS = ("effective", "installed", "change", "diff", "history")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def availability_label(state: str) -> str:
|
|
48
|
+
return "available" if state == "active" else state
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def guide_pointers(item: dict[str, Any], inventory: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
52
|
+
"""Display literal guide references, not inferred dependencies or new routing."""
|
|
53
|
+
if item.get("kind") != "rule":
|
|
54
|
+
return []
|
|
55
|
+
paths = dict.fromkeys(re.findall(r"\bguides/[A-Za-z0-9][A-Za-z0-9_./-]*\.md\b", item.get("body", "")))
|
|
56
|
+
result = []
|
|
57
|
+
for path in paths:
|
|
58
|
+
if ".." in PurePosixPath(path).parts:
|
|
59
|
+
continue
|
|
60
|
+
matches = [candidate for candidate in inventory
|
|
61
|
+
if candidate.get("package_id") == item.get("package_id")
|
|
62
|
+
and candidate.get("kind") == "guide" and path in candidate.get("members", {})]
|
|
63
|
+
result.append({"path": path, "target": matches[0] if len(matches) == 1 else None,
|
|
64
|
+
"problem": "Ambiguous guide reference" if matches else "Not found in this package"})
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def library_label(item: dict[str, Any], inventory: list[dict[str, Any]]) -> Text:
|
|
69
|
+
pointers = guide_pointers(item, inventory)
|
|
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 " "))
|
|
73
|
+
if pointers:
|
|
74
|
+
label.append("→ GUIDE ", style="bold cyan")
|
|
75
|
+
label.append(", ".join(PurePosixPath(pointer["path"]).stem for pointer in pointers))
|
|
76
|
+
label.append(" · ")
|
|
77
|
+
label.append(f"{item.get('title', item.get('ref'))} · {item.get('surface', '?')}")
|
|
78
|
+
return label
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_member_body(body: str, member: str | None, kind: str | None = None) -> str:
|
|
82
|
+
"""Render prose as Markdown and preserve source files as literal code."""
|
|
83
|
+
suffix = PurePosixPath(member).suffix.lower() if member else ""
|
|
84
|
+
if suffix in {".md", ".markdown"} or (not member and kind != "hook"):
|
|
85
|
+
return body
|
|
86
|
+
language = {".py": "python", ".json": "json", ".toml": "toml",
|
|
87
|
+
".sh": "bash", ".zsh": "bash", ".yaml": "yaml", ".yml": "yaml",
|
|
88
|
+
".js": "javascript", ".ts": "typescript"}.get(suffix, "")
|
|
89
|
+
fence = "`" * max(3, 1 + max((len(run) for run in re.findall(r"`+", body)), default=0))
|
|
90
|
+
return f"{fence}{language}\n{body}\n{fence}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Confirmation(ModalScreen[bool]):
|
|
94
|
+
"""One bounded confirmation; the caller decides what the answer means."""
|
|
95
|
+
|
|
96
|
+
DEFAULT_CSS = """
|
|
97
|
+
Confirmation { align: center middle; }
|
|
98
|
+
Confirmation > Vertical { width: 62; height: auto; border: round $warning;
|
|
99
|
+
padding: 1 2; background: $surface; }
|
|
100
|
+
Confirmation Horizontal { height: auto; align-horizontal: right; }
|
|
101
|
+
Confirmation Button { margin-left: 1; }
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, message: str) -> None:
|
|
105
|
+
super().__init__()
|
|
106
|
+
self.message = message
|
|
107
|
+
|
|
108
|
+
def compose(self) -> ComposeResult:
|
|
109
|
+
with Vertical():
|
|
110
|
+
yield Label(self.message, id="confirm-message")
|
|
111
|
+
with Horizontal():
|
|
112
|
+
yield Button("Keep editing", id="confirm-no")
|
|
113
|
+
yield Button("Discard", id="confirm-yes", variant="warning")
|
|
114
|
+
|
|
115
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
116
|
+
self.dismiss(event.button.id == "confirm-yes")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class InstructionsMarkdownViewer(MarkdownViewer):
|
|
120
|
+
"""Keep all links inside Studio; never hand a URL to the operating system."""
|
|
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
|
+
|
|
147
|
+
async def _on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
|
|
148
|
+
# stop() prevents bubbling, not the inherited MarkdownViewer handler.
|
|
149
|
+
# Its default go() would try to load instructions:// as a filesystem path.
|
|
150
|
+
message.prevent_default()
|
|
151
|
+
message.stop()
|
|
152
|
+
if message.href.startswith("instructions://"):
|
|
153
|
+
ref = unquote(message.href.removeprefix("instructions://"))
|
|
154
|
+
await self.app.select_ref(ref) # type: ignore[attr-defined]
|
|
155
|
+
else:
|
|
156
|
+
self.app.notify("External links are disabled in Instructions Studio.", severity="warning")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class InstructionsSearch(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 InstructionsTree(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
|
+
|
|
210
|
+
class InstructionsStudio(App[None]):
|
|
211
|
+
"""Searchable instructions library, editor, and revision-bound Preview → Apply flow."""
|
|
212
|
+
|
|
213
|
+
TITLE = "Instructions Studio"
|
|
214
|
+
SUB_TITLE = "Private authoring for future activated sessions"
|
|
215
|
+
BINDINGS = [
|
|
216
|
+
Binding("ctrl+f", "focus_search", "Search"),
|
|
217
|
+
Binding("ctrl+l", "focus_library", "Library"),
|
|
218
|
+
Binding("v", "cycle_view", "View"),
|
|
219
|
+
Binding("c", "create", "Create"),
|
|
220
|
+
Binding("e", "edit", "Edit"),
|
|
221
|
+
Binding("delete", "remove", "Remove"),
|
|
222
|
+
Binding("r", "restore", "Restore"),
|
|
223
|
+
Binding("shift+r", "recover", "Recover"),
|
|
224
|
+
Binding("f2", "focus_surface", "Surface"),
|
|
225
|
+
Binding("escape", "cancel", "Back"),
|
|
226
|
+
Binding("q", "request_quit", "Quit"),
|
|
227
|
+
]
|
|
228
|
+
CSS = """
|
|
229
|
+
Screen { layout: vertical; }
|
|
230
|
+
#toolbar { height: 6; padding: 0 1; }
|
|
231
|
+
#search-row, #action-row { height: 3; }
|
|
232
|
+
#search { width: 1fr; }
|
|
233
|
+
#view-select { width: 18; margin-left: 1; }
|
|
234
|
+
#action-row Button { width: 1fr; min-width: 8; }
|
|
235
|
+
#workspace { height: 1fr; }
|
|
236
|
+
#library { width: 34; min-width: 24; border-right: solid $primary; }
|
|
237
|
+
#detail { width: 1fr; padding: 0 1; }
|
|
238
|
+
#member-select { display: none; height: 3; }
|
|
239
|
+
#wiki { height: 1fr; }
|
|
240
|
+
#editor-panel, #preview-panel { display: none; height: 1fr; }
|
|
241
|
+
#editor-title { height: 3; }
|
|
242
|
+
#editor-surface { height: 3; width: 32; }
|
|
243
|
+
#primary-member-row { display: none; height: 3; }
|
|
244
|
+
#primary-member-label { width: 20; }
|
|
245
|
+
#editor-primary-member { width: 1fr; }
|
|
246
|
+
#hook-binding-row { display: none; height: 3; }
|
|
247
|
+
#editor-hook-event { width: 26; }
|
|
248
|
+
#editor-hook-matcher { width: 1fr; margin-left: 1; }
|
|
249
|
+
#editor-body { height: 1fr; border: round $primary; }
|
|
250
|
+
#preview-text { height: 1fr; overflow: auto; border: round $warning;
|
|
251
|
+
padding: 1; }
|
|
252
|
+
.actions { height: 3; align-horizontal: right; }
|
|
253
|
+
.actions Button { margin-left: 1; }
|
|
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; }
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(self, store: Any) -> None:
|
|
261
|
+
super().__init__()
|
|
262
|
+
self.store = store
|
|
263
|
+
self.items: list[dict[str, Any]] = []
|
|
264
|
+
self.current_ref: str | None = None
|
|
265
|
+
self.editor_ref: str | None = None
|
|
266
|
+
self.editor_item: dict[str, Any] | None = None
|
|
267
|
+
self.editor_initial = ("", "", "requested")
|
|
268
|
+
self.current_member: str | None = None
|
|
269
|
+
self.view_item: dict[str, Any] | None = None
|
|
270
|
+
self.member_drafts: dict[str, str] = {}
|
|
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
|
|
275
|
+
self.mode = "view"
|
|
276
|
+
|
|
277
|
+
def compose(self) -> ComposeResult:
|
|
278
|
+
yield Header()
|
|
279
|
+
with Vertical(id="toolbar"):
|
|
280
|
+
with Horizontal(id="search-row"):
|
|
281
|
+
yield InstructionsSearch(placeholder="Search title, content, ref, package, domain, or state (↓ controls)", id="search")
|
|
282
|
+
yield Select([(view.title(), view) for view in VIEWS], value="effective",
|
|
283
|
+
allow_blank=False, id="view-select")
|
|
284
|
+
with Horizontal(id="action-row"):
|
|
285
|
+
yield Button("Create", id="create", compact=True)
|
|
286
|
+
yield Button("Edit", id="edit", compact=True)
|
|
287
|
+
yield Button("Remove", id="remove", variant="warning", compact=True)
|
|
288
|
+
yield Button("Restore", id="restore", compact=True)
|
|
289
|
+
yield Button("Recover", id="recover", compact=True)
|
|
290
|
+
yield Button("Reset", id="reset", variant="error", compact=True)
|
|
291
|
+
with Horizontal(id="workspace"):
|
|
292
|
+
yield InstructionsTree("Instructions", id="library")
|
|
293
|
+
with Vertical(id="detail"):
|
|
294
|
+
yield Select([], prompt="Bundle document", allow_blank=True, id="member-select")
|
|
295
|
+
yield InstructionsMarkdownViewer(
|
|
296
|
+
"# Instructions Studio\n\nSelect an item from the library.",
|
|
297
|
+
show_table_of_contents=False,
|
|
298
|
+
open_links=False,
|
|
299
|
+
id="wiki",
|
|
300
|
+
)
|
|
301
|
+
with Vertical(id="editor-panel"):
|
|
302
|
+
yield Input(placeholder="Title", id="editor-title")
|
|
303
|
+
yield Select(
|
|
304
|
+
[
|
|
305
|
+
("Always in activated sessions", "always"),
|
|
306
|
+
("When relevant", "relevant"),
|
|
307
|
+
("When requested", "requested"),
|
|
308
|
+
("On a matching event (adapter verification required)", "event"),
|
|
309
|
+
("When delegated (adapter verification required)", "delegated"),
|
|
310
|
+
],
|
|
311
|
+
value="requested", allow_blank=False, id="editor-surface",
|
|
312
|
+
)
|
|
313
|
+
with Horizontal(id="primary-member-row"):
|
|
314
|
+
yield Label("Reconcile primary", id="primary-member-label")
|
|
315
|
+
yield Select([], prompt="Choose member", allow_blank=True,
|
|
316
|
+
id="editor-primary-member")
|
|
317
|
+
with Horizontal(id="hook-binding-row"):
|
|
318
|
+
yield Select(
|
|
319
|
+
[(event, event) for event in sorted(HOOK_EVENTS)],
|
|
320
|
+
value=sorted(HOOK_EVENTS)[0], allow_blank=False, id="editor-hook-event",
|
|
321
|
+
)
|
|
322
|
+
yield Input(placeholder="Hook matcher", id="editor-hook-matcher")
|
|
323
|
+
yield TextArea(language="markdown", id="editor-body")
|
|
324
|
+
with Horizontal(classes="actions"):
|
|
325
|
+
yield Button("Cancel", id="editor-cancel")
|
|
326
|
+
yield Button("Preview", id="preview", variant="primary")
|
|
327
|
+
with Vertical(id="preview-panel"):
|
|
328
|
+
yield Static("", id="preview-text", markup=False)
|
|
329
|
+
with Horizontal(classes="actions"):
|
|
330
|
+
yield Button("Cancel plan", id="plan-cancel")
|
|
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")
|
|
336
|
+
yield Static("Stored privately; no host session is activated by this screen.", id="status-line")
|
|
337
|
+
yield Footer()
|
|
338
|
+
|
|
339
|
+
async def on_mount(self) -> None:
|
|
340
|
+
await self.refresh_library()
|
|
341
|
+
self.query_one("#search", Input).focus()
|
|
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
|
+
|
|
400
|
+
def _set_mode(self, mode: str) -> None:
|
|
401
|
+
self.mode = mode
|
|
402
|
+
self.query_one("#member-select", Select).disabled = mode == "preview"
|
|
403
|
+
# Read navigation cannot change the target of an open draft or prepared plan.
|
|
404
|
+
for selector in ("#search", "#view-select", "#library", "#create", "#edit",
|
|
405
|
+
"#remove", "#restore", "#recover", "#reset"):
|
|
406
|
+
self.query_one(selector).disabled = mode != "view"
|
|
407
|
+
self.query_one("#wiki").display = mode == "view"
|
|
408
|
+
self.query_one("#editor-panel").display = mode == "editor"
|
|
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)
|
|
487
|
+
|
|
488
|
+
async def refresh_library(self, query: str = "") -> None:
|
|
489
|
+
if self.mode != "view":
|
|
490
|
+
return
|
|
491
|
+
tree = self.query_one("#library", Tree)
|
|
492
|
+
tree.reset("Instructions")
|
|
493
|
+
try:
|
|
494
|
+
self.items = self.store.list_items(include_removed=True)
|
|
495
|
+
except Exception as exc:
|
|
496
|
+
self.items = []
|
|
497
|
+
tree.root.add_leaf(f"Unavailable: {exc}")
|
|
498
|
+
tree.root.expand()
|
|
499
|
+
self.query_one("#status-line", Static).update(str(exc))
|
|
500
|
+
return
|
|
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}
|
|
504
|
+
groups: dict[tuple[str, str], Any] = {}
|
|
505
|
+
state_nodes: dict[str, Any] = {}
|
|
506
|
+
for item in visible:
|
|
507
|
+
state = str(item.get("state", "active"))
|
|
508
|
+
package = str(item.get("package_id", "unknown"))
|
|
509
|
+
if state not in state_nodes:
|
|
510
|
+
state_nodes[state] = tree.root.add(availability_label(state).title())
|
|
511
|
+
state_nodes[state].expand()
|
|
512
|
+
key = (state, package)
|
|
513
|
+
if key not in groups:
|
|
514
|
+
groups[key] = state_nodes[state].add(package)
|
|
515
|
+
groups[key].expand()
|
|
516
|
+
groups[key].add_leaf(
|
|
517
|
+
library_label(display_by_ref[item["ref"]], displayed),
|
|
518
|
+
data=item.get("ref"),
|
|
519
|
+
)
|
|
520
|
+
tree.root.expand()
|
|
521
|
+
status = self.store.status()
|
|
522
|
+
self.query_one("#status-line", Static).update(
|
|
523
|
+
f"{len(visible)}/{len(self.items)} items · authoring revision {status.get('revision', '?')[:12]} · "
|
|
524
|
+
"changes affect future activated sessions only"
|
|
525
|
+
)
|
|
526
|
+
if visible and (self.current_ref is None or not any(i.get("ref") == self.current_ref for i in visible)):
|
|
527
|
+
await self.select_ref(str(visible[0]["ref"]))
|
|
528
|
+
self._toggle_controls()
|
|
529
|
+
|
|
530
|
+
@staticmethod
|
|
531
|
+
def _matches(item: dict[str, Any], query: str) -> bool:
|
|
532
|
+
if not query:
|
|
533
|
+
return True
|
|
534
|
+
fields = [item.get(key, "") for key in (
|
|
535
|
+
"ref", "title", "body", "surface", "tier", "kind", "state", "package_id"
|
|
536
|
+
)]
|
|
537
|
+
fields.extend(item.get("domains", []))
|
|
538
|
+
return query.casefold() in "\n".join(str(value) for value in fields).casefold()
|
|
539
|
+
|
|
540
|
+
def _row(self, ref: str | None = None) -> dict[str, Any] | None:
|
|
541
|
+
wanted = ref or self.current_ref
|
|
542
|
+
return next((item for item in self.items if item.get("ref") == wanted), None)
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def _recoverable(row: dict[str, Any]) -> bool:
|
|
546
|
+
return row.get("state") == "removed" and (
|
|
547
|
+
row.get("package_id") == "@local/personal"
|
|
548
|
+
or row.get("learning_source") is True
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
async def select_ref(self, ref: str) -> None:
|
|
552
|
+
if self.mode != "view":
|
|
553
|
+
return
|
|
554
|
+
row = self._row(ref)
|
|
555
|
+
if row is None:
|
|
556
|
+
self.notify(f"Unknown InstructionsRef: {ref}", severity="error")
|
|
557
|
+
return
|
|
558
|
+
self.current_ref = ref
|
|
559
|
+
self.current_member = None
|
|
560
|
+
view_value = self.query_one("#view-select", Select).value
|
|
561
|
+
view = view_value if isinstance(view_value, str) else "effective"
|
|
562
|
+
try:
|
|
563
|
+
result = self.store.show(ref, view=view)
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
self.notify(str(exc), severity="error")
|
|
566
|
+
return
|
|
567
|
+
self.view_item = result.get("item") if view == "effective" else None
|
|
568
|
+
self._select_members(self.view_item)
|
|
569
|
+
markdown = self._render_item(row, result, view)
|
|
570
|
+
await self.query_one("#wiki", InstructionsMarkdownViewer).document.update(markdown)
|
|
571
|
+
self.query_one("#edit", Button).disabled = row.get("state") == "removed"
|
|
572
|
+
self.query_one("#remove", Button).disabled = row.get("state") == "removed"
|
|
573
|
+
self.query_one("#restore", Button).disabled = not bool(row.get("baseline_ref"))
|
|
574
|
+
self.query_one("#recover", Button).disabled = not self._recoverable(row)
|
|
575
|
+
self._toggle_controls()
|
|
576
|
+
|
|
577
|
+
def _select_members(self, item: dict[str, Any] | None, preferred: str | None = None) -> None:
|
|
578
|
+
selector = self.query_one("#member-select", Select)
|
|
579
|
+
members = item.get("members", {}) if item else {}
|
|
580
|
+
primary = item.get("primary_member") if item else None
|
|
581
|
+
usable = isinstance(members, dict) and primary in members
|
|
582
|
+
self.current_member = preferred if usable and preferred in members else primary if usable else None
|
|
583
|
+
selector.set_options([(name + (" (primary)" if name == primary else ""), name)
|
|
584
|
+
for name in members] if usable else [])
|
|
585
|
+
selector.value = self.current_member if self.current_member is not None else Select.NULL
|
|
586
|
+
selector.styles.display = "block" if usable and len(members) > 1 else "none"
|
|
587
|
+
|
|
588
|
+
def _editor_members(self) -> dict[str, str]:
|
|
589
|
+
members = dict(self.member_drafts)
|
|
590
|
+
if self.current_member in members:
|
|
591
|
+
members[self.current_member] = self.query_one("#editor-body", TextArea).text
|
|
592
|
+
return members
|
|
593
|
+
|
|
594
|
+
def _render_item(self, row: dict[str, Any], result: dict[str, Any], view: str) -> str:
|
|
595
|
+
if view == "effective" and isinstance(result.get("item"), dict):
|
|
596
|
+
item = result["item"]
|
|
597
|
+
links = "\n".join(
|
|
598
|
+
f"- [{ref}](instructions://{ref})" for ref in item.get("dependencies", [])
|
|
599
|
+
) or "None"
|
|
600
|
+
conflict = item.get("content_conflict")
|
|
601
|
+
reconciliation = ""
|
|
602
|
+
if isinstance(conflict, dict):
|
|
603
|
+
members = ", ".join(str(path) for path in conflict.get("members", []))
|
|
604
|
+
reconciliation = (
|
|
605
|
+
"\n> ⚠ Legacy content needs reconciliation before activation. "
|
|
606
|
+
"Edit the body to the intended member text, or choose a primary member through the API."
|
|
607
|
+
+ (f" Available members: {members}." if members else "") + "\n"
|
|
608
|
+
)
|
|
609
|
+
body = render_member_body(item.get("body", ""), item.get("primary_member"), item.get("kind"))
|
|
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)
|
|
613
|
+
guide_links = ""
|
|
614
|
+
if pointers:
|
|
615
|
+
lines = []
|
|
616
|
+
for pointer in pointers:
|
|
617
|
+
target = pointer["target"]
|
|
618
|
+
if target is None:
|
|
619
|
+
lines.append(f"- `{pointer['path']}` — {pointer['problem']}")
|
|
620
|
+
else:
|
|
621
|
+
destination = quote(target["ref"], safe="@/:")
|
|
622
|
+
use = "ON" if target.get("enabled", True) else "OFF — linked guide disabled"
|
|
623
|
+
lines.append(f"- [{pointer['path']}](instructions://{destination}) — "
|
|
624
|
+
f"**{target['surface']}** · {availability_label(target.get('state', 'active'))} · {use}")
|
|
625
|
+
guide_links = (
|
|
626
|
+
"## Guide pointer\n\nThis rule explicitly references the following guide(s). "
|
|
627
|
+
"The rule keeps its own consumption surface; these links do not change "
|
|
628
|
+
"delivery or prove that a guide was loaded.\n\n" + "\n".join(lines) + "\n\n## Rule\n\n"
|
|
629
|
+
)
|
|
630
|
+
return (
|
|
631
|
+
f"# {item.get('title', item.get('ref'))}\n\n"
|
|
632
|
+
f"`{item.get('ref')}` · **{availability_label(row.get('state', 'active'))}** · "
|
|
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"
|
|
637
|
+
f"{guide_links}{reconciliation}\n{body}\n\n## Dependencies\n\n{links}\n"
|
|
638
|
+
+ ("\n## Native consumption\n\nRequires explicit `agent-launch --instructions-native`.\n"
|
|
639
|
+
if item.get("kind") == "hook" else "")
|
|
640
|
+
)
|
|
641
|
+
payload = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)
|
|
642
|
+
return f"# {row.get('title', row.get('ref'))}\n\n## {view.title()}\n\n```json\n{payload}\n```\n"
|
|
643
|
+
|
|
644
|
+
async def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
645
|
+
if isinstance(event.node.data, str):
|
|
646
|
+
await self.select_ref(event.node.data)
|
|
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", InstructionsMarkdownViewer).document.update(
|
|
665
|
+
f"# Instructions group\n\n{label}\n\nMove the cursor to an instruction item to read it."
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
async def on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
|
|
669
|
+
# InstructionsMarkdownViewer normally handles this before it bubbles. Keeping
|
|
670
|
+
# the app handler makes direct Markdown messages safe in tests and future layouts.
|
|
671
|
+
if message.href.startswith("instructions://"):
|
|
672
|
+
message.stop()
|
|
673
|
+
await self.select_ref(unquote(message.href.removeprefix("instructions://")))
|
|
674
|
+
|
|
675
|
+
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
676
|
+
if event.input.id == "search":
|
|
677
|
+
await self.refresh_library(event.value)
|
|
678
|
+
|
|
679
|
+
async def on_select_changed(self, event: Select.Changed) -> None:
|
|
680
|
+
if event.select.id == "view-select" and self.mode == "view" and isinstance(event.value, str) and self.current_ref:
|
|
681
|
+
await self.select_ref(self.current_ref)
|
|
682
|
+
elif event.select.id == "member-select" and isinstance(event.value, str):
|
|
683
|
+
item = self.editor_item if self.mode == "editor" else self.view_item
|
|
684
|
+
if not item or event.value not in item.get("members", {}) or event.value == self.current_member:
|
|
685
|
+
return
|
|
686
|
+
if self.mode == "editor":
|
|
687
|
+
self.member_drafts = self._editor_members()
|
|
688
|
+
self.current_member = event.value
|
|
689
|
+
self.query_one("#editor-body", TextArea).text = self.member_drafts[event.value]
|
|
690
|
+
elif self.mode == "view":
|
|
691
|
+
self.current_member = event.value
|
|
692
|
+
primary = item.get("primary_member")
|
|
693
|
+
body = item["members"][event.value]
|
|
694
|
+
if event.value == primary:
|
|
695
|
+
markdown = self._render_item(self._row() or item, {"item": item}, "effective")
|
|
696
|
+
else:
|
|
697
|
+
markdown = f"# {event.value}\n\n{render_member_body(body, event.value)}\n"
|
|
698
|
+
await self.query_one("#wiki", InstructionsMarkdownViewer).document.update(markdown)
|
|
699
|
+
|
|
700
|
+
def editor_dirty(self) -> bool:
|
|
701
|
+
if self.mode != "editor":
|
|
702
|
+
return False
|
|
703
|
+
current = (
|
|
704
|
+
self.query_one("#editor-title", Input).value,
|
|
705
|
+
self._editor_members().get(self.editor_item.get("primary_member"), self.query_one("#editor-body", TextArea).text)
|
|
706
|
+
if self.editor_item else self.query_one("#editor-body", TextArea).text,
|
|
707
|
+
self.query_one("#editor-surface", Select).value,
|
|
708
|
+
self.query_one("#editor-primary-member", Select).value
|
|
709
|
+
if self.editor_item and isinstance(self.editor_item.get("content_conflict"), dict) else "",
|
|
710
|
+
self.query_one("#editor-hook-event", Select).value if self.editor_item and self.editor_item.get("kind") == "hook" else "",
|
|
711
|
+
self.query_one("#editor-hook-matcher", Input).value if self.editor_item and self.editor_item.get("kind") == "hook" else "",
|
|
712
|
+
)
|
|
713
|
+
return current != self.editor_initial or bool(self.editor_item and self.member_drafts
|
|
714
|
+
and self._editor_members() != self.editor_item.get("members", {}))
|
|
715
|
+
|
|
716
|
+
def _open_editor(self, item: dict[str, Any] | None) -> None:
|
|
717
|
+
if self.mode != "view":
|
|
718
|
+
return
|
|
719
|
+
if self.toggle_drafts:
|
|
720
|
+
self.notify("Apply or discard on/off changes first.", severity="warning")
|
|
721
|
+
return
|
|
722
|
+
preferred = self.current_member if item and item.get("ref") == self.current_ref else None
|
|
723
|
+
self.editor_item = item
|
|
724
|
+
self.editor_ref = str(item["ref"]) if item else None
|
|
725
|
+
title = str(item.get("title", "")) if item else ""
|
|
726
|
+
body = str(item.get("body", "")) if item else ""
|
|
727
|
+
surface = str(item.get("surface", "requested")) if item else "requested"
|
|
728
|
+
hook = item.get("hook") if item else None
|
|
729
|
+
is_hook = bool(item and item.get("kind") == "hook")
|
|
730
|
+
conflict = item.get("content_conflict") if item else None
|
|
731
|
+
self.member_drafts = dict(item.get("members", {})) if item and not conflict else {}
|
|
732
|
+
self._select_members(item if not conflict else None, preferred)
|
|
733
|
+
conflict_members = conflict.get("members", []) if isinstance(conflict, dict) else []
|
|
734
|
+
primary_select = self.query_one("#editor-primary-member", Select)
|
|
735
|
+
primary_select.set_options([(str(path), str(path)) for path in conflict_members])
|
|
736
|
+
primary_select.clear()
|
|
737
|
+
self.query_one("#primary-member-row").styles.display = "block" if conflict_members else "none"
|
|
738
|
+
self.query_one("#editor-title", Input).value = title
|
|
739
|
+
self.query_one("#editor-body", TextArea).text = self.member_drafts.get(self.current_member, body)
|
|
740
|
+
self.query_one("#editor-surface", Select).value = surface
|
|
741
|
+
self.query_one("#hook-binding-row").styles.display = "block" if is_hook else "none"
|
|
742
|
+
if is_hook:
|
|
743
|
+
event = hook.get("event") if isinstance(hook, dict) else sorted(HOOK_EVENTS)[0]
|
|
744
|
+
matcher = hook.get("matcher") if isinstance(hook, dict) else ""
|
|
745
|
+
self.query_one("#editor-hook-event", Select).value = event
|
|
746
|
+
self.query_one("#editor-hook-matcher", Input).value = matcher
|
|
747
|
+
else:
|
|
748
|
+
self.query_one("#editor-hook-matcher", Input).value = ""
|
|
749
|
+
self.editor_initial = (title, body, surface, primary_select.value if conflict_members else "",
|
|
750
|
+
event if is_hook else "", matcher if is_hook else "")
|
|
751
|
+
self._set_mode("editor")
|
|
752
|
+
self.query_one("#editor-title", Input).focus()
|
|
753
|
+
|
|
754
|
+
def action_create(self) -> None:
|
|
755
|
+
self._open_editor(None)
|
|
756
|
+
|
|
757
|
+
def action_edit(self) -> None:
|
|
758
|
+
row = self._row()
|
|
759
|
+
if row is None or row.get("state") == "removed":
|
|
760
|
+
self.notify("Select an active item first.", severity="warning")
|
|
761
|
+
return
|
|
762
|
+
self._open_editor(row)
|
|
763
|
+
|
|
764
|
+
def action_focus_search(self) -> None:
|
|
765
|
+
self.query_one("#search", Input).focus()
|
|
766
|
+
|
|
767
|
+
def action_focus_library(self) -> None:
|
|
768
|
+
self.query_one("#library", Tree).focus()
|
|
769
|
+
|
|
770
|
+
def action_focus_surface(self) -> None:
|
|
771
|
+
if self.mode == "editor":
|
|
772
|
+
self.query_one("#editor-surface", Select).focus()
|
|
773
|
+
|
|
774
|
+
def action_cycle_view(self) -> None:
|
|
775
|
+
view = self.query_one("#view-select", Select)
|
|
776
|
+
current = view.value if isinstance(view.value, str) else VIEWS[0]
|
|
777
|
+
view.value = VIEWS[(VIEWS.index(current) + 1) % len(VIEWS)]
|
|
778
|
+
|
|
779
|
+
def _stage_current_operation(self, operation: str) -> None:
|
|
780
|
+
if self.mode != "view":
|
|
781
|
+
return
|
|
782
|
+
row = self._row()
|
|
783
|
+
if row is None:
|
|
784
|
+
self.notify("Select an item first.", severity="warning")
|
|
785
|
+
return
|
|
786
|
+
if operation == "remove" and row.get("state") == "removed":
|
|
787
|
+
self.notify("The selected item is already removed.", severity="warning")
|
|
788
|
+
return
|
|
789
|
+
if operation == "restore" and not row.get("baseline_ref"):
|
|
790
|
+
self.notify("Restore is available only for an installed item.", severity="warning")
|
|
791
|
+
return
|
|
792
|
+
if operation == "recover" and not self._recoverable(row):
|
|
793
|
+
self.notify(
|
|
794
|
+
"Recover is available only for a removed personal item or learning.",
|
|
795
|
+
severity="warning",
|
|
796
|
+
)
|
|
797
|
+
return
|
|
798
|
+
payload: dict[str, Any] = {"operation": operation, "ref": row["ref"]}
|
|
799
|
+
if operation == "remove":
|
|
800
|
+
payload["item_digest"] = row.get("digest")
|
|
801
|
+
self._stage_plan(payload, f"{operation.title()} {row['ref']} for future snapshots.")
|
|
802
|
+
|
|
803
|
+
def action_remove(self) -> None:
|
|
804
|
+
self._stage_current_operation("remove")
|
|
805
|
+
|
|
806
|
+
def action_restore(self) -> None:
|
|
807
|
+
self._stage_current_operation("restore")
|
|
808
|
+
|
|
809
|
+
def action_recover(self) -> None:
|
|
810
|
+
self._stage_current_operation("recover")
|
|
811
|
+
|
|
812
|
+
async def action_cancel(self) -> None:
|
|
813
|
+
if self.mode == "preview":
|
|
814
|
+
self.pending_plan = None
|
|
815
|
+
self._set_mode("view")
|
|
816
|
+
if self.current_ref:
|
|
817
|
+
await self.select_ref(self.current_ref)
|
|
818
|
+
elif self.mode == "editor":
|
|
819
|
+
await self._cancel_editor()
|
|
820
|
+
else:
|
|
821
|
+
await self.action_request_quit()
|
|
822
|
+
|
|
823
|
+
async def _cancel_editor(self) -> None:
|
|
824
|
+
if not self.editor_dirty():
|
|
825
|
+
self._set_mode("view")
|
|
826
|
+
if self.current_ref:
|
|
827
|
+
await self.select_ref(self.current_ref)
|
|
828
|
+
return
|
|
829
|
+
self.push_screen(Confirmation("Discard the unsaved draft?"), self._discard_editor)
|
|
830
|
+
|
|
831
|
+
async def _discard_editor(self, discard: bool | None) -> None:
|
|
832
|
+
if discard:
|
|
833
|
+
self._set_mode("view")
|
|
834
|
+
if self.current_ref:
|
|
835
|
+
await self.select_ref(self.current_ref)
|
|
836
|
+
|
|
837
|
+
async def action_request_quit(self) -> None:
|
|
838
|
+
if self.editor_dirty():
|
|
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)
|
|
842
|
+
else:
|
|
843
|
+
self.exit()
|
|
844
|
+
|
|
845
|
+
def _discard_and_exit(self, discard: bool | None) -> None:
|
|
846
|
+
if discard:
|
|
847
|
+
self.exit()
|
|
848
|
+
|
|
849
|
+
def _editor_payload(self) -> tuple[dict[str, Any], str]:
|
|
850
|
+
title = self.query_one("#editor-title", Input).value.strip()
|
|
851
|
+
body = self.query_one("#editor-body", TextArea).text
|
|
852
|
+
members = self._editor_members()
|
|
853
|
+
if self.editor_item and self.member_drafts:
|
|
854
|
+
body = members[self.editor_item["primary_member"]]
|
|
855
|
+
value = self.query_one("#editor-surface", Select).value
|
|
856
|
+
surface = value if isinstance(value, str) else "requested"
|
|
857
|
+
if not title:
|
|
858
|
+
raise ValueError("Title is required.")
|
|
859
|
+
if self.editor_item is None:
|
|
860
|
+
item = {
|
|
861
|
+
"title": title, "body": body, "surface": surface,
|
|
862
|
+
"tier": "env-personal", "domains": ["personal"], "kind": "rule",
|
|
863
|
+
"members": {"content.md": body},
|
|
864
|
+
}
|
|
865
|
+
return {"operation": "create", "item": item}, f"Create {title!r} on {surface}."
|
|
866
|
+
old = str(self.editor_item.get("body", ""))
|
|
867
|
+
patch = {
|
|
868
|
+
"title": title, "body": body, "surface": surface,
|
|
869
|
+
}
|
|
870
|
+
if self.member_drafts and members != self.editor_item.get("members", {}):
|
|
871
|
+
patch["members"] = members
|
|
872
|
+
if isinstance(self.editor_item.get("content_conflict"), dict):
|
|
873
|
+
primary = self.query_one("#editor-primary-member", Select).value
|
|
874
|
+
if not isinstance(primary, str):
|
|
875
|
+
raise ValueError("Choose the primary member to reconcile legacy content.")
|
|
876
|
+
patch["primary_member"] = primary
|
|
877
|
+
if self.editor_item.get("kind") == "hook":
|
|
878
|
+
event = self.query_one("#editor-hook-event", Select).value
|
|
879
|
+
matcher = self.query_one("#editor-hook-matcher", Input).value
|
|
880
|
+
if not isinstance(event, str) or event not in HOOK_EVENTS:
|
|
881
|
+
raise ValueError("Choose a supported hook event.")
|
|
882
|
+
if not matcher or "\n" in matcher or "\r" in matcher:
|
|
883
|
+
raise ValueError("Hook matcher must be one non-empty line.")
|
|
884
|
+
patch["hook"] = {"event": event, "matcher": matcher}
|
|
885
|
+
diff = "".join(difflib.unified_diff(
|
|
886
|
+
old.splitlines(True), body.splitlines(True), fromfile="current", tofile="planned",
|
|
887
|
+
)) or "(content unchanged; metadata will change)"
|
|
888
|
+
if "members" in patch:
|
|
889
|
+
diff = "\n".join("".join(difflib.unified_diff(
|
|
890
|
+
self.editor_item["members"][name].splitlines(True), value.splitlines(True),
|
|
891
|
+
fromfile=f"current/{name}", tofile=f"planned/{name}",
|
|
892
|
+
)) for name, value in members.items() if value != self.editor_item["members"][name])
|
|
893
|
+
hook_summary = ""
|
|
894
|
+
if "hook" in patch:
|
|
895
|
+
hook_summary = f"\nHook binding: {patch['hook']['event']} / {patch['hook']['matcher']}"
|
|
896
|
+
return {
|
|
897
|
+
"operation": "update", "ref": self.editor_item["ref"],
|
|
898
|
+
"item_digest": self.editor_item["digest"], "patch": patch,
|
|
899
|
+
}, diff + hook_summary
|
|
900
|
+
|
|
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
|
|
905
|
+
try:
|
|
906
|
+
plan = self.store.plan(payload)
|
|
907
|
+
except Exception as exc:
|
|
908
|
+
self.notify(str(exc), severity="error")
|
|
909
|
+
return
|
|
910
|
+
self.pending_plan = plan
|
|
911
|
+
preview = (
|
|
912
|
+
f"{summary}\n\nPlan: {plan['plan_id']}\n"
|
|
913
|
+
f"Expected revision: {plan['expected_revision']}\n"
|
|
914
|
+
f"Result revision: {plan['result_revision']}\n\n"
|
|
915
|
+
"Pinned/running sessions will not change. Apply affects future activated sessions only.\n\n"
|
|
916
|
+
f"Details:\n{json.dumps(plan.get('details', {}), ensure_ascii=False, indent=2, sort_keys=True)}"
|
|
917
|
+
)
|
|
918
|
+
self.query_one("#preview-text", Static).update(preview)
|
|
919
|
+
self._set_mode("preview")
|
|
920
|
+
|
|
921
|
+
async def _apply_pending(self) -> None:
|
|
922
|
+
if self.pending_plan is None:
|
|
923
|
+
return
|
|
924
|
+
try:
|
|
925
|
+
result = self.store.apply(
|
|
926
|
+
self.pending_plan["plan_id"],
|
|
927
|
+
expected_revision=self.pending_plan["expected_revision"],
|
|
928
|
+
)
|
|
929
|
+
except Exception as exc:
|
|
930
|
+
self.notify(str(exc), severity="error")
|
|
931
|
+
return
|
|
932
|
+
ref = result.get("details", {}).get("ref")
|
|
933
|
+
if result.get("details", {}).get("operation") == "enable":
|
|
934
|
+
self._clear_toggle_drafts()
|
|
935
|
+
self.pending_plan = None
|
|
936
|
+
self._set_mode("view")
|
|
937
|
+
await self.refresh_library(self.query_one("#search", Input).value)
|
|
938
|
+
if isinstance(ref, str) and self._row(ref):
|
|
939
|
+
await self.select_ref(ref)
|
|
940
|
+
self.notify("Applied. Future activated sessions use the new revision.")
|
|
941
|
+
|
|
942
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
943
|
+
button = event.button.id
|
|
944
|
+
if button == "enablement-preview":
|
|
945
|
+
self._preview_toggles()
|
|
946
|
+
elif button == "enablement-discard":
|
|
947
|
+
await self._discard_toggles()
|
|
948
|
+
elif button == "create":
|
|
949
|
+
self.action_create()
|
|
950
|
+
elif button == "edit":
|
|
951
|
+
self.action_edit()
|
|
952
|
+
elif button == "editor-cancel":
|
|
953
|
+
await self._cancel_editor()
|
|
954
|
+
elif button == "preview":
|
|
955
|
+
try:
|
|
956
|
+
payload, summary = self._editor_payload()
|
|
957
|
+
except ValueError as exc:
|
|
958
|
+
self.notify(str(exc), severity="error")
|
|
959
|
+
else:
|
|
960
|
+
self._stage_plan(payload, summary)
|
|
961
|
+
elif button == "plan-cancel":
|
|
962
|
+
await self.action_cancel()
|
|
963
|
+
elif button == "apply":
|
|
964
|
+
await self._apply_pending()
|
|
965
|
+
elif button in {"remove", "restore", "recover"}:
|
|
966
|
+
self._stage_current_operation(button)
|
|
967
|
+
elif button == "reset":
|
|
968
|
+
self._stage_plan(
|
|
969
|
+
{"operation": "reset"},
|
|
970
|
+
"Reset future authoring to the last successful install tuple; preserve sources, history, and pins.",
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def run(store: Any) -> None:
|
|
975
|
+
InstructionsStudio(store).run()
|