aetherius 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
@@ -0,0 +1,57 @@
1
+ """Live JSON preview with real-time schema validation.
2
+
3
+ Renders the draft's assembled JSON and, below it, the issues from ``validate_draft`` — errors in
4
+ pompeian red, warnings in amber, or a laurel "ready to save" when clean. Colours come from the
5
+ theme; the widget owns no validation logic, it only displays what the builder reports.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from rich.text import Text
11
+
12
+ from textual.app import ComposeResult
13
+ from textual.containers import Vertical
14
+ from textual.widgets import Static
15
+
16
+ from ....builder.factory import BlueprintDraft
17
+ from ....builder.validation import validate_draft
18
+ from ...theme import AMBER, LAUREL, POMPEIAN
19
+ from ...widgets.json_preview import JsonPreview
20
+
21
+
22
+ class BlueprintPreview(Vertical):
23
+ """Syntax-highlighted JSON plus a live list of validation issues."""
24
+
25
+ DEFAULT_CSS = """
26
+ BlueprintPreview {
27
+ height: auto;
28
+ border: double $primary;
29
+ padding: 0 1;
30
+ }
31
+ BlueprintPreview #preview-issues {
32
+ height: auto;
33
+ padding: 1 0 0 0;
34
+ }
35
+ """
36
+
37
+ def compose(self) -> ComposeResult:
38
+ yield JsonPreview(id="preview-json")
39
+ yield Static(id="preview-issues")
40
+
41
+ def refresh_from(self, draft: BlueprintDraft) -> None:
42
+ """Re-render the JSON and the issue list from *draft*."""
43
+ self.query_one("#preview-json", JsonPreview).show(draft.to_data())
44
+ self.query_one("#preview-issues", Static).update(self._issue_text(draft))
45
+
46
+ def _issue_text(self, draft: BlueprintDraft) -> Text:
47
+ issues = validate_draft(draft)
48
+ if not issues:
49
+ return Text("Valid — ready to save.", style=f"bold {LAUREL}")
50
+ errors = [i for i in issues if i.severity == "error"]
51
+ warnings = [i for i in issues if i.severity == "warning"]
52
+ text = Text()
53
+ for issue in errors:
54
+ text.append(f"✗ {issue.path}: {issue.message}\n", style=POMPEIAN)
55
+ for issue in warnings:
56
+ text.append(f"! {issue.path}: {issue.message}\n", style=AMBER)
57
+ return text
@@ -0,0 +1,235 @@
1
+ """Blueprint Studio orchestrator screen.
2
+
3
+ Guided creation and editing of a Blueprint without hand-writing JSON: pick an Act, declare inputs
4
+ and secrets, assemble steps through forms, set the durable options, and watch a live JSON preview
5
+ validated against the schema. The screen owns the single :class:`BlueprintDraft`; its child editors
6
+ report changes, it re-reads them into the draft and refreshes the preview. Everything is local and
7
+ fast, so no worker is needed. Opened with a *path* it edits that file in place; otherwise it creates
8
+ a new one under ``./blueprints``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ from textual.app import ComposeResult
17
+ from textual.containers import Horizontal, Vertical, VerticalScroll
18
+ from textual.screen import Screen
19
+ from textual.widgets import Button, Footer, Header, Input, Label, Select, Static
20
+
21
+ from ....builder.factory import BlueprintDraft, StepDraft, save_blueprint
22
+ from ....builder.templates import list_templates, template_draft
23
+ from ....core.blueprint.loader import load_blueprint
24
+ from ....core.errors import AetheriusError
25
+ from ...theme import starred
26
+ from .act_picker import ActPicker
27
+ from .io_editor import InputsEditor, VarsOutputsEditor
28
+ from .options_editor import OptionsEditor
29
+ from .preview import BlueprintPreview
30
+ from .step_editor import StepEditorModal, StepList
31
+
32
+
33
+ class BlueprintStudioScreen(Screen[None]):
34
+ """Assemble or edit a Blueprint through forms, with a live validated JSON preview."""
35
+
36
+ AUTO_FOCUS = "#studio-name"
37
+
38
+ DEFAULT_CSS = """
39
+ BlueprintStudioScreen #studio-save {
40
+ border: double $accent;
41
+ background: $panel;
42
+ color: $accent;
43
+ text-style: bold;
44
+ padding: 0 3;
45
+ margin: 1 0;
46
+ }
47
+ BlueprintStudioScreen #studio-save:hover { background: $accent 20%; }
48
+ BlueprintStudioScreen .studio-field { height: auto; padding: 1 1 0 1; }
49
+ BlueprintStudioScreen #studio-template-row { height: auto; padding: 1 1 0 1; }
50
+ """
51
+
52
+ def __init__(self, path: Path | str | None = None) -> None:
53
+ super().__init__()
54
+ self._edit_path: Path | None = Path(path) if path is not None else None
55
+ self._draft = self._initial_draft()
56
+
57
+ def _initial_draft(self) -> BlueprintDraft:
58
+ if self._edit_path is None:
59
+ return BlueprintDraft()
60
+ # Prefer the canonical loader; fall back to a raw read so a well-formed-but-act-pending file
61
+ # still opens for editing. A truly unparseable file is caught by Library before we get here.
62
+ try:
63
+ return BlueprintDraft.from_data(load_blueprint(self._edit_path).model_dump())
64
+ except AetheriusError:
65
+ data = json.loads(Path(self._edit_path).read_text(encoding="utf-8"))
66
+ return BlueprintDraft.from_data(data)
67
+
68
+ def compose(self) -> ComposeResult:
69
+ yield Header()
70
+ with VerticalScroll(classes="console-body"):
71
+ title = "Blueprint Studio" + (" — editing" if self._edit_path else "")
72
+ yield Static(starred(title), classes="console-title")
73
+ yield Static(
74
+ "Pick an Act, declare inputs, assemble steps, set options. The JSON preview is "
75
+ "validated live; Save writes it to ./blueprints (or overwrites the edited file).",
76
+ classes="console-subtitle",
77
+ )
78
+ if self._edit_path is None:
79
+ with Horizontal(id="studio-template-row"):
80
+ yield Select(
81
+ [(t.title + f" ({t.act})", t.key) for t in list_templates()],
82
+ prompt="Start from a template…",
83
+ id="studio-template",
84
+ )
85
+ yield Button("Load template", id="studio-load-template")
86
+ with Vertical(classes="studio-field"):
87
+ yield Label("Name (e.g. domain.task)")
88
+ yield Input(value=self._draft.name, placeholder="domain.task", id="studio-name")
89
+ yield Label("Description (optional)")
90
+ yield Input(value=self._draft.description or "", id="studio-description")
91
+ yield ActPicker(self._draft.act)
92
+ yield InputsEditor()
93
+ steps = StepList()
94
+ steps.border_title = "✦ Steps ✦"
95
+ yield steps
96
+ yield OptionsEditor()
97
+ yield VarsOutputsEditor()
98
+ preview = BlueprintPreview()
99
+ preview.border_title = "✦ Live preview ✦"
100
+ yield preview
101
+ yield Button("✦ Save Blueprint ✦", id="studio-save", variant="primary")
102
+ yield Footer()
103
+
104
+ def on_mount(self) -> None:
105
+ self.query_one(InputsEditor).load(self._draft.inputs, self._draft.secrets)
106
+ self.query_one(OptionsEditor).load(self._draft.options)
107
+ self.query_one(VarsOutputsEditor).load(self._draft.vars, self._draft.outputs)
108
+ self._render_steps()
109
+ # Render straight from the draft: the editors were just loaded from it, and their freshly
110
+ # mounted children are not queryable yet, so a sync-back would momentarily read them empty.
111
+ self.query_one(BlueprintPreview).refresh_from(self._draft)
112
+
113
+ # ── Draft synchronisation ───────────────────────────────────────────────
114
+
115
+ def _sync_from_editors(self) -> None:
116
+ """Read every editor back into the draft (steps are mutated in place elsewhere)."""
117
+ self._draft.name = self.query_one("#studio-name", Input).value.strip()
118
+ description = self.query_one("#studio-description", Input).value.strip()
119
+ self._draft.description = description or None
120
+ self._draft.inputs, self._draft.secrets = self.query_one(InputsEditor).collect()
121
+ self._draft.options = self.query_one(OptionsEditor).collect()
122
+ self._draft.vars, self._draft.outputs = self.query_one(VarsOutputsEditor).collect()
123
+
124
+ def _refresh_preview(self) -> None:
125
+ self._sync_from_editors()
126
+ self.query_one(BlueprintPreview).refresh_from(self._draft)
127
+
128
+ def _render_steps(self) -> None:
129
+ self.query_one(StepList).refresh_from(self._draft.steps)
130
+
131
+ # ── Events ────────────────────────────────────────────────────────────────
132
+
133
+ def on_input_changed(self, event: Input.Changed) -> None:
134
+ self._refresh_preview()
135
+
136
+ def on_switch_changed(self, event: object) -> None:
137
+ self._refresh_preview()
138
+
139
+ def on_select_changed(self, event: Select.Changed) -> None:
140
+ if event.select.id == "studio-template":
141
+ return # loaded explicitly via the button, not on selection
142
+ self._refresh_preview()
143
+
144
+ def on_text_area_changed(self, event: object) -> None:
145
+ self._refresh_preview()
146
+
147
+ def on_act_picker_act_changed(self, event: ActPicker.ActChanged) -> None:
148
+ self._draft.act = event.act
149
+ self._render_steps()
150
+ self._refresh_preview()
151
+
152
+ def on_inputs_editor_changed(self, event: InputsEditor.Changed) -> None:
153
+ self._refresh_preview()
154
+
155
+ def on_button_pressed(self, event: Button.Pressed) -> None:
156
+ button = event.button.id
157
+ if button == "studio-load-template":
158
+ self._load_template()
159
+ elif button == "studio-step-add":
160
+ self.app.push_screen(StepEditorModal(self._draft.act), self._on_step_added)
161
+ elif button == "studio-step-edit":
162
+ self._edit_selected_step()
163
+ elif button == "studio-step-remove":
164
+ self._remove_selected_step()
165
+ elif button == "studio-step-up":
166
+ self._move_selected_step(-1)
167
+ elif button == "studio-step-down":
168
+ self._move_selected_step(1)
169
+ elif button == "studio-save":
170
+ self._save()
171
+
172
+ # ── Steps ───────────────────────────────────────────────────────────────
173
+
174
+ def _on_step_added(self, step: StepDraft | None) -> None:
175
+ if step is not None:
176
+ self._draft.steps.append(step)
177
+ self._render_steps()
178
+ self._refresh_preview()
179
+
180
+ def _edit_selected_step(self) -> None:
181
+ index = self.query_one(StepList).selected_index
182
+ if index is None:
183
+ return
184
+
185
+ def _apply(step: StepDraft | None) -> None:
186
+ if step is not None:
187
+ self._draft.steps[index] = step
188
+ self._render_steps()
189
+ self._refresh_preview()
190
+
191
+ self.app.push_screen(StepEditorModal(self._draft.act, self._draft.steps[index]), _apply)
192
+
193
+ def _remove_selected_step(self) -> None:
194
+ index = self.query_one(StepList).selected_index
195
+ if index is not None:
196
+ self._draft.remove_step(index)
197
+ self._render_steps()
198
+ self._refresh_preview()
199
+
200
+ def _move_selected_step(self, delta: int) -> None:
201
+ index = self.query_one(StepList).selected_index
202
+ if index is not None:
203
+ self._draft.move_step(index, delta)
204
+ self._render_steps()
205
+ self._refresh_preview()
206
+
207
+ # ── Template & save ─────────────────────────────────────────────────────
208
+
209
+ def _load_template(self) -> None:
210
+ select = self.query_one("#studio-template", Select)
211
+ if select.value is Select.BLANK:
212
+ return
213
+ self._draft = template_draft(str(select.value))
214
+ self.query_one("#studio-name", Input).value = self._draft.name
215
+ self.query_one("#studio-description", Input).value = self._draft.description or ""
216
+ self.query_one(ActPicker).query_one("#act-select", Select).value = self._draft.act
217
+ self.query_one(InputsEditor).load(self._draft.inputs, self._draft.secrets)
218
+ self.query_one(OptionsEditor).load(self._draft.options)
219
+ self.query_one(VarsOutputsEditor).load(self._draft.vars, self._draft.outputs)
220
+ self._render_steps()
221
+ self._refresh_preview()
222
+ self.app.notify(f"Loaded template {select.value}.")
223
+
224
+ def _save(self) -> None:
225
+ self._sync_from_editors()
226
+ if self.query_one(VarsOutputsEditor).has_errors:
227
+ self.app.notify("Fix the invalid JSON in vars/outputs first.", severity="warning")
228
+ return
229
+ try:
230
+ path = save_blueprint(self._draft, path=self._edit_path)
231
+ except AetheriusError as exc:
232
+ self.app.notify(str(exc), title="Cannot save", severity="error", timeout=8)
233
+ return
234
+ self._edit_path = path # subsequent saves overwrite the same file
235
+ self.app.notify(f"Saved {path}", timeout=6)
@@ -0,0 +1,249 @@
1
+ """Add and edit steps through forms driven by the builder catalog.
2
+
3
+ ``StepEditorModal`` renders one form field per parameter of the chosen action (a JSON text area for
4
+ object/array parameters), plus a "raw JSON" escape hatch so any parameter combination — including
5
+ actions the forms do not fully model — stays reachable. ``StepList`` is the read-only table of steps
6
+ with its add/edit/remove/reorder controls; it owns no state, the Studio screen does.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ from textual.app import ComposeResult
15
+ from textual.containers import Horizontal, Vertical, VerticalScroll
16
+ from textual.screen import ModalScreen
17
+ from textual.widgets import Button, DataTable, Input, Label, Select, Static, Switch, TextArea
18
+
19
+ from ....builder.catalog import actions_for_act
20
+ from ....builder.factory import StepDraft
21
+ from ....core.actions.registry import get_spec
22
+ from ....core.actions.spec import ParamSpec
23
+
24
+ _JSON_KINDS = ("object", "array")
25
+
26
+
27
+ def _params_summary(params: dict[str, Any]) -> str:
28
+ """A short, one-line digest of a step's parameters for the table."""
29
+ parts = []
30
+ for key, value in list(params.items())[:3]:
31
+ text = json.dumps(value) if isinstance(value, (dict, list)) else str(value)
32
+ parts.append(f"{key}={text[:24]}")
33
+ return ", ".join(parts)
34
+
35
+
36
+ class StepEditorModal(ModalScreen[StepDraft | None]):
37
+ """Edit one step: pick an action, fill its parameters, dismiss with the resulting StepDraft."""
38
+
39
+ DEFAULT_CSS = """
40
+ StepEditorModal { align: center middle; }
41
+ StepEditorModal #step-dialog {
42
+ width: 80%;
43
+ max-width: 90;
44
+ height: auto;
45
+ max-height: 90%;
46
+ border: double $primary;
47
+ background: $surface;
48
+ padding: 1 2;
49
+ }
50
+ StepEditorModal .step-field { height: auto; padding: 0 0 1 0; }
51
+ StepEditorModal TextArea { height: 5; }
52
+ StepEditorModal #step-raw { height: 8; }
53
+ StepEditorModal .step-actions { height: auto; padding: 1 0 0 0; }
54
+ """
55
+
56
+ def __init__(self, act: str, step: StepDraft | None = None) -> None:
57
+ super().__init__()
58
+ self._act = act
59
+ self._editing = step
60
+ self._action = step.action if step and step.action else self._default_action()
61
+
62
+ def _default_action(self) -> str:
63
+ actions = actions_for_act(self._act)
64
+ return actions[0].spec.name if actions else ""
65
+
66
+ def compose(self) -> ComposeResult:
67
+ options = [
68
+ (info.spec.name + ("" if info.runnable else " (not runnable yet)"), info.spec.name)
69
+ for info in actions_for_act(self._act)
70
+ ]
71
+ with VerticalScroll(id="step-dialog"):
72
+ yield Static("Edit step", classes="console-title")
73
+ with Vertical(classes="step-field"):
74
+ yield Label("Action")
75
+ yield Select(options, value=self._action, allow_blank=False, id="step-action")
76
+ with Vertical(classes="step-field"):
77
+ yield Label("Step id (optional)")
78
+ yield Input(
79
+ value=(self._editing.id or "") if self._editing else "",
80
+ placeholder="e.g. login",
81
+ id="step-id",
82
+ )
83
+ with Horizontal(classes="step-field"):
84
+ yield Switch(value=False, id="step-raw-toggle")
85
+ yield Label("Edit raw JSON params")
86
+ yield Vertical(id="step-params")
87
+ yield TextArea(id="step-raw")
88
+ with Horizontal(classes="step-actions"):
89
+ yield Button("Save", id="step-ok", variant="primary")
90
+ yield Button("Cancel", id="step-cancel")
91
+
92
+ def on_mount(self) -> None:
93
+ self.query_one("#step-raw", TextArea).display = False
94
+ self._render_params(self._action)
95
+
96
+ # ── Parameter form ────────────────────────────────────────────────────────
97
+
98
+ def _render_params(self, action: str) -> None:
99
+ container = self.query_one("#step-params", Vertical)
100
+ container.remove_children()
101
+ try:
102
+ spec = get_spec(action)
103
+ except Exception:
104
+ return
105
+ existing = self._editing.params if self._editing and self._editing.action == action else {}
106
+ for param in spec.params:
107
+ container.mount(self._field(param, existing.get(param.name)))
108
+
109
+ def _field(self, param: ParamSpec, value: Any) -> Vertical:
110
+ wid = f"param-{param.name}"
111
+ label = (
112
+ param.name
113
+ + (" *" if param.required else "")
114
+ + (f" — {param.help}" if param.help else "")
115
+ )
116
+ editor: Switch | TextArea | Input
117
+ if param.kind == "boolean":
118
+ editor = Switch(value=bool(value), id=wid)
119
+ elif param.kind in _JSON_KINDS:
120
+ text = json.dumps(value, indent=2) if value not in (None, "", {}, []) else ""
121
+ editor = TextArea(text=text, id=wid)
122
+ else:
123
+ shown = "" if value in (None, "") else str(value)
124
+ editor = Input(value=shown, placeholder=param.placeholder or param.kind, id=wid)
125
+ return Vertical(Label(label), editor, classes="step-field")
126
+
127
+ # ── Events ──────────────────────────────────────────────────────────────
128
+
129
+ def on_select_changed(self, event: Select.Changed) -> None:
130
+ if event.select.id == "step-action":
131
+ event.stop()
132
+ self._action = str(event.value)
133
+ self._render_params(self._action)
134
+
135
+ def on_switch_changed(self, event: Switch.Changed) -> None:
136
+ if event.switch.id != "step-raw-toggle":
137
+ return
138
+ event.stop()
139
+ raw = self.query_one("#step-raw", TextArea)
140
+ params_box = self.query_one("#step-params", Vertical)
141
+ if event.value:
142
+ raw.text = json.dumps(self._collect_fields() or {}, indent=2)
143
+ raw.display = True
144
+ params_box.display = False
145
+ else:
146
+ raw.display = False
147
+ params_box.display = True
148
+
149
+ def on_button_pressed(self, event: Button.Pressed) -> None:
150
+ if event.button.id == "step-cancel":
151
+ self.dismiss(None)
152
+ elif event.button.id == "step-ok":
153
+ self._save()
154
+
155
+ # ── Collection ────────────────────────────────────────────────────────────
156
+
157
+ def _collect_fields(self) -> dict[str, Any] | None:
158
+ """Read the per-parameter widgets; None if a JSON field does not parse."""
159
+ try:
160
+ spec = get_spec(self._action)
161
+ except Exception:
162
+ return {}
163
+ params: dict[str, Any] = {}
164
+ for param in spec.params:
165
+ widget = self.query_one(f"#param-{param.name}")
166
+ if param.kind == "boolean":
167
+ if isinstance(widget, Switch) and widget.value:
168
+ params[param.name] = True
169
+ elif param.kind in _JSON_KINDS:
170
+ text = widget.text.strip() if isinstance(widget, TextArea) else ""
171
+ if text:
172
+ try:
173
+ params[param.name] = json.loads(text)
174
+ except ValueError:
175
+ self.app.notify(f"Invalid JSON in '{param.name}'.", severity="error")
176
+ return None
177
+ else:
178
+ value = widget.value.strip() if isinstance(widget, Input) else ""
179
+ if value:
180
+ params[param.name] = self._coerce(param, value)
181
+ return params
182
+
183
+ @staticmethod
184
+ def _coerce(param: ParamSpec, value: str) -> Any:
185
+ if param.kind in ("number", "integer"):
186
+ try:
187
+ return int(value) if param.kind == "integer" else float(value)
188
+ except ValueError:
189
+ return value # leave it; the preview's schema check will flag it
190
+ return value
191
+
192
+ def _save(self) -> None:
193
+ if self.query_one("#step-raw-toggle", Switch).value:
194
+ text = self.query_one("#step-raw", TextArea).text.strip()
195
+ try:
196
+ params = json.loads(text) if text else {}
197
+ except ValueError:
198
+ self.app.notify("Invalid raw JSON.", severity="error")
199
+ return
200
+ if not isinstance(params, dict):
201
+ self.app.notify("Raw params must be a JSON object.", severity="error")
202
+ return
203
+ else:
204
+ collected = self._collect_fields()
205
+ if collected is None:
206
+ return
207
+ params = collected
208
+ step_id = self.query_one("#step-id", Input).value.strip() or None
209
+ self.dismiss(StepDraft(action=self._action, id=step_id, params=params))
210
+
211
+
212
+ class StepList(Vertical):
213
+ """The steps table with add / edit / remove / reorder controls (a pure view of the draft)."""
214
+
215
+ DEFAULT_CSS = """
216
+ StepList {
217
+ height: auto;
218
+ border: round $primary;
219
+ padding: 0 1 1 1;
220
+ margin: 1 1 0 1;
221
+ }
222
+ StepList DataTable { height: auto; max-height: 16; }
223
+ StepList .step-list-actions { height: auto; padding: 1 0 0 0; }
224
+ """
225
+
226
+ def compose(self) -> ComposeResult:
227
+ yield DataTable(id="studio-steps", cursor_type="row")
228
+ with Horizontal(classes="step-list-actions"):
229
+ yield Button("+ Add", id="studio-step-add")
230
+ yield Button("Edit", id="studio-step-edit")
231
+ yield Button("Remove", id="studio-step-remove")
232
+ yield Button("↑", id="studio-step-up")
233
+ yield Button("↓", id="studio-step-down")
234
+
235
+ def on_mount(self) -> None:
236
+ self.query_one("#studio-steps", DataTable).add_columns("#", "Action", "Id", "Parameters")
237
+
238
+ def refresh_from(self, steps: list[StepDraft]) -> None:
239
+ table = self.query_one("#studio-steps", DataTable)
240
+ table.clear()
241
+ for index, step in enumerate(steps):
242
+ table.add_row(str(index + 1), step.action, step.id or "", _params_summary(step.params))
243
+
244
+ @property
245
+ def selected_index(self) -> int | None:
246
+ table = self.query_one("#studio-steps", DataTable)
247
+ if table.row_count == 0:
248
+ return None
249
+ return table.cursor_row
@@ -0,0 +1,52 @@
1
+ """Explore and explain the four Acts and their action capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Vertical
9
+ from textual.screen import Screen
10
+ from textual.widgets import DataTable, Footer, Header, Static
11
+
12
+ from ..theme import ACT_LABELS, LAUREL, STONE, act_color, starred
13
+ from ...builder.catalog import act_infos, actions_for_act
14
+
15
+
16
+ class CatalogScreen(Screen[None]):
17
+ """Read-only reference: the 4 Acts, their status, and their supported actions.
18
+
19
+ A pure projection of the shared builder catalogue (``builder/catalog.py``), so the Act
20
+ descriptions and the runnable/pending status never drift from what the Studio offers.
21
+ """
22
+
23
+ def compose(self) -> ComposeResult:
24
+ yield Header()
25
+ with Vertical(classes="console-body"):
26
+ yield Static(starred("Act Catalog"), classes="console-title")
27
+ yield Static(
28
+ "Each Blueprint declares one Act; a step using an unsupported action fails "
29
+ "validation before it ever runs.",
30
+ classes="console-subtitle",
31
+ )
32
+ yield DataTable(id="catalog-table", cursor_type="row")
33
+ yield Footer()
34
+
35
+ def on_mount(self) -> None:
36
+ table = self.query_one("#catalog-table", DataTable)
37
+ table.add_columns("Act", "Status", "Description", "Actions")
38
+ for info in act_infos():
39
+ color = act_color(info.act, info.implemented)
40
+ status = (
41
+ Text("implemented", style=f"bold {LAUREL}")
42
+ if info.implemented
43
+ else Text("not runnable yet", style=STONE)
44
+ )
45
+ # A dagger marks actions declared but not yet dispatched by the Act's driver.
46
+ names = [a.spec.name + ("" if a.runnable else " †") for a in actions_for_act(info.act)]
47
+ table.add_row(
48
+ Text(ACT_LABELS[info.act], style=color),
49
+ status,
50
+ info.summary,
51
+ ", ".join(names),
52
+ )
@@ -0,0 +1,90 @@
1
+ """Home dashboard and main menu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import VerticalScroll
9
+ from textual.screen import Screen
10
+ from textual.widgets import Footer, Header, OptionList, Static
11
+ from textual.widgets.option_list import Option
12
+
13
+ from ...core.runtime.engine import IMPLEMENTED_ACTS
14
+ from ...version import __version__
15
+ from ..theme import GOLD, LAUREL, MOTTO, STARFIELD, TYRIAN, VIOLET, WORDMARK, frieze, garland
16
+
17
+ # A gold star marks the sections that are usable today; a dot marks the pending ones.
18
+ _MENU: list[tuple[str, str]] = [
19
+ ("library", "✦ Library — browse Blueprints and run one"),
20
+ ("catalog", "✦ Catalog — the 4 Acts and their capabilities"),
21
+ ("recorder", "✦ Recorder — capture a Blueprint by demonstration"),
22
+ ("builder", "✦ Blueprint Studio — create and edit Blueprints, guided"),
23
+ ("sessions", "· Sessions — profiles and warmup (coming soon)"),
24
+ ("settings", "✦ Settings — start/stop the daemon and see its config"),
25
+ ]
26
+
27
+
28
+ def _banner() -> Text:
29
+ text = Text(justify="center")
30
+ text.append(frieze() + "\n", style=VIOLET)
31
+ text.append(STARFIELD + "\n", style=GOLD)
32
+ for row in WORDMARK:
33
+ text.append(row + "\n", style=f"bold {GOLD}")
34
+ text.append(garland() + "\n", style=LAUREL)
35
+ text.append("✦ ", style=GOLD)
36
+ text.append(MOTTO, style=f"italic {TYRIAN}")
37
+ text.append(" ✦\n", style=GOLD)
38
+ text.append(frieze(), style=VIOLET)
39
+ return text
40
+
41
+
42
+ class HomeScreen(Screen[None]):
43
+ """Landing screen: wordmark, Act availability, and the section menu."""
44
+
45
+ # The menu must own the focus so arrow keys and Enter work immediately,
46
+ # instead of the scroll container grabbing it.
47
+ AUTO_FOCUS = "#home-menu"
48
+
49
+ def compose(self) -> ComposeResult:
50
+ yield Header()
51
+ with VerticalScroll(classes="console-body"):
52
+ yield Static(_banner(), id="home-banner")
53
+ yield Static(
54
+ f"v{__version__} · implemented acts: {', '.join(sorted(IMPLEMENTED_ACTS))} "
55
+ "(the rest are visible in Catalog, not runnable yet)",
56
+ id="home-status",
57
+ classes="console-subtitle",
58
+ )
59
+ yield OptionList(
60
+ *(Option(label, id=key) for key, label in _MENU),
61
+ id="home-menu",
62
+ )
63
+ yield Footer()
64
+
65
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
66
+ key = event.option.id
67
+ if key == "library":
68
+ from .library import LibraryScreen
69
+
70
+ self.app.push_screen(LibraryScreen())
71
+ elif key == "catalog":
72
+ from .catalog import CatalogScreen
73
+
74
+ self.app.push_screen(CatalogScreen())
75
+ elif key == "sessions":
76
+ from .sessions import SessionsScreen
77
+
78
+ self.app.push_screen(SessionsScreen())
79
+ elif key == "settings":
80
+ from .settings import SettingsScreen
81
+
82
+ self.app.push_screen(SettingsScreen())
83
+ elif key == "recorder":
84
+ from .recorder import RecorderScreen
85
+
86
+ self.app.push_screen(RecorderScreen())
87
+ elif key == "builder":
88
+ from .builder.screen import BlueprintStudioScreen
89
+
90
+ self.app.push_screen(BlueprintStudioScreen())