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,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."""