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,132 @@
|
|
|
1
|
+
"""Daemon control (start and stop) and configuration.
|
|
2
|
+
|
|
3
|
+
Starts and stops the local daemon (`aetherius serve`) without leaving the terminal, and shows its
|
|
4
|
+
bind address and auth state. The daemon is session-scoped: it keeps running as you navigate the
|
|
5
|
+
Console and is stopped when the Console exits. For a daemon that outlives the Console, run
|
|
6
|
+
`aetherius serve` in its own terminal instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import TYPE_CHECKING, cast
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from textual import work
|
|
17
|
+
from textual.app import ComposeResult
|
|
18
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
19
|
+
from textual.css.query import NoMatches
|
|
20
|
+
from textual.screen import Screen
|
|
21
|
+
from textual.widgets import Button, Footer, Header, Static
|
|
22
|
+
|
|
23
|
+
from ..theme import LAUREL, STONE, starred
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from ..app import AetheriusConsoleApp
|
|
27
|
+
from ..daemon_control import DaemonController
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SettingsScreen(Screen[None]):
|
|
31
|
+
"""Start and stop the local daemon, and see its configuration."""
|
|
32
|
+
|
|
33
|
+
DEFAULT_CSS = """
|
|
34
|
+
SettingsScreen #settings-status, SettingsScreen #settings-config {
|
|
35
|
+
padding: 1 2 0 2;
|
|
36
|
+
}
|
|
37
|
+
SettingsScreen #settings-start, SettingsScreen #settings-stop {
|
|
38
|
+
border: double $accent;
|
|
39
|
+
background: $panel;
|
|
40
|
+
color: $accent;
|
|
41
|
+
text-style: bold;
|
|
42
|
+
padding: 0 3;
|
|
43
|
+
}
|
|
44
|
+
SettingsScreen #settings-start:hover, SettingsScreen #settings-stop:hover {
|
|
45
|
+
background: $accent 20%;
|
|
46
|
+
}
|
|
47
|
+
SettingsScreen #settings-start:disabled, SettingsScreen #settings-stop:disabled {
|
|
48
|
+
color: $text-muted;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
yield Header()
|
|
54
|
+
with VerticalScroll(classes="console-body"):
|
|
55
|
+
yield Static(starred("Settings"), classes="console-title")
|
|
56
|
+
yield Static(
|
|
57
|
+
"Start or stop the local daemon that exposes the engine over HTTP and WebSocket. "
|
|
58
|
+
"It binds to loopback and is stopped when the Console exits.",
|
|
59
|
+
classes="console-subtitle",
|
|
60
|
+
)
|
|
61
|
+
yield Static(id="settings-status")
|
|
62
|
+
yield Static(id="settings-config")
|
|
63
|
+
with Horizontal(classes="run-actions"):
|
|
64
|
+
yield Button("✦ Start daemon ✦", id="settings-start", variant="primary")
|
|
65
|
+
yield Button("Stop daemon", id="settings-stop")
|
|
66
|
+
yield Static(
|
|
67
|
+
"Once running, drive it from any language via the SDKs "
|
|
68
|
+
"(see docs/daemon.md). Configure host/port/token with AETHERIUS_DAEMON_*.",
|
|
69
|
+
classes="console-footer-hint",
|
|
70
|
+
)
|
|
71
|
+
yield Footer()
|
|
72
|
+
|
|
73
|
+
def on_mount(self) -> None:
|
|
74
|
+
self._refresh()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def _controller(self) -> DaemonController:
|
|
78
|
+
return cast("AetheriusConsoleApp", self.app).daemon
|
|
79
|
+
|
|
80
|
+
def _refresh(self, health: str = "") -> None:
|
|
81
|
+
"""Repaint status, config and button states from the controller. Tolerant of a popped screen."""
|
|
82
|
+
controller = self._controller
|
|
83
|
+
running = controller.is_running()
|
|
84
|
+
config = controller.config
|
|
85
|
+
try:
|
|
86
|
+
status = self.query_one("#settings-status", Static)
|
|
87
|
+
if running:
|
|
88
|
+
line = Text.assemble(("● running", f"bold {LAUREL}"), f" {controller.base_url}")
|
|
89
|
+
if health:
|
|
90
|
+
line.append(f" · {health}")
|
|
91
|
+
status.update(line)
|
|
92
|
+
else:
|
|
93
|
+
status.update(Text("● stopped", style=f"bold {STONE}"))
|
|
94
|
+
self.query_one("#settings-config", Static).update(
|
|
95
|
+
f"host {config.host} · port {config.port} · "
|
|
96
|
+
f"token {'set' if config.token else 'none'}"
|
|
97
|
+
)
|
|
98
|
+
self.query_one("#settings-start", Button).disabled = running
|
|
99
|
+
self.query_one("#settings-stop", Button).disabled = not running
|
|
100
|
+
except NoMatches:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
104
|
+
if event.button.id == "settings-start":
|
|
105
|
+
self._controller.start()
|
|
106
|
+
self.app.notify(f"Daemon starting on {self._controller.base_url}…", timeout=4)
|
|
107
|
+
self._refresh()
|
|
108
|
+
self._probe_health()
|
|
109
|
+
elif event.button.id == "settings-stop":
|
|
110
|
+
self._controller.stop()
|
|
111
|
+
self.app.notify("Daemon stopped.", timeout=3)
|
|
112
|
+
self._refresh()
|
|
113
|
+
|
|
114
|
+
@work(thread=True, exclusive=True)
|
|
115
|
+
def _probe_health(self) -> None:
|
|
116
|
+
"""Poll health off the UI thread until the daemon answers or exits."""
|
|
117
|
+
for _ in range(50):
|
|
118
|
+
if self._controller.healthy():
|
|
119
|
+
self.app.call_from_thread(self._on_healthy)
|
|
120
|
+
return
|
|
121
|
+
if not self._controller.is_running():
|
|
122
|
+
self.app.call_from_thread(self._on_exited)
|
|
123
|
+
return
|
|
124
|
+
time.sleep(0.1)
|
|
125
|
+
|
|
126
|
+
def _on_healthy(self) -> None:
|
|
127
|
+
self._refresh(health="healthy")
|
|
128
|
+
self.app.notify("Daemon is healthy.", timeout=3)
|
|
129
|
+
|
|
130
|
+
def _on_exited(self) -> None:
|
|
131
|
+
self._refresh()
|
|
132
|
+
self.app.notify("Daemon failed to start.", severity="error", timeout=6)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Generate SVG screenshots of the Console for the documentation.
|
|
2
|
+
|
|
3
|
+
Dev-only utility (never imported at runtime): it drives the Textual app **headlessly** through
|
|
4
|
+
``run_test``/``Pilot`` — the same mechanism the tests use — and exports each key screen to a
|
|
5
|
+
deterministic SVG under ``docs/screenshots/``. Determinism matters so the committed files change only
|
|
6
|
+
when the UI actually changes: Rich stamps every export with a random id, which we normalise to a
|
|
7
|
+
stable per-file token, and the external web-font ``@font-face`` blocks are stripped so each SVG is
|
|
8
|
+
self-contained. Regenerate with ``make screenshots`` after any Console UI change.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Awaitable, Callable
|
|
17
|
+
|
|
18
|
+
from textual.pilot import Pilot
|
|
19
|
+
from textual.widgets import Select
|
|
20
|
+
|
|
21
|
+
from .app import AetheriusConsoleApp
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .screens.builder.screen import BlueprintStudioScreen
|
|
25
|
+
|
|
26
|
+
# Repo root, resolved from this module so paths don't depend on the working directory.
|
|
27
|
+
_REPO = Path(__file__).resolve().parents[3]
|
|
28
|
+
_EXAMPLE = _REPO / "examples" / "vector" / "ukit-planning-week.blueprint.json"
|
|
29
|
+
|
|
30
|
+
Setup = Callable[[AetheriusConsoleApp, Pilot[None]], Awaitable[None]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _home(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
34
|
+
pass # HomeScreen is already pushed on mount
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _library(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
38
|
+
from .screens.library import LibraryScreen
|
|
39
|
+
|
|
40
|
+
app.push_screen(LibraryScreen())
|
|
41
|
+
await pilot.pause()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _runs(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
45
|
+
from .screens.runs import RunsScreen
|
|
46
|
+
|
|
47
|
+
app.push_screen(RunsScreen(_EXAMPLE))
|
|
48
|
+
await pilot.pause()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _catalog(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
52
|
+
from .screens.catalog import CatalogScreen
|
|
53
|
+
|
|
54
|
+
app.push_screen(CatalogScreen())
|
|
55
|
+
await pilot.pause()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _recorder(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
59
|
+
from .screens.recorder import RecorderScreen
|
|
60
|
+
|
|
61
|
+
app.push_screen(RecorderScreen())
|
|
62
|
+
await pilot.pause()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _settings(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
66
|
+
from .screens.settings import SettingsScreen
|
|
67
|
+
|
|
68
|
+
app.push_screen(SettingsScreen())
|
|
69
|
+
await pilot.pause()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _open_studio_with_template(
|
|
73
|
+
app: AetheriusConsoleApp, pilot: Pilot[None]
|
|
74
|
+
) -> BlueprintStudioScreen:
|
|
75
|
+
from .screens.builder.screen import BlueprintStudioScreen
|
|
76
|
+
|
|
77
|
+
screen = BlueprintStudioScreen()
|
|
78
|
+
app.push_screen(screen)
|
|
79
|
+
await pilot.pause()
|
|
80
|
+
await pilot.pause()
|
|
81
|
+
screen.query_one("#studio-template", Select).value = "continuum.scrape"
|
|
82
|
+
await pilot.pause()
|
|
83
|
+
screen._load_template()
|
|
84
|
+
await pilot.pause()
|
|
85
|
+
await pilot.pause()
|
|
86
|
+
return screen
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _studio(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
90
|
+
await _open_studio_with_template(app, pilot)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _studio_preview(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
94
|
+
from textual.widgets import Button
|
|
95
|
+
|
|
96
|
+
screen = await _open_studio_with_template(app, pilot)
|
|
97
|
+
# Scroll the Save button into view: the frame then shows the tail of the validated JSON, the green
|
|
98
|
+
# "Valid — ready to save.", and the Save button — the flagship live-validation moment.
|
|
99
|
+
screen.query_one("#studio-save", Button).scroll_visible(animate=False)
|
|
100
|
+
await pilot.pause()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _studio_step_editor(app: AetheriusConsoleApp, pilot: Pilot[None]) -> None:
|
|
104
|
+
from .screens.builder.screen import BlueprintStudioScreen
|
|
105
|
+
from .screens.builder.step_editor import StepEditorModal
|
|
106
|
+
|
|
107
|
+
screen = BlueprintStudioScreen()
|
|
108
|
+
app.push_screen(screen)
|
|
109
|
+
await pilot.pause()
|
|
110
|
+
await pilot.pause()
|
|
111
|
+
modal = StepEditorModal("continuum")
|
|
112
|
+
app.push_screen(modal)
|
|
113
|
+
await pilot.pause()
|
|
114
|
+
modal.query_one("#step-action", Select).value = "fill"
|
|
115
|
+
await pilot.pause()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# slug -> (terminal size, setup). Sizes are chosen per screen for a legible, well-framed capture.
|
|
119
|
+
_SHOTS: list[tuple[str, tuple[int, int], Setup]] = [
|
|
120
|
+
("home", (92, 32), _home),
|
|
121
|
+
("library", (100, 26), _library),
|
|
122
|
+
("runs", (100, 34), _runs),
|
|
123
|
+
("catalog", (110, 26), _catalog),
|
|
124
|
+
("recorder", (100, 30), _recorder),
|
|
125
|
+
("settings", (100, 26), _settings),
|
|
126
|
+
("studio", (104, 50), _studio),
|
|
127
|
+
("studio-preview", (104, 40), _studio_preview),
|
|
128
|
+
("studio-step-editor", (100, 40), _studio_step_editor),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _normalize(svg: str, slug: str) -> str:
|
|
133
|
+
"""Make the exported SVG deterministic and self-contained.
|
|
134
|
+
|
|
135
|
+
Rich uses a random ``terminal-<n>`` id per export (only source of run-to-run churn); we pin it to
|
|
136
|
+
the slug. The two web-font ``@font-face`` blocks reference an external CDN (blocked on GitHub
|
|
137
|
+
anyway) — dropping them leaves the ``monospace`` fallback and keeps the file self-contained.
|
|
138
|
+
"""
|
|
139
|
+
svg = re.sub(r"terminal-\d+", f"terminal-{slug}", svg)
|
|
140
|
+
svg = re.sub(r"@font-face \{[^}]*\}", "", svg)
|
|
141
|
+
# Never bake the author's absolute checkout path (username, machine) into a committed doc asset;
|
|
142
|
+
# this also makes the output identical regardless of where the repo is cloned.
|
|
143
|
+
svg = svg.replace(str(_REPO), "/home/user/aetherius")
|
|
144
|
+
return svg
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _capture(slug: str, size: tuple[int, int], setup: Setup, out_dir: Path) -> Path:
|
|
148
|
+
app = AetheriusConsoleApp()
|
|
149
|
+
async with app.run_test(size=size) as pilot:
|
|
150
|
+
await pilot.pause()
|
|
151
|
+
await setup(app, pilot)
|
|
152
|
+
await pilot.pause()
|
|
153
|
+
svg = app.export_screenshot(title="Aetherius")
|
|
154
|
+
path = out_dir / f"{slug}.svg"
|
|
155
|
+
path.write_text(_normalize(svg, slug), encoding="utf-8")
|
|
156
|
+
return path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def capture_all(out_dir: Path) -> list[Path]:
|
|
160
|
+
"""Render every Console screen to ``out_dir/<slug>.svg`` and return the written paths."""
|
|
161
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
return [await _capture(slug, size, setup, out_dir) for slug, size, setup in _SHOTS]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main() -> None:
|
|
166
|
+
out_dir = _REPO / "docs" / "screenshots"
|
|
167
|
+
paths = asyncio.run(capture_all(out_dir))
|
|
168
|
+
for path in paths:
|
|
169
|
+
print(f"wrote {path.relative_to(_REPO)}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Consistent visual theme for the Console (palette, wordmark and ornaments).
|
|
2
|
+
|
|
3
|
+
Pure data and tiny ornament factories: a registered Textual Theme, the per-Act accent colors,
|
|
4
|
+
and the decorative pixel ornaments (mosaic frieze, wordmark, cloud bank, starfield) shared by
|
|
5
|
+
the screens. No widget logic here.
|
|
6
|
+
|
|
7
|
+
Art direction: nocturnal aether — the temple at night. Dark void sky, moonlit clouds, imperial
|
|
8
|
+
gold relics, tyrian violet for the veiled and the mystic, laurel green for triumph. Pixel-mosaic
|
|
9
|
+
friezes and starfields as details. Echoes the Latin name of the tool.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from textual.theme import Theme
|
|
17
|
+
|
|
18
|
+
# Palette: single source for the Theme, the Act badges and every decorative detail.
|
|
19
|
+
# Dominants requested by the user: pourpre, violet, dark green — readability kept by leaving
|
|
20
|
+
# body text near-white and letting the purples carry structure (frames, friezes), not prose.
|
|
21
|
+
VOID = "#141020" # violet-tinted night background
|
|
22
|
+
NIMBUS = "#1c1730" # twilight surface
|
|
23
|
+
PANEL = "#282045" # raised panels
|
|
24
|
+
MOONLIGHT = "#dcd7e8" # violet-tinged moonlight, main text
|
|
25
|
+
STONE = "#7f7a90" # weathered stone, muted and pending things
|
|
26
|
+
VIOLET = "#9d7bd8" # twilight violet, primary structure
|
|
27
|
+
TYRIAN = "#b0568c" # tyrian pourpre, the imperial dye, mystic accents
|
|
28
|
+
GOLD = "#d4af37" # imperial gold, reserved for stars and the wordmark
|
|
29
|
+
LAUREL = "#4e8a5a" # dark laurel green, success and vines
|
|
30
|
+
AMBER = "#d08a3e" # torchlight, warnings
|
|
31
|
+
POMPEIAN = "#c1564a" # pompeian red, errors
|
|
32
|
+
|
|
33
|
+
AETHERIUS_THEME = Theme(
|
|
34
|
+
name="aetherius",
|
|
35
|
+
primary=VIOLET,
|
|
36
|
+
secondary=TYRIAN,
|
|
37
|
+
accent=GOLD,
|
|
38
|
+
warning=AMBER,
|
|
39
|
+
error=POMPEIAN,
|
|
40
|
+
success=LAUREL,
|
|
41
|
+
foreground=MOONLIGHT,
|
|
42
|
+
background=VOID,
|
|
43
|
+
surface=NIMBUS,
|
|
44
|
+
panel=PANEL,
|
|
45
|
+
dark=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Each Act's own accent colour. It is only shown once the Act is runnable; a pending Act stays
|
|
49
|
+
# muted stone (see `act_color`), so a badge's colour always reflects real availability rather than
|
|
50
|
+
# a hand-maintained list that drifts as Acts land.
|
|
51
|
+
ACT_ACCENT: dict[str, str] = {
|
|
52
|
+
"vector": LAUREL,
|
|
53
|
+
"continuum": VIOLET,
|
|
54
|
+
"oracle": TYRIAN,
|
|
55
|
+
"phantom": AMBER,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def act_color(act: str, implemented: bool) -> str:
|
|
60
|
+
"""Badge colour for *act*: its own accent when runnable, muted stone while pending."""
|
|
61
|
+
return ACT_ACCENT.get(act, STONE) if implemented else STONE
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
ACT_LABELS: dict[str, str] = {
|
|
65
|
+
"vector": "I - Vector",
|
|
66
|
+
"continuum": "II - Continuum",
|
|
67
|
+
"oracle": "III - Oracle",
|
|
68
|
+
"phantom": "IV - Phantom",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Ornaments. The wordmark spells AETHERIVS (Roman V), generated once with pyfiglet
|
|
72
|
+
# (font "ansi_shadow", dev-time only — never a runtime dependency) and frozen here.
|
|
73
|
+
# All rows are 69 columns, guarded by a test. Regenerate with:
|
|
74
|
+
# python -c "import pyfiglet; print(pyfiglet.figlet_format('AETHERIVS', font='ansi_shadow'))"
|
|
75
|
+
WORDMARK: tuple[str, ...] = (
|
|
76
|
+
" █████╗ ███████╗████████╗██╗ ██╗███████╗██████╗ ██╗██╗ ██╗███████╗",
|
|
77
|
+
"██╔══██╗██╔════╝╚══██╔══╝██║ ██║██╔════╝██╔══██╗██║██║ ██║██╔════╝",
|
|
78
|
+
"███████║█████╗ ██║ ███████║█████╗ ██████╔╝██║██║ ██║███████╗",
|
|
79
|
+
"██╔══██║██╔══╝ ██║ ██╔══██║██╔══╝ ██╔══██╗██║╚██╗ ██╔╝╚════██║",
|
|
80
|
+
"██║ ██║███████╗ ██║ ██║ ██║███████╗██║ ██║██║ ╚████╔╝ ███████║",
|
|
81
|
+
"╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝",
|
|
82
|
+
)
|
|
83
|
+
MOTTO = "per nubes, ad aethera"
|
|
84
|
+
STARFIELD = "˚ ✦ · ✦ · ˚ ✦ · ˚ · ✦ · ˚ ✦ · ✦ ˚"
|
|
85
|
+
|
|
86
|
+
# The hedera (Latin for ivy) is the leaf ornament Romans carved into inscriptions as a
|
|
87
|
+
# separator. It is the closest thing to a vine a character grid can honestly offer.
|
|
88
|
+
HEDERA = "❦"
|
|
89
|
+
HEDERA_ALT = "❧"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def frieze(width: int = 69) -> str:
|
|
93
|
+
"""Horizontal mosaic frieze strip, the Console's recurring pixel ornament."""
|
|
94
|
+
return ("▚▞" * ((width + 1) // 2))[:width]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def frieze_column(height: int) -> str:
|
|
98
|
+
"""Vertical mosaic frieze (one glyph per line), for flanking content like temple columns."""
|
|
99
|
+
return "\n".join("▚" if i % 2 == 0 else "▞" for i in range(height))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def garland(width: int = 45) -> str:
|
|
103
|
+
"""Ivy garland: hederae threaded on a vine line, the Console's vine ornament."""
|
|
104
|
+
inner = "─" * max((width - 5) // 2, 1)
|
|
105
|
+
return f"{HEDERA_ALT}{inner} {HEDERA} {inner}{HEDERA_ALT}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def starred(title: str) -> Text:
|
|
109
|
+
"""Title flanked by small gold stars — the recurring ornament for screen titles."""
|
|
110
|
+
text = Text()
|
|
111
|
+
text.append("✦ ", style=GOLD)
|
|
112
|
+
text.append(title)
|
|
113
|
+
text.append(" ✦", style=GOLD)
|
|
114
|
+
return text
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reusable Console widgets."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Live event and log stream widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from textual.widgets import RichLog
|
|
8
|
+
|
|
9
|
+
from ...core.events.models import EventType, RunEvent
|
|
10
|
+
from ...core.events.sinks import format_event
|
|
11
|
+
from ..theme import AMBER, POMPEIAN, STONE
|
|
12
|
+
|
|
13
|
+
_LEVEL_STYLE: dict[str, str] = {
|
|
14
|
+
"debug": STONE,
|
|
15
|
+
"info": "",
|
|
16
|
+
"warning": AMBER,
|
|
17
|
+
"error": f"bold {POMPEIAN}",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventLog(RichLog):
|
|
22
|
+
"""Scrolling, color-coded view of the RunEvents emitted by a Blueprint run."""
|
|
23
|
+
|
|
24
|
+
# Fixed height with internal scroll: the stream must never push the result summary
|
|
25
|
+
# (or anything else) out of the screen.
|
|
26
|
+
DEFAULT_CSS = """
|
|
27
|
+
EventLog {
|
|
28
|
+
height: 12;
|
|
29
|
+
border: double $primary;
|
|
30
|
+
padding: 0 1;
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, **kwargs: object) -> None:
|
|
35
|
+
super().__init__(markup=False, wrap=True, auto_scroll=True, **kwargs) # type: ignore[arg-type]
|
|
36
|
+
|
|
37
|
+
def write_event(self, event: RunEvent) -> None:
|
|
38
|
+
level = event.level or ("debug" if event.type == EventType.DEBUG else "info")
|
|
39
|
+
if event.type == EventType.ERROR:
|
|
40
|
+
level = "error"
|
|
41
|
+
elif event.type == EventType.DONE and event.data.get("status") == "success":
|
|
42
|
+
level = "info"
|
|
43
|
+
style = _LEVEL_STYLE.get(level, "")
|
|
44
|
+
self.write(Text(format_event(event), style=style))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Generic form widget rendering fields from a Blueprint's inputs and secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.widgets import Input, Label, Switch
|
|
10
|
+
|
|
11
|
+
from ...core.blueprint.models import InputSpec
|
|
12
|
+
|
|
13
|
+
_INPUT_ID_PREFIX = "bp-input-"
|
|
14
|
+
_SECRET_ID_PREFIX = "bp-secret-"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BlueprintInputForm(Vertical):
|
|
18
|
+
"""Renders one field per declared Blueprint input and secret.
|
|
19
|
+
|
|
20
|
+
Boolean inputs get a Switch; everything else gets a text Input (secrets are masked).
|
|
21
|
+
``collect()`` reads the current values back out; ``validation_errors()`` runs a client-side
|
|
22
|
+
pre-check for required fields before a run is allowed to start.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# height: auto everywhere: fields must keep their natural height and let the screen scroll,
|
|
26
|
+
# never get squeezed into overlapping fractions when the terminal is short.
|
|
27
|
+
DEFAULT_CSS = """
|
|
28
|
+
BlueprintInputForm {
|
|
29
|
+
height: auto;
|
|
30
|
+
padding: 1 1 0 1;
|
|
31
|
+
}
|
|
32
|
+
BlueprintInputForm .form-field {
|
|
33
|
+
height: auto;
|
|
34
|
+
padding-bottom: 1;
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
inputs: dict[str, InputSpec],
|
|
41
|
+
secrets: list[str],
|
|
42
|
+
available_secrets: set[str] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._inputs = inputs
|
|
46
|
+
self._secrets = secrets
|
|
47
|
+
# Secrets already resolvable from the environment (.env): shown optional, not required.
|
|
48
|
+
self._available_secrets = available_secrets or set()
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
for name, spec in self._inputs.items():
|
|
52
|
+
label = name + (" *" if spec.required else "")
|
|
53
|
+
if spec.description:
|
|
54
|
+
label += f" - {spec.description}"
|
|
55
|
+
with Vertical(classes="form-field"):
|
|
56
|
+
yield Label(label)
|
|
57
|
+
if spec.type == "boolean":
|
|
58
|
+
yield Switch(value=bool(spec.default), id=_INPUT_ID_PREFIX + name)
|
|
59
|
+
else:
|
|
60
|
+
default = "" if spec.default is None else str(spec.default)
|
|
61
|
+
yield Input(
|
|
62
|
+
value=default,
|
|
63
|
+
placeholder=spec.type,
|
|
64
|
+
id=_INPUT_ID_PREFIX + name,
|
|
65
|
+
)
|
|
66
|
+
for name in self._secrets:
|
|
67
|
+
from_env = name in self._available_secrets
|
|
68
|
+
label = f"{name} (secret · loaded from .env)" if from_env else f"{name} (secret)"
|
|
69
|
+
placeholder = "leave blank to use .env value" if from_env else ""
|
|
70
|
+
with Vertical(classes="form-field"):
|
|
71
|
+
yield Label(label)
|
|
72
|
+
yield Input(password=True, placeholder=placeholder, id=_SECRET_ID_PREFIX + name)
|
|
73
|
+
|
|
74
|
+
def collect(self) -> tuple[dict[str, Any], dict[str, str]]:
|
|
75
|
+
"""Read the current form values into (inputs, secrets) dicts."""
|
|
76
|
+
input_values: dict[str, Any] = {}
|
|
77
|
+
for name, spec in self._inputs.items():
|
|
78
|
+
widget_id = "#" + _INPUT_ID_PREFIX + name
|
|
79
|
+
if spec.type == "boolean":
|
|
80
|
+
input_values[name] = self.query_one(widget_id, Switch).value
|
|
81
|
+
else:
|
|
82
|
+
value = self.query_one(widget_id, Input).value
|
|
83
|
+
if value:
|
|
84
|
+
input_values[name] = value
|
|
85
|
+
|
|
86
|
+
secret_values: dict[str, str] = {}
|
|
87
|
+
for name in self._secrets:
|
|
88
|
+
value = self.query_one("#" + _SECRET_ID_PREFIX + name, Input).value
|
|
89
|
+
if value:
|
|
90
|
+
secret_values[name] = value
|
|
91
|
+
|
|
92
|
+
return input_values, secret_values
|
|
93
|
+
|
|
94
|
+
def validation_errors(self) -> list[str]:
|
|
95
|
+
"""Client-side pre-check; the engine's own input resolution remains authoritative."""
|
|
96
|
+
input_values, _ = self.collect()
|
|
97
|
+
errors = []
|
|
98
|
+
for name, spec in self._inputs.items():
|
|
99
|
+
if spec.required and name not in input_values:
|
|
100
|
+
errors.append(f"Missing required input '{name}'.")
|
|
101
|
+
return errors
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Syntax-highlighted, validated JSON preview widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonPreview(Static):
|
|
14
|
+
"""Read-only, syntax-highlighted rendering of arbitrary JSON-serializable data."""
|
|
15
|
+
|
|
16
|
+
def show(self, data: Any) -> None:
|
|
17
|
+
text = json.dumps(data, indent=2, default=str, ensure_ascii=False)
|
|
18
|
+
self.update(Syntax(text, "json", theme="ansi_dark", word_wrap=True))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Renders the final Result of a Blueprint run: status, per-step outcomes, and outputs."""
|
|
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.widgets import DataTable, Static
|
|
10
|
+
|
|
11
|
+
from ...core.runtime.result import Result, RunStatus
|
|
12
|
+
from ..theme import AMBER, LAUREL, POMPEIAN
|
|
13
|
+
from .json_preview import JsonPreview
|
|
14
|
+
|
|
15
|
+
_STATUS_STYLE: dict[RunStatus, str] = {
|
|
16
|
+
RunStatus.SUCCESS: f"bold {LAUREL}",
|
|
17
|
+
RunStatus.FAILED: f"bold {POMPEIAN}",
|
|
18
|
+
RunStatus.PARTIAL: f"bold {AMBER}",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunSummary(Vertical):
|
|
23
|
+
"""Hidden until `show(result)` reveals it once a run completes; `reset()` hides it again."""
|
|
24
|
+
|
|
25
|
+
DEFAULT_CSS = """
|
|
26
|
+
RunSummary {
|
|
27
|
+
display: none;
|
|
28
|
+
height: auto;
|
|
29
|
+
border: double $success;
|
|
30
|
+
padding: 0 1 1 1;
|
|
31
|
+
}
|
|
32
|
+
RunSummary.-revealed {
|
|
33
|
+
display: block;
|
|
34
|
+
}
|
|
35
|
+
#run-summary-status {
|
|
36
|
+
height: auto;
|
|
37
|
+
padding: 1 0;
|
|
38
|
+
}
|
|
39
|
+
#run-summary-steps {
|
|
40
|
+
height: auto;
|
|
41
|
+
max-height: 12;
|
|
42
|
+
}
|
|
43
|
+
#run-summary-outputs {
|
|
44
|
+
height: auto;
|
|
45
|
+
margin-top: 1;
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
yield Static("", id="run-summary-status")
|
|
51
|
+
yield DataTable(id="run-summary-steps", cursor_type="none")
|
|
52
|
+
yield JsonPreview(id="run-summary-outputs")
|
|
53
|
+
|
|
54
|
+
def on_mount(self) -> None:
|
|
55
|
+
self.query_one("#run-summary-steps", DataTable).add_columns(
|
|
56
|
+
"Step", "Action", "Status", "Duration"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def show(self, result: Result) -> None:
|
|
60
|
+
status_line = self.query_one("#run-summary-status", Static)
|
|
61
|
+
style = _STATUS_STYLE.get(result.status, "")
|
|
62
|
+
text = Text(f"{result.status.value} — {result.duration_ms:.1f} ms", style=style)
|
|
63
|
+
if result.error:
|
|
64
|
+
text.append(f"\n{result.error}", style=POMPEIAN)
|
|
65
|
+
status_line.update(text)
|
|
66
|
+
|
|
67
|
+
steps = self.query_one("#run-summary-steps", DataTable)
|
|
68
|
+
steps.clear()
|
|
69
|
+
for step in result.step_results:
|
|
70
|
+
steps.add_row(
|
|
71
|
+
step.step_id or "-",
|
|
72
|
+
step.action,
|
|
73
|
+
Text(step.status.value, style=_STATUS_STYLE.get(step.status, "")),
|
|
74
|
+
f"{step.duration_ms:.1f} ms",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self.query_one("#run-summary-outputs", JsonPreview).show(result.outputs)
|
|
78
|
+
self.add_class("-revealed")
|
|
79
|
+
|
|
80
|
+
def reset(self) -> None:
|
|
81
|
+
"""Hide the previous result while a new run is in flight."""
|
|
82
|
+
self.remove_class("-revealed")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Engine-agnostic backbone: blueprint model, action dictionary, runtime, extraction, events."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The action dictionary: base types, registry and the concrete action groups shared across Acts."""
|