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.
- aetherius/__init__.py +42 -0
- aetherius/__main__.py +12 -0
- aetherius/_contracts/__init__.py +0 -0
- aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius/acts/__init__.py +1 -0
- aetherius/acts/_shared.py +61 -0
- aetherius/acts/continuum/__init__.py +7 -0
- aetherius/acts/continuum/actions.py +149 -0
- aetherius/acts/continuum/bridge.py +159 -0
- aetherius/acts/continuum/browser.py +164 -0
- aetherius/acts/continuum/debug_overlay.py +65 -0
- aetherius/acts/continuum/driver.py +127 -0
- aetherius/acts/continuum/human_actions.py +74 -0
- aetherius/acts/oracle/__init__.py +1 -0
- aetherius/acts/oracle/driver.py +1 -0
- aetherius/acts/oracle/locator.py +1 -0
- aetherius/acts/oracle/model.py +1 -0
- aetherius/acts/oracle/perception.py +1 -0
- aetherius/acts/phantom/__init__.py +1 -0
- aetherius/acts/phantom/driver.py +1 -0
- aetherius/acts/phantom/loop.py +1 -0
- aetherius/acts/phantom/memory.py +1 -0
- aetherius/acts/phantom/perception.py +1 -0
- aetherius/acts/phantom/planner.py +1 -0
- aetherius/acts/vector/__init__.py +1 -0
- aetherius/acts/vector/auth.py +108 -0
- aetherius/acts/vector/client.py +129 -0
- aetherius/acts/vector/driver.py +128 -0
- aetherius/builder/__init__.py +40 -0
- aetherius/builder/catalog.py +93 -0
- aetherius/builder/factory.py +243 -0
- aetherius/builder/templates.py +150 -0
- aetherius/builder/validation.py +120 -0
- aetherius/cli.py +236 -0
- aetherius/config/__init__.py +1 -0
- aetherius/config/secrets.py +71 -0
- aetherius/config/settings.py +44 -0
- aetherius/console/__init__.py +1 -0
- aetherius/console/app.py +66 -0
- aetherius/console/console.tcss +97 -0
- aetherius/console/daemon_control.py +83 -0
- aetherius/console/run_bridge.py +43 -0
- aetherius/console/screens/__init__.py +1 -0
- aetherius/console/screens/_pending.py +38 -0
- aetherius/console/screens/builder/__init__.py +1 -0
- aetherius/console/screens/builder/act_picker.py +75 -0
- aetherius/console/screens/builder/io_editor.py +167 -0
- aetherius/console/screens/builder/options_editor.py +132 -0
- aetherius/console/screens/builder/preview.py +57 -0
- aetherius/console/screens/builder/screen.py +235 -0
- aetherius/console/screens/builder/step_editor.py +249 -0
- aetherius/console/screens/catalog.py +52 -0
- aetherius/console/screens/home.py +90 -0
- aetherius/console/screens/library.py +124 -0
- aetherius/console/screens/library_scan.py +99 -0
- aetherius/console/screens/recorder.py +193 -0
- aetherius/console/screens/runs.py +138 -0
- aetherius/console/screens/sessions.py +14 -0
- aetherius/console/screens/settings.py +132 -0
- aetherius/console/screenshots.py +173 -0
- aetherius/console/theme.py +114 -0
- aetherius/console/widgets/__init__.py +1 -0
- aetherius/console/widgets/event_log.py +44 -0
- aetherius/console/widgets/form.py +101 -0
- aetherius/console/widgets/json_preview.py +18 -0
- aetherius/console/widgets/run_summary.py +82 -0
- aetherius/core/__init__.py +1 -0
- aetherius/core/actions/__init__.py +1 -0
- aetherius/core/actions/base.py +99 -0
- aetherius/core/actions/data.py +126 -0
- aetherius/core/actions/flow.py +76 -0
- aetherius/core/actions/interaction.py +109 -0
- aetherius/core/actions/navigation.py +32 -0
- aetherius/core/actions/registry.py +70 -0
- aetherius/core/actions/spec.py +42 -0
- aetherius/core/blueprint/__init__.py +1 -0
- aetherius/core/blueprint/loader.py +82 -0
- aetherius/core/blueprint/models.py +81 -0
- aetherius/core/blueprint/template.py +101 -0
- aetherius/core/blueprint/validator.py +45 -0
- aetherius/core/driver.py +36 -0
- aetherius/core/errors.py +134 -0
- aetherius/core/events/__init__.py +1 -0
- aetherius/core/events/bus.py +28 -0
- aetherius/core/events/models.py +29 -0
- aetherius/core/events/sinks.py +55 -0
- aetherius/core/extraction/__init__.py +1 -0
- aetherius/core/extraction/html_extractor.py +52 -0
- aetherius/core/extraction/json_extractor.py +125 -0
- aetherius/core/runtime/__init__.py +1 -0
- aetherius/core/runtime/context.py +61 -0
- aetherius/core/runtime/engine.py +188 -0
- aetherius/core/runtime/result.py +39 -0
- aetherius/core/runtime/selector.py +1 -0
- aetherius/models/__init__.py +1 -0
- aetherius/models/registry.py +1 -0
- aetherius/models/store/.gitkeep +0 -0
- aetherius/recorder/__init__.py +12 -0
- aetherius/recorder/_capture_js.py +80 -0
- aetherius/recorder/_gesture_js.py +40 -0
- aetherius/recorder/_names.py +20 -0
- aetherius/recorder/_overlay_js.py +262 -0
- aetherius/recorder/_playwright.py +49 -0
- aetherius/recorder/_selector_js.py +148 -0
- aetherius/recorder/_transform.py +214 -0
- aetherius/recorder/_vector_js.py +174 -0
- aetherius/recorder/base.py +92 -0
- aetherius/recorder/blueprint_recorder.py +72 -0
- aetherius/recorder/capture.py +59 -0
- aetherius/recorder/continuum_backend.py +119 -0
- aetherius/recorder/gesture_recorder.py +193 -0
- aetherius/recorder/selector_synth.py +83 -0
- aetherius/recorder/session.py +101 -0
- aetherius/recorder/vector_backend.py +158 -0
- aetherius/server/__init__.py +12 -0
- aetherius/server/app.py +37 -0
- aetherius/server/config.py +25 -0
- aetherius/server/deps.py +50 -0
- aetherius/server/jobs.py +155 -0
- aetherius/server/routes/__init__.py +1 -0
- aetherius/server/routes/blueprints.py +45 -0
- aetherius/server/routes/recorder.py +25 -0
- aetherius/server/routes/runs.py +49 -0
- aetherius/server/routes/stream.py +53 -0
- aetherius/server/schemas.py +78 -0
- aetherius/stealth/__init__.py +1 -0
- aetherius/stealth/fingerprint/__init__.py +1 -0
- aetherius/stealth/fingerprint/patch.py +38 -0
- aetherius/stealth/fingerprint/profile.py +95 -0
- aetherius/stealth/gestures/__init__.py +1 -0
- aetherius/stealth/gestures/data/human_library.json +7107 -0
- aetherius/stealth/gestures/library.py +126 -0
- aetherius/stealth/gestures/seed.py +97 -0
- aetherius/stealth/humanizer/__init__.py +1 -0
- aetherius/stealth/humanizer/input.py +89 -0
- aetherius/stealth/humanizer/keyboard.py +75 -0
- aetherius/stealth/humanizer/mouse.py +153 -0
- aetherius/stealth/humanizer/scroll.py +55 -0
- aetherius/stealth/humanizer/timing.py +54 -0
- aetherius/stealth/ml/__init__.py +1 -0
- aetherius/stealth/ml/fingerprint_model.py +1 -0
- aetherius/stealth/ml/motion_model.py +1 -0
- aetherius/stealth/policy.py +133 -0
- aetherius/stealth/session/__init__.py +1 -0
- aetherius/stealth/session/store.py +35 -0
- aetherius/stealth/session/warmup.py +52 -0
- aetherius/version.py +3 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
- aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
- aetherius-0.2.0.dist-info/METADATA +606 -0
- aetherius-0.2.0.dist-info/RECORD +154 -0
- aetherius-0.2.0.dist-info/WHEEL +4 -0
- aetherius-0.2.0.dist-info/entry_points.txt +2 -0
- 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())
|