From 32cb22343d20eb23c84f80b5d36d161f8c8fba87 Mon Sep 17 00:00:00 2001 From: Elliot Chen <2340896+cyfyifanchen@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:33:44 +0800 Subject: [PATCH] feat(cli): add EverOS demo TUI (#298) * feat: add EverOS demo TUI * docs: add EverOS demo quick start * style(cli): elevate EverOS demo TUI * style(cli): align demo palette with poster * fix(cli): round demo sphere animation loop * fix(cli): refine EverOS demo TUI layout * fix(cli): use uniform demo sphere dots * fix(cli): refine EverOS demo sphere media * fix(docs): smooth EverOS demo animation * fix(docs): raise demo animation frame rate * docs(readme): note external demo animation hosting * fix(docs): optimize demo animation size * feat(cli): add demo confetti success state * feat(cli): make demo playable * test(cli): normalize demo help ansi output * docs(readme): streamline demo and roadmap sections * docs(readme): restore star history section * refactor(tui): move demo implementation out of cli --- README.md | 243 ++++-------- docs/everos-demo.md | 51 +++ pyproject.toml | 1 + src/everos/entrypoints/cli/commands/demo.py | 108 ++++++ src/everos/entrypoints/cli/main.py | 3 +- src/everos/entrypoints/tui/__init__.py | 1 + src/everos/entrypoints/tui/demo/__init__.py | 1 + src/everos/entrypoints/tui/demo/app.py | 345 ++++++++++++++++++ src/everos/entrypoints/tui/demo/data.py | 75 ++++ .../entrypoints/tui/demo/readme_media.py | 267 ++++++++++++++ .../entrypoints/tui/demo/widgets/__init__.py | 1 + .../entrypoints/tui/demo/widgets/sphere.py | 322 ++++++++++++++++ .../test_cli/test_demo_command.py | 106 ++++++ .../test_entrypoints/test_cli/test_main.py | 1 + .../test_entrypoints/test_tui/__init__.py | 1 + .../test_tui/test_demo_app.py | 122 +++++++ .../test_tui/test_demo_data.py | 29 ++ .../test_tui/test_demo_readme_media.py | 117 ++++++ .../test_tui/test_demo_sphere.py | 143 ++++++++ uv.lock | 57 +++ 20 files changed, 1816 insertions(+), 178 deletions(-) create mode 100644 docs/everos-demo.md create mode 100644 src/everos/entrypoints/cli/commands/demo.py create mode 100644 src/everos/entrypoints/tui/__init__.py create mode 100644 src/everos/entrypoints/tui/demo/__init__.py create mode 100644 src/everos/entrypoints/tui/demo/app.py create mode 100644 src/everos/entrypoints/tui/demo/data.py create mode 100644 src/everos/entrypoints/tui/demo/readme_media.py create mode 100644 src/everos/entrypoints/tui/demo/widgets/__init__.py create mode 100644 src/everos/entrypoints/tui/demo/widgets/sphere.py create mode 100644 tests/unit/test_entrypoints/test_cli/test_demo_command.py create mode 100644 tests/unit/test_entrypoints/test_tui/__init__.py create mode 100644 tests/unit/test_entrypoints/test_tui/test_demo_app.py create mode 100644 tests/unit/test_entrypoints/test_tui/test_demo_data.py create mode 100644 tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py create mode 100644 tests/unit/test_entrypoints/test_tui/test_demo_sphere.py diff --git a/README.md b/README.md index 6b39c11..35f1ea5 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,12 @@
-- [EverOS 1.0.0](#everos-100) +- [Upcoming Features](#upcoming-features) - [Why Ever OS](#why-ever-os) - [Quick Start](#quick-start) - [Use Cases](#use-cases) -- [Architecture At A Glance](#architecture-at-a-glance) -- [Storage Layout](#storage-layout) -- [Features](#features) -- [Project Structure](#project-structure) - [Documentation](#documentation) -- [Watch EverOS](#watch-everos) +- [Star Us](#star-us) - [EverMind Ecosystems](#evermind-ecosystems) - [Contributing](#contributing) @@ -39,19 +35,16 @@ -## EverOS 1.0.0 +## Upcoming Features > [!IMPORTANT] > -> **EverOS 1.0.0 is a major release for self-evolving memory.** It brings a -> local-first runtime, Markdown as the source of truth, hybrid retrieval, -> multimodal ingestion, user and agent memory scopes, and modular algorithms -> through [EverAlgo](https://github.com/EverMind-AI/EverAlgo). +> **Knowledge Wiki:** editable, source-backed Markdown knowledge pages built +> from memory. > -> **Coming next:** Knowledge Wiki will turn memory into editable, -> source-backed Markdown knowledge pages. Reflection will run when the -> system is idle or offline to connect signals, compress -> history, and improve profiles and skills between sessions. +> **Reflection:** idle-time and offline memory evolution that connects +> signals, compresses history, and improves profiles and skills between +> sessions.
@@ -63,11 +56,11 @@ ## Why Ever OS -EverOS is the local memory operating system for agents and makers. It gives -one portable memory layer across coding assistants, apps, devices, and -workflows. Today it stores conversations, files, and agent trajectories as -readable Markdown, then syncs local SQLite and LanceDB indexes for fast -retrieval and self-evolving reuse. +EverOS is a Python library and local-first memory runtime for agents and +makers. It gives one portable memory layer across coding assistants, apps, +devices, and workflows from day one. It stores conversations, files, and agent +trajectories as readable Markdown, then syncs local SQLite and LanceDB indexes +for fast retrieval and self-evolving reuse. @@ -100,16 +93,6 @@ retrieval and self-evolving reuse. - - - - - - - - - -
✅ Search by user_id, agent_id, app_id, project_id, and session_id ❌ Usually app, namespace, tenant, thread, or graph scoped
Knowledge Wiki✅ Coming next: editable, source-backed Markdown knowledge pages built from memory❌ Usually retrieval, graph, dashboards, or generated summaries instead of editable source-backed pages
Reflection✅ Coming next: Reflection that runs when the system is idle or offline to connect signals, compress history, and improve profiles and skills between sessions❌ Usually online read/write APIs, retrieval records, or summaries rather than idle-time memory consolidation

@@ -122,7 +105,8 @@ retrieval and self-evolving reuse. ## Quick Start -> Goal: start EverOS, write one memory, and search it back. +> Goal: play with the memory visualizer first, then start EverOS, write one +> real memory, and search it back. ### 0. Prerequisites @@ -138,7 +122,43 @@ uv pip install everos # or: pip install everos ``` -### 2. Configure +### 2. Play With The Demo + +Run this before configuring API keys or starting the server: + +```bash +everos demo +``` + +The command asks for one memory and one recall question, then opens a +full-screen terminal UI. This is an educational visualizer: it is hardcoded, +local to the CLI, and does not connect to the EverOS server. Its job is to make +the memory lifecycle visible: conversation -> memory sphere -> recall -> source +proof -> confetti. See [docs/everos-demo.md](docs/everos-demo.md) for the demo +scope and TUI source layout. + +The sphere moves through ingest, extraction, indexing, recall, source reveal, +and a confetti burst after the first memory lands. Press `r` to replay and `q` +to quit. The optimized animated preview is externally hosted so the repository +stays light: + +

+ Animated EverOS demo preview showing the memory sphere moving through recall and confetti states +

+ +For the looping showroom view used in README media, run: + +```bash +everos demo --cinematic +``` + +If your shell is not interactive, or you want a copyable preview, use: + +```bash +everos demo --plain +``` + +### 3. Configure Generate a starter `.env` file, then fill the four API key slots shown in the generated comments. Only two distinct keys are needed with the defaults: @@ -153,7 +173,7 @@ cp .env.example .env `everos init` writes `./.env` by default. Use `everos init --xdg` to write `${XDG_CONFIG_HOME:-~/.config}/everos/.env` instead. -### 3. Start EverOS +### 4. Start EverOS ```bash everos server start @@ -177,7 +197,7 @@ The endpoint stack is OpenAI-protocol compatible (OpenAI / OpenRouter / vLLM / Ollama / DeepInfra) - override `*__BASE_URL` in the generated `.env` to point at any of them. -### 4. Try Your First Memory +### 5. Try Your First Memory Add a tiny conversation: @@ -541,7 +561,7 @@ A context-native AI wearable that listens to everyday life and converts conversa #### Legacy OpenClaw Agent Memory -Archived pre-1.0.0 plugin reference. New integrations should use the EverOS 1.0.0 API. +Archived pre-1.0.0 plugin reference. New integrations should use the current EverOS API. [Learn more](docs/migration-to-1.0.0.md) @@ -615,115 +635,15 @@ Explore stored entities and relationships in a graph interface. Frontend demo; b
-## Architecture At A Glance - -``` -┌───────────────────────────────────────────────┐ -│ entrypoints/ (CLI + HTTP API) │ presentation -├───────────────────────────────────────────────┤ -│ service/ (use cases: memorize/retrieve) │ application -├───────────────────────────────────────────────┤ -│ memory/ (extract + search + cascade) │ domain -├───────────────────────────────────────────────┤ -│ infra/ (markdown / sqlite / lancedb) │ infrastructure -└───────────────────────────────────────────────┘ - ↑ ↑ - component/ core/ - (LLM/Embedding) (observability/lifespan) -``` - -DDD 5 layers, single-direction dependency. See [docs/architecture.md](docs/architecture.md). - -
-
- -[![](https://img.shields.io/badge/-Back_to_top-gray?style=flat-square)](#readme-top) - -
- -## Storage Layout - -``` -~/.everos/ -├── default_app/ # app_id ("default" → "default_app" on disk) -│ └── default_project/ # project_id ("default" → "default_project") -│ ├── users// -│ │ ├── user.md # profile -│ │ ├── episodes/ # daily-log episodes (visible) -│ │ ├── .atomic_facts/ # nested facts (dotfile-hidden) -│ │ └── .foresights/ # predictive memory (dotfile-hidden) -│ └── agents// -│ ├── agent.md -│ ├── .cases/ # one task case per entry -│ └── skills/ # named procedural memories -├── .index/ # derived indexes (rebuildable from md) -│ ├── sqlite/system.db # state + queue + audit -│ └── lancedb/*.lance/ # vector + BM25 + scalar -└── .tmp/ # transient working files -``` - -Open any `//users//` folder in Obsidian — your -agent's brain is just files. The dotfile directories (`.atomic_facts/`, -`.foresights/`, `.cases/`) stay hidden by default so the visible folder -is the user-facing memory surface, while extracted derivatives sit -quietly alongside. - -
-
- -[![](https://img.shields.io/badge/-Back_to_top-gray?style=flat-square)](#readme-top) - -
- -## Features - -- **Hybrid retrieval**: BM25 + cosine vector ANN + scalar filters, backed by LanceDB -- **Cascade index sync**: edit a `.md` → file watcher → entry-level diff → LanceDB sync, sub-second -- **Multi-source extraction**: conversations / agent trajectories / file knowledge -- **Dual-track memory**: user-track (Episodes / Profiles) + agent-track (Cases / Skills) -- **Async-first**: full asyncio, single event loop -- **Multi-modal**: text + small image / audio inline; large media via S3/OSS reference - -
-
- -[![](https://img.shields.io/badge/-Back_to_top-gray?style=flat-square)](#readme-top) - -
- -## Project Structure - -``` -everos/ # repo root -├── src/everos/ # main package (src layout) -│ ├── entrypoints/ # cli + api -│ ├── service/ # use case orchestration -│ ├── memory/ # domain: extract + search + cascade + prompt_slots -│ ├── infra/ # storage: markdown + lancedb + sqlite -│ ├── component/ # cross-cutting: llm / embedding / config / utils -│ ├── core/ # runtime: observability / lifespan / context -│ └── config/ # configuration data + Settings schema -├── tests/ # unit / integration / golden / fixtures -├── docs/ # design docs -└── .claude/ # team-shared rules + skills (auto-loaded by Claude Code) -``` -
-
- -[![](https://img.shields.io/badge/-Back_to_top-gray?style=flat-square)](#readme-top) - -
- ## Documentation -- [docs/overview.md](docs/overview.md) — Project overview & vision -- [docs/architecture.md](docs/architecture.md) — DDD layered architecture & dependency rules -- [docs/engineering.md](docs/engineering.md) — Engineering & dev-efficiency infrastructure (CI / tooling / Claude Code) +- [docs/everos-demo.md](docs/everos-demo.md) — Demo scope and TUI source layout +- [docs/how-memory-works.md](docs/how-memory-works.md) — Markdown, SQLite, LanceDB, and recall flow - [docs/use-cases.md](docs/use-cases.md) — Full use-case gallery and integration examples -- [docs/migration-to-1.0.0.md](docs/migration-to-1.0.0.md) — Legacy API and infrastructure migration notes +- [docs/engineering.md](docs/engineering.md) — Engineering and CI tooling +- [docs/migration-to-1.0.0.md](docs/migration-to-1.0.0.md) — Legacy API migration notes - [CHANGELOG.md](CHANGELOG.md) — Release notes - [CONTRIBUTING.md](CONTRIBUTING.md) — How to contribute -- [.claude/rules/](.claude/rules/) — Detailed coding conventions (auto-loaded by Claude Code)
@@ -732,42 +652,11 @@ everos/ # repo root
+## Star Us - -## Watch EverOS - -EverOS 1.0.0 is the first release of a larger memory-system roadmap. -Watch this repository for upcoming work on deeper idle-time and offline evolution, -benchmark releases, and more real-world agent integrations. - - - - - - -
-Knowledge Wiki
-
-Turns scattered episodes, files, facts, and agent traces into source-backed -Markdown pages for people, projects, topics, decisions, and workflows. Memory -becomes something users can read, correct, link, version, and open in their -existing Markdown tools. -
-Reflection
-
-Runs when the system is idle or offline to revisit stored memory, connect weak -signals, compress noisy history into durable patterns, and improve profiles and -skills. The agent gets better between active sessions, not only while you prompt -it. -
- -Most memory systems stop at chat history, opaque profiles, or vector recall. -EverOS keeps memory local, Markdown-native, auditable, and self-evolving: raw -memory stays readable, derived knowledge becomes a wiki, and Reflection turns -repeated experience into more useful long-term behavior. - -If EverOS is useful to your agent stack, starring the repo helps more -builders discover it. +If EverOS is useful to your agent stack, please star the repo. It helps more +builders discover the project and gives the memory ecosystem a stronger signal +to keep improving. ### Star History @@ -789,7 +678,7 @@ EverMind is an open-source ecosystem for long-term memory, self-evolving agents, EverMind Open-Source Ecosystem -Core Memory Architecture +Memory Runtime EverOS - the local memory operating system and research-backed runtime for agent and user memory. @@ -797,7 +686,7 @@ EverMind is an open-source ecosystem for long-term memory, self-evolving agents, EverAlgo - stateless extraction, ranking, parsing, and memory operators that power EverOS. -Alternative Architecture +Hypergraph Memory HyperMem - hypergraph memory for long-term conversations, with its own benchmark-backed topic -> episode -> fact retrieval method. @@ -831,7 +720,7 @@ Together, these repositories form EverMind's research-to-runtime stack: new memo ## Contributing -Contributions are welcome across the whole repository: architecture methods, benchmark coverage, use-case examples, documentation, and bug fixes. Browse [Issues](https://github.com/EverMind-AI/EverOS/issues) to find a good entry point, then open a PR when you are ready. +Contributions are welcome across the whole repository: memory methods, benchmark coverage, use-case examples, documentation, and bug fixes. Browse [Issues](https://github.com/EverMind-AI/EverOS/issues) to find a good entry point, then open a PR when you are ready.
diff --git a/docs/everos-demo.md b/docs/everos-demo.md new file mode 100644 index 0000000..b962e02 --- /dev/null +++ b/docs/everos-demo.md @@ -0,0 +1,51 @@ +# EverOS Demo + +`everos demo` is a local educational TUI. It helps new users feel the memory +lifecycle before they configure API keys, start the server, or write real +memory through the API. + +## Run It + +```bash +everos demo +``` + +The command asks for one memory and one recall question, then opens a +full-screen terminal UI. The visual flow is deterministic and local to the CLI: +conversation -> memory sphere -> recall -> source proof -> confetti. + +For non-interactive shells or a copyable preview, use: + +```bash +everos demo --plain +``` + +For the looping showroom view used by README media, use: + +```bash +everos demo --cinematic +``` + +## What It Does Not Do + +The demo does not connect to the EverOS server, call LLM providers, or write +production memory files. It is intentionally hardcoded so users can try the +experience before configuring the full runtime. + +## Source Layout + +The CLI command adapter stays under `src/everos/entrypoints/cli/commands/demo.py` +because the public command is still `everos demo`. + +The TUI implementation lives under `src/everos/entrypoints/tui/demo/`: + +- `app.py` renders the Textual app. +- `data.py` builds the deterministic demo story. +- `widgets/sphere.py` builds the memory sphere frames. +- `readme_media.py` renders README media. + +To regenerate README media locally: + +```bash +uv run python -m everos.entrypoints.tui.demo.readme_media --out-dir /tmp/everos-demo-media +``` diff --git a/pyproject.toml b/pyproject.toml index 0715b99..a06c1a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ # CLI "typer>=0.12.0", + "textual>=8.2.7", # Tokenization (BM25 Chinese support) "jieba==0.42.1", diff --git a/src/everos/entrypoints/cli/commands/demo.py b/src/everos/entrypoints/cli/commands/demo.py new file mode 100644 index 0000000..893eadd --- /dev/null +++ b/src/everos/entrypoints/cli/commands/demo.py @@ -0,0 +1,108 @@ +"""``everos demo`` — first-run memory sphere demo.""" + +from __future__ import annotations + +import sys + +import typer +from rich.console import Console +from rich.panel import Panel + +from everos.entrypoints.tui.demo.data import ( + DEFAULT_MEMORY_SEED, + DEFAULT_QUERY, + DemoStory, + build_demo_story, + default_demo_story, +) +from everos.entrypoints.tui.demo.widgets.sphere import ( + EVEROS_GREEN, + EVEROS_YELLOW, + build_dot_sphere, + render_dot_sphere_text, +) + + +def register(parent: typer.Typer) -> None: + """Attach the ``demo`` command to the root CLI app.""" + + @parent.command("demo") + def demo( + plain: bool = typer.Option( + False, + "--plain", + help="Print a static terminal preview instead of launching the TUI.", + ), + cinematic: bool = typer.Option( + False, + "--cinematic", + help="Skip prompts and launch the looping README-style demo.", + ), + ) -> None: + """Launch the EverOS first-memory Textual TUI.""" + if plain or not sys.stdout.isatty(): + _print_plain_demo() + return + + _run_interactive_demo(cinematic=cinematic) + + +def _run_interactive_demo(*, cinematic: bool) -> None: + run_demo_tui = _load_run_demo_tui() + story = None if cinematic else _collect_playable_story() + run_demo_tui(story=story) + + +def _load_run_demo_tui(): + try: + from everos.entrypoints.tui.demo.app import run_demo_tui + except ModuleNotFoundError as exc: + if exc.name != "textual": + raise + typer.secho( + "error: Textual is required for `everos demo`; install the " + "package with TUI dependencies or run `everos demo --plain`.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) from exc + + return run_demo_tui + + +def _collect_playable_story() -> DemoStory: + Console().print( + f"[bold {EVEROS_YELLOW}]EverOS demo[/] " + "Give it one memory, then ask for it back." + ) + memory = typer.prompt( + "Give EverOS one thing to remember", + default=DEFAULT_MEMORY_SEED, + ) + query = typer.prompt( + "Ask EverOS to recall it", + default=DEFAULT_QUERY, + ) + return build_demo_story(memory, query) + + +def _print_plain_demo(story: DemoStory | None = None) -> None: + story = story or default_demo_story() + console = Console() + frame = build_dot_sphere( + width=57, + height=23, + phase=0.18, + state_key="remembered", + ) + console.print( + Panel( + render_dot_sphere_text(frame), + title="EverOS Memory Sphere", + border_style=EVEROS_YELLOW, + ) + ) + console.print(f"[bold {EVEROS_GREEN}]EverOS remembered:[/]") + console.print(story.memory) + console.print() + console.print(f"[bold {EVEROS_YELLOW}]Source:[/] {story.source_filename}") diff --git a/src/everos/entrypoints/cli/main.py b/src/everos/entrypoints/cli/main.py index de2da8b..e030678 100644 --- a/src/everos/entrypoints/cli/main.py +++ b/src/everos/entrypoints/cli/main.py @@ -13,7 +13,7 @@ from __future__ import annotations import typer -from .commands import cascade, init_cmd, server +from .commands import cascade, demo, init_cmd, server app = typer.Typer( name="everos", @@ -28,6 +28,7 @@ app.add_typer(cascade.app, name="cascade") # ``init`` is a top-level leaf command (not a Typer group) — match the # idiomatic ``alembic init`` / ``django-admin startproject`` shape. init_cmd.register(app) +demo.register(app) if __name__ == "__main__": diff --git a/src/everos/entrypoints/tui/__init__.py b/src/everos/entrypoints/tui/__init__.py new file mode 100644 index 0000000..6048267 --- /dev/null +++ b/src/everos/entrypoints/tui/__init__.py @@ -0,0 +1 @@ +"""Terminal UI entrypoints.""" diff --git a/src/everos/entrypoints/tui/demo/__init__.py b/src/everos/entrypoints/tui/demo/__init__.py new file mode 100644 index 0000000..e68b0d8 --- /dev/null +++ b/src/everos/entrypoints/tui/demo/__init__.py @@ -0,0 +1 @@ +"""EverOS educational demo TUI.""" diff --git a/src/everos/entrypoints/tui/demo/app.py b/src/everos/entrypoints/tui/demo/app.py new file mode 100644 index 0000000..b1eabaf --- /dev/null +++ b/src/everos/entrypoints/tui/demo/app.py @@ -0,0 +1,345 @@ +"""Textual TUI for ``everos demo``.""" + +from __future__ import annotations + +from rich.text import Text +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.timer import Timer +from textual.widgets import Footer, Static + +from everos.entrypoints.tui.demo.data import DemoStory, default_demo_story +from everos.entrypoints.tui.demo.widgets.sphere import ( + EVEROS_AMBER, + EVEROS_AMBER_DIM, + EVEROS_CYAN, + EVEROS_GREEN, + EVEROS_ORANGE, + EVEROS_YELLOW, + EVEROS_YELLOW_SOFT, + build_dot_sphere, + render_dot_sphere_text, +) + +EVEROS_BLACK = "#1D1C18" +EVEROS_SURFACE = "#24231E" +EVEROS_SURFACE_RAISED = "#31302B" +EVEROS_INK = "#F5EDDC" +EVEROS_MUTED = "#918C80" +EVEROS_BORDER = "#5A5549" +SPHERE_FRAME_WIDTH = 37 +SPHERE_FRAME_HEIGHT = 17 +TERMINAL_CELL_HEIGHT_RATIO = 2.0 +SIGNAL_RAIL_SOURCE_WIDTH = 18 + + +class DotSphereWidget(Static): + """Animated dot sphere that represents EverOS memory activity.""" + + DEFAULT_CSS = """ + DotSphereWidget { + height: 1fr; + content-align: center middle; + } + """ + + STATES = ( + "booting", + "ingesting", + "extracting", + "indexing", + "recalling", + "remembered", + "source", + "celebrating", + ) + + def __init__(self) -> None: + super().__init__() + self._phase = 0.0 + self._tick = 0 + self._animation_timer: Timer | None = None + + def on_mount(self) -> None: + self._animation_timer = self.set_interval(1 / 12, self._advance) + self._advance() + + def pause_animation(self) -> None: + if self._animation_timer is not None: + self._animation_timer.pause() + + def _advance(self) -> None: + self._phase = (self._phase + 0.025) % 1.0 + self._tick += 1 + state = self.STATES[(self._tick // 36) % len(self.STATES)] + frame = build_dot_sphere( + width=SPHERE_FRAME_WIDTH, + height=SPHERE_FRAME_HEIGHT, + phase=self._phase, + state_key=state, + ) + self.update(render_dot_sphere_text(frame)) + + +class EverOSDemoApp(App[None]): + """Fullscreen first-run demo cockpit.""" + + TITLE = "EverOS Memory Core" + SUB_TITLE = "dot sphere demo" + BINDINGS = [ + ("q", "quit", "Quit"), + ("r", "replay", "Replay"), + ] + + CSS = f""" + Screen {{ + background: {EVEROS_BLACK}; + color: {EVEROS_INK}; + }} + + #shell {{ + width: 100%; + height: 100%; + padding: 1 2; + border: round {EVEROS_BORDER}; + }} + + #command-strip {{ + height: 2; + padding: 0 1; + color: {EVEROS_INK}; + content-align: left middle; + }} + + #main {{ + height: 1fr; + margin-top: 1; + }} + + #memory-field {{ + width: 1fr; + border: round {EVEROS_AMBER}; + background: {EVEROS_SURFACE}; + padding: 0 2; + }} + + #field-header {{ + height: 2; + content-align: left middle; + }} + + #field-answer {{ + height: 2; + border-top: hkey {EVEROS_AMBER_DIM}; + background: {EVEROS_SURFACE_RAISED}; + padding: 0 1; + }} + + #signal-rail {{ + width: 48; + height: 100%; + margin-left: 1; + border: round {EVEROS_AMBER}; + background: {EVEROS_SURFACE}; + padding: 1 2; + }} + + #provenance-strip {{ + height: 6; + margin-top: 1; + }} + + #source-lock {{ + width: 1fr; + border: round {EVEROS_CYAN}; + background: {EVEROS_SURFACE}; + padding: 0 2; + margin-right: 1; + }} + + #recall-lock {{ + width: 54; + border: round {EVEROS_GREEN}; + background: {EVEROS_SURFACE}; + padding: 0 2; + }} + + #payoff {{ + height: 2; + border-top: hkey {EVEROS_YELLOW}; + background: {EVEROS_SURFACE}; + color: {EVEROS_INK}; + padding: 0 1; + margin-top: 1; + content-align: left middle; + }} + + Footer {{ + background: {EVEROS_BLACK}; + color: {EVEROS_MUTED}; + }} + + FooterKey {{ + background: {EVEROS_BLACK}; + }} + + FooterKey > .footer-key--key {{ + color: {EVEROS_BLACK}; + background: {EVEROS_YELLOW}; + text-style: bold; + }} + + FooterKey > .footer-key--description {{ + color: {EVEROS_INK}; + background: {EVEROS_BLACK}; + }} + """ + + def __init__(self, *, story: DemoStory | None = None) -> None: + super().__init__() + self._story = story or default_demo_story() + + def compose(self) -> ComposeResult: + with Vertical(id="shell"): + yield Static(_hero_text(), id="command-strip") + with Horizontal(id="main"): + memory_field = Vertical(id="memory-field") + memory_field.border_title = "memory field" + with memory_field: + yield Static(_field_header_text(self._story), id="field-header") + yield DotSphereWidget() + yield Static(_sphere_caption(self._story), id="field-answer") + signal_rail = Static(_signal_rail_text(self._story), id="signal-rail") + signal_rail.border_title = "signal rail" + yield signal_rail + with Horizontal(id="provenance-strip"): + source_lock = Static(_source_tree_text(self._story), id="source-lock") + source_lock.border_title = "source lock" + yield source_lock + recall_lock = Static(_recall_proof_text(self._story), id="recall-lock") + recall_lock.border_title = "recall lock" + yield recall_lock + yield Static(_payoff_text(self._story), id="payoff") + yield Footer(show_command_palette=False) + + def action_replay(self) -> None: + widget = self.query_one(DotSphereWidget) + widget._tick = 0 + widget._phase = 0.0 + widget._advance() + + +def run_demo_tui(*, story: DemoStory | None = None) -> None: + EverOSDemoApp(story=story).run() + + +def _hero_text() -> Text: + return Text.assemble( + (" everos demo ", f"bold black on {EVEROS_YELLOW}"), + (" memory core ", f"bold {EVEROS_YELLOW}"), + ("online", EVEROS_MUTED), + ) + + +def _field_header_text(story: DemoStory | None = None) -> Text: + story = story or default_demo_story() + return Text.assemble( + (f"user={story.owner}", f"bold {EVEROS_INK}"), + (" scope=local-first", f"bold {EVEROS_YELLOW_SOFT}"), + (" trace ", EVEROS_MUTED), + ("conversation -> facts -> index", f"bold {EVEROS_YELLOW}"), + (" live", f"bold {EVEROS_ORANGE}"), + ) + + +def _sphere_caption(story: DemoStory | None = None) -> Text: + story = story or default_demo_story() + return Text.assemble( + ("query ", f"bold {EVEROS_CYAN}"), + (f"{story.query} ", EVEROS_INK), + ("-> ", EVEROS_MUTED), + ("answer ", f"bold {EVEROS_GREEN}"), + (story.answer, f"bold {EVEROS_GREEN}"), + ) + + +def _signal_rail_text(story: DemoStory | None = None) -> Text: + story = story or default_demo_story() + return Text.assemble( + ("● ", f"bold {EVEROS_GREEN}"), + ("memory core ", EVEROS_INK), + ("ready\n", f"bold {EVEROS_GREEN}"), + ("● ", f"bold {EVEROS_YELLOW_SOFT}"), + ("conversation ", EVEROS_INK), + ("captured\n", f"bold {EVEROS_YELLOW_SOFT}"), + ("● ", f"bold {EVEROS_ORANGE}"), + ("episode -> facts ", EVEROS_INK), + ("live\n", f"bold {EVEROS_ORANGE}"), + ("● ", f"bold {EVEROS_CYAN}"), + ("SQLite + LanceDB ", EVEROS_INK), + ("synced\n", f"bold {EVEROS_CYAN}"), + ("● ", f"bold {EVEROS_GREEN}"), + ("memory recall ", EVEROS_INK), + ("hit\n", f"bold {EVEROS_GREEN}"), + ("\nsource route\n", EVEROS_MUTED), + (_rail_cell(story.source_filename), EVEROS_INK), + (" attached\n", f"bold {EVEROS_YELLOW_SOFT}"), + (_rail_cell(story.fact_filename), EVEROS_INK), + (" 7 nodes\n", f"bold {EVEROS_ORANGE}"), + ("lancedb orbit ", EVEROS_INK), + ("synced\n", f"bold {EVEROS_CYAN}"), + ("\nrecall proof\n", EVEROS_MUTED), + ("score ", EVEROS_INK), + ("0.628\n", f"bold {EVEROS_GREEN}"), + ("source ", EVEROS_INK), + (f"{story.source_filename}\n", f"bold {EVEROS_CYAN}"), + ("field integrity\n", EVEROS_MUTED), + ("█████████░ 92%\n", f"bold {EVEROS_YELLOW}"), + ("latency ", EVEROS_MUTED), + ("42 ms\n", f"bold {EVEROS_GREEN}"), + ("mode ", EVEROS_MUTED), + ("local-first", f"bold {EVEROS_INK}"), + ) + + +def _rail_cell(value: str, *, width: int = SIGNAL_RAIL_SOURCE_WIDTH) -> str: + if len(value) > width: + return f"{value[: width - 3]}..." + return f"{value:<{width}}" + + +def _source_tree_text(story: DemoStory | None = None) -> Text: + story = story or default_demo_story() + return Text.assemble( + ("episode ", EVEROS_MUTED), + (f"{story.source_filename}\n", f"bold {EVEROS_YELLOW_SOFT}"), + ("facts ", EVEROS_MUTED), + (f"{story.fact_filename}\n", f"bold {EVEROS_ORANGE}"), + ("index ", EVEROS_MUTED), + ("sqlite/system.db + lancedb/*.lance\n", EVEROS_CYAN), + ("root ", EVEROS_MUTED), + ("~/.everos/default_app/default_project", EVEROS_INK), + ) + + +def _recall_proof_text(story: DemoStory | None = None) -> Text: + story = story or default_demo_story() + return Text.assemble( + ("score ", EVEROS_MUTED), + ("0.628\n", f"bold {EVEROS_GREEN}"), + ("scope ", EVEROS_MUTED), + (f"user={story.owner} project=default\n", EVEROS_INK), + ("answer ", EVEROS_MUTED), + (story.answer, f"bold {EVEROS_YELLOW}"), + ) + + +def _payoff_text(story: DemoStory | None = None) -> Text: + story = story or default_demo_story() + return Text.assemble( + ("memory formed: ", f"bold {EVEROS_YELLOW}"), + ( + f"EverOS recalled {story.answer} and kept the source attached.", + f"bold {EVEROS_INK}", + ), + ) diff --git a/src/everos/entrypoints/tui/demo/data.py b/src/everos/entrypoints/tui/demo/data.py new file mode 100644 index 0000000..c5e2330 --- /dev/null +++ b/src/everos/entrypoints/tui/demo/data.py @@ -0,0 +1,75 @@ +"""Scripted story data for the educational ``everos demo`` flow.""" + +from __future__ import annotations + +from dataclasses import dataclass + +DEFAULT_MEMORY_SEED = "I love climbing in Yosemite every spring." +DEFAULT_QUERY = "Where do I like to climb?" + + +@dataclass(frozen=True, slots=True) +class DemoStory: + """Small, deterministic story rendered by the demo TUI.""" + + owner: str + memory: str + query: str + answer: str + source_filename: str + fact_filename: str + + +def default_demo_story() -> DemoStory: + """Return the cinematic story used by README media and no-prompt previews.""" + + return DemoStory( + owner="alice", + memory=DEFAULT_MEMORY_SEED, + query="Where does Alice like to climb?", + answer="Yosemite every spring", + source_filename="episode-2026-06-20.md", + fact_filename="atomic_fact-2026-06-20.md", + ) + + +def build_demo_story( + memory_seed: str | None = None, + query: str | None = None, +) -> DemoStory: + """Build a playable demo story from one user memory and one recall query.""" + + memory = _clean(memory_seed, DEFAULT_MEMORY_SEED) + recall_query = _clean(query, DEFAULT_QUERY) + return DemoStory( + owner="you", + memory=memory, + query=recall_query, + answer=_derive_demo_answer(memory), + source_filename="episode-demo.md", + fact_filename="atomic_fact-demo.md", + ) + + +def _clean(value: str | None, fallback: str) -> str: + if value is None: + return fallback + stripped = value.strip() + return stripped or fallback + + +def _derive_demo_answer(memory: str) -> str: + """Keep the demo deterministic without pretending to run the server.""" + + lower_memory = memory.lower() + if "yosemite" in lower_memory: + if "spring" in lower_memory: + return "Yosemite every spring" + return "Yosemite" + return _compact(memory) + + +def _compact(text: str, *, limit: int = 66) -> str: + if len(text) <= limit: + return text + return f"{text[: limit - 3].rstrip()}..." diff --git a/src/everos/entrypoints/tui/demo/readme_media.py b/src/everos/entrypoints/tui/demo/readme_media.py new file mode 100644 index 0000000..36ad890 --- /dev/null +++ b/src/everos/entrypoints/tui/demo/readme_media.py @@ -0,0 +1,267 @@ +"""Render README media for the ``everos demo`` TUI.""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import html +import io +import os +import re +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +import anyio +from rich.console import Console +from rich.terminal_theme import TerminalTheme + +TERMINAL_SIZE = (150, 48) +ANIMATION_TERMINAL_SIZE = (132, 42) +ANIMATION_FPS = 24 +ANIMATION_FRAMES_PER_STATE = 4 +FRAME_SECONDS = 1 / ANIMATION_FPS +EVEROS_TERMINAL_THEME = TerminalTheme( + background=(29, 28, 24), + foreground=(245, 237, 220), + normal=[ + (29, 28, 24), + (192, 149, 37), + (216, 205, 175), + (249, 185, 28), + (139, 118, 63), + (246, 194, 59), + (245, 237, 220), + (245, 237, 220), + ], + bright=[ + (49, 48, 43), + (192, 149, 37), + (216, 205, 175), + (249, 185, 28), + (145, 140, 128), + (246, 194, 59), + (245, 237, 220), + (245, 237, 220), + ], +) + + +@dataclass(frozen=True) +class FramePlan: + state: str + phase: float + + +@dataclass(frozen=True) +class OpacitySchedule: + key_times: str + values: str + + +def build_frame_plan( + states: Sequence[str], + *, + frames_per_state: int = ANIMATION_FRAMES_PER_STATE, +) -> tuple[FramePlan, ...]: + """Return animation frames, closed by a duplicate of the first frame.""" + if not states: + raise ValueError("at least one animation state is required") + if frames_per_state < 1: + raise ValueError("frames_per_state must be at least 1") + + state_count = len(states) + frame_count = state_count * frames_per_state + frames = [ + FramePlan( + state=states[index // frames_per_state], + phase=index / frame_count, + ) + for index in range(frame_count) + ] + frames.append(FramePlan(state=states[0], phase=0.0)) + return tuple(frames) + + +def opacity_schedule(*, index: int, total: int) -> OpacitySchedule: + """Build a gap-free discrete opacity schedule for one frame.""" + if total < 2: + raise ValueError("at least two frames are required") + if index < 0 or index >= total: + raise ValueError("frame index is out of range") + + start = index / total + end = (index + 1) / total + + if index == 0: + return OpacitySchedule(key_times=f"0;{_time(end)};1", values="1;0;0") + if index == total - 1: + return OpacitySchedule(key_times=f"0;{_time(start)};1", values="0;1;1") + return OpacitySchedule( + key_times=f"0;{_time(start)};{_time(end)};1", + values="0;1;0;0", + ) + + +async def render_media(out_dir: Path) -> tuple[Path, Path]: + """Render the static screenshot and looping animated SVG.""" + from everos.entrypoints.tui.demo.app import DotSphereWidget + + await anyio.Path(out_dir).mkdir(parents=True, exist_ok=True) + screenshot = out_dir / "everos-demo-tui-screenshot.svg" + remembered_index = DotSphereWidget.STATES.index("remembered") + remembered = FramePlan( + state=DotSphereWidget.STATES[remembered_index], + phase=remembered_index / len(DotSphereWidget.STATES), + ) + await _export_frame(screenshot, remembered, terminal_size=TERMINAL_SIZE) + + plan = build_frame_plan(DotSphereWidget.STATES) + frame_paths: list[Path] = [] + for index, frame in enumerate(plan): + frame_path = out_dir / f"frame-{index:02d}-{frame.state}.svg" + await _export_frame( + frame_path, + frame, + terminal_size=ANIMATION_TERMINAL_SIZE, + ) + frame_paths.append(frame_path) + + animation = out_dir / "everos-demo-tui-animation.svg" + animation.write_text(_build_animation_svg(frame_paths, plan)) + return screenshot, animation + + +async def _export_frame( + path: Path, + frame: FramePlan, + *, + terminal_size: tuple[int, int] = TERMINAL_SIZE, +) -> None: + from everos.entrypoints.tui.demo.app import ( + SPHERE_FRAME_HEIGHT, + SPHERE_FRAME_WIDTH, + DotSphereWidget, + EverOSDemoApp, + ) + from everos.entrypoints.tui.demo.widgets.sphere import ( + build_dot_sphere, + render_dot_sphere_text, + ) + + no_color = os.environ.pop("NO_COLOR", None) + try: + app = EverOSDemoApp() + async with app.run_test(size=terminal_size) as pilot: + await pilot.pause(0.05) + widget = app.query_one(DotSphereWidget) + widget.pause_animation() + sphere = build_dot_sphere( + width=SPHERE_FRAME_WIDTH, + height=SPHERE_FRAME_HEIGHT, + phase=frame.phase, + state_key=frame.state, + ) + widget.update(render_dot_sphere_text(sphere)) + await pilot.pause(0.05) + screenshot = _export_screenshot_svg(app) + finally: + if no_color is not None: + os.environ["NO_COLOR"] = no_color + await anyio.Path(path).write_text(normalize_svg_terminal_ids(screenshot)) + + +def _export_screenshot_svg(app) -> str: + width, height = app.size + console = Console( + width=width, + height=height, + file=io.StringIO(), + force_terminal=True, + color_system="truecolor", + record=True, + legacy_windows=False, + safe_box=False, + ) + screen_render = app.screen._compositor.render_update( + full=True, + screen_stack=app.app._background_screens, + simplify=False, + ) + console.print(screen_render) + return console.export_svg( + title=app.title, + theme=EVEROS_TERMINAL_THEME, + unique_id="terminal-everos", + ) + + +def _build_animation_svg(frame_paths: Sequence[Path], plan: Sequence[FramePlan]) -> str: + if len(frame_paths) != len(plan): + raise ValueError("frame paths and frame plan lengths must match") + + view_box, width, height = _read_svg_dimensions(frame_paths[0]) + duration = len(frame_paths) * FRAME_SECONDS + images = [] + for index, frame_path in enumerate(frame_paths): + encoded = base64.b64encode(frame_path.read_bytes()).decode("ascii") + schedule = opacity_schedule(index=index, total=len(frame_paths)) + state = html.escape(plan[index].state) + images.append( + f' \n' + f" EverOS demo state: {state}\n" + f' \n' + " " + ) + + return ( + f'\n' + "Animated EverOS demo terminal UI\n" + "EverOS demo cycles through memory states with a closed loop.\n" + + "\n".join(images) + + "\n\n" + ) + + +def _read_svg_dimensions(path: Path) -> tuple[str, str, str]: + svg_open = re.search(r"]+)>", path.read_text()) + if svg_open is None: + raise ValueError(f"could not find SVG root in {path}") + view_box_match = re.search(r'viewBox="([^"]+)"', svg_open.group(0)) + if view_box_match is None: + raise ValueError(f"could not find SVG viewBox in {path}") + view_box = view_box_match.group(1) + _, _, width, height = view_box.split() + return view_box, width, height + + +def normalize_svg_terminal_ids(svg: str) -> str: + """Normalize Rich's random terminal SVG prefix for deterministic media.""" + return re.sub(r"terminal-\d+", "terminal-everos", svg) + + +def _time(value: float) -> str: + return f"{value:.6f}".rstrip("0").rstrip(".") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--out-dir", + type=Path, + default=Path("/tmp/everos-demo-media"), + help="Directory for generated README SVG media.", + ) + args = parser.parse_args() + screenshot, animation = asyncio.run(render_media(args.out_dir)) + print(screenshot) + print(animation) + + +if __name__ == "__main__": + main() diff --git a/src/everos/entrypoints/tui/demo/widgets/__init__.py b/src/everos/entrypoints/tui/demo/widgets/__init__.py new file mode 100644 index 0000000..184c236 --- /dev/null +++ b/src/everos/entrypoints/tui/demo/widgets/__init__.py @@ -0,0 +1 @@ +"""Widgets and rendering primitives for the EverOS demo TUI.""" diff --git a/src/everos/entrypoints/tui/demo/widgets/sphere.py b/src/everos/entrypoints/tui/demo/widgets/sphere.py new file mode 100644 index 0000000..8c15c34 --- /dev/null +++ b/src/everos/entrypoints/tui/demo/widgets/sphere.py @@ -0,0 +1,322 @@ +"""Dot-sphere primitives for the EverOS demo TUI. + +The Textual app consumes these pure rendering primitives so the animated +surface stays testable without standing up a terminal UI. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from rich.text import Text + +EVEROS_YELLOW = "#F9B91C" +EVEROS_YELLOW_SOFT = "#F6C23B" +EVEROS_AMBER_DIM = "#4A3D20" +EVEROS_AMBER = "#8B763F" +EVEROS_CYAN = "#F5EDDC" +EVEROS_GREEN = "#D8CDAF" +EVEROS_ORANGE = "#C09525" +BRAILLE_BASE = 0x2800 +BRAILLE_DOT_BITS = ( + (0x01, 0x02, 0x04, 0x40), + (0x08, 0x10, 0x20, 0x80), +) +SPHERE_POINT_COUNT = 1300 +CONFETTI_POINT_COUNT = 150 +CONFETTI_GLYPHS = (".", "+", "*", "x") +CONFETTI_STYLES = ( + EVEROS_YELLOW, + EVEROS_YELLOW_SOFT, + EVEROS_CYAN, + EVEROS_ORANGE, + EVEROS_AMBER, +) +GOLDEN_ANGLE = math.pi * (3 - math.sqrt(5)) + + +@dataclass(frozen=True) +class SphereState: + """Visual and copy settings for a sphere animation state.""" + + key: str + caption: str + accent: str + + +@dataclass(frozen=True) +class DotCell: + """One projected dot in terminal cell coordinates.""" + + x: int + y: int + z: float + glyph: str + style: str + highlighted: bool = False + + +@dataclass(frozen=True) +class DotSphereFrame: + """A fully projected dot-sphere frame.""" + + width: int + height: int + state: SphereState + cells: tuple[DotCell, ...] + + @property + def caption(self) -> str: + return self.state.caption + + +SPHERE_STATES: dict[str, SphereState] = { + "booting": SphereState( + key="booting", + caption="forming local memory field", + accent=EVEROS_YELLOW, + ), + "ingesting": SphereState( + key="ingesting", + caption="ingesting conversation dots", + accent=EVEROS_CYAN, + ), + "extracting": SphereState( + key="extracting", + caption="extracting episode -> atomic facts", + accent=EVEROS_ORANGE, + ), + "indexing": SphereState( + key="indexing", + caption="syncing SQLite + LanceDB orbit", + accent=EVEROS_CYAN, + ), + "recalling": SphereState( + key="recalling", + caption="scanning memory sphere", + accent=EVEROS_GREEN, + ), + "remembered": SphereState( + key="remembered", + caption="remembered Yosemite preference", + accent=EVEROS_YELLOW, + ), + "source": SphereState( + key="source", + caption="revealing episode.md source", + accent=EVEROS_YELLOW_SOFT, + ), + "celebrating": SphereState( + key="celebrating", + caption="memory crystallized", + accent=EVEROS_YELLOW, + ), +} + + +def build_dot_sphere( + *, width: int, height: int, phase: float, state_key: str +) -> DotSphereFrame: + """Build one dot-sphere animation frame.""" + if width < 13 or height < 7: + raise ValueError("dot sphere requires at least 13x7 cells") + try: + state = SPHERE_STATES[state_key] + except KeyError as exc: + raise ValueError(f"unknown sphere state: {state_key}") from exc + + if state.key == "celebrating": + return _build_confetti_burst( + width=width, + height=height, + phase=phase, + state=state, + ) + + sub_width = width * 2 + sub_height = height * 4 + sub_center_x = (sub_width - 1) / 2 + sub_center_y = (sub_height - 1) / 2 + radius_x = max(1.0, sub_center_x - 5) + radius_y = max(1.0, sub_center_y - 3) + rotation = phase * math.tau + active_target = _highlight_target(width, height) + + masks: dict[tuple[int, int], int] = {} + depths: dict[tuple[int, int], float] = {} + highlighted_positions: set[tuple[int, int]] = set() + for index in range(SPHERE_POINT_COUNT): + y3 = 1 - 2 * ((index + 0.5) / SPHERE_POINT_COUNT) + ring_radius = math.sqrt(max(0.0, 1.0 - y3 * y3)) + theta = index * GOLDEN_ANGLE + rotation + x3 = ring_radius * math.cos(theta) + z3 = ring_radius * math.sin(theta) + sub_x = round(sub_center_x + x3 * radius_x) + sub_y = round(sub_center_y + y3 * radius_y) + if not (0 <= sub_x < sub_width and 0 <= sub_y < sub_height): + continue + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=sub_x, + sub_y=sub_y, + z=z3, + ) + + if state.key in {"recalling", "remembered", "source"}: + highlighted_positions.add(active_target) + target_sub_x = active_target[0] * 2 + 1 + target_sub_y = active_target[1] * 4 + 1 + _add_braille_dot( + masks=masks, + depths=depths, + sub_x=target_sub_x, + sub_y=target_sub_y, + z=1.0, + ) + + cells = [] + for (x, y), mask in masks.items(): + highlighted = (x, y) in highlighted_positions + if highlighted and state.key == "recalling": + style = EVEROS_CYAN + elif highlighted: + style = EVEROS_YELLOW + else: + style = _style_for_depth(depths[(x, y)], state) + cells.append( + DotCell( + x=x, + y=y, + z=depths[(x, y)], + glyph=chr(BRAILLE_BASE + mask), + style=style, + highlighted=highlighted, + ) + ) + + return DotSphereFrame( + width=width, + height=height, + state=state, + cells=tuple(sorted(cells, key=lambda cell: (cell.y, cell.x))), + ) + + +def _build_confetti_burst( + *, width: int, height: int, phase: float, state: SphereState +) -> DotSphereFrame: + center_x = (width - 1) / 2 + center_y = (height - 1) / 2 + radius_x = max(1.0, center_x - 3) + radius_y = max(1.0, center_y - 2) + local_phase = _state_local_phase(phase, state.key) + bloom = 0.62 + 0.58 * math.sin(local_phase * math.pi) + rotation = phase * math.tau * 1.4 + + cells_by_position: dict[tuple[int, int], DotCell] = {} + for index in range(CONFETTI_POINT_COUNT): + shell = 0.55 + 0.45 * ((index % 17) / 16) + angle = index * GOLDEN_ANGLE + rotation + drift = math.sin(phase * math.tau * 2 + index * 0.23) + x = round(center_x + math.cos(angle) * radius_x * shell * bloom) + y = round( + center_y + + math.sin(angle) * radius_y * shell * bloom + + drift * 0.75 * local_phase + ) + if not (0 <= x < width and 0 <= y < height): + continue + + z = math.cos(angle - rotation) * shell + glyph = CONFETTI_GLYPHS[(index + int(local_phase * 10)) % len(CONFETTI_GLYPHS)] + style = CONFETTI_STYLES[ + (index * 3 + int(local_phase * 7)) % len(CONFETTI_STYLES) + ] + position = (x, y) + existing = cells_by_position.get(position) + if existing is None or z > existing.z: + cells_by_position[position] = DotCell( + x=x, + y=y, + z=z, + glyph=glyph, + style=style, + ) + + return DotSphereFrame( + width=width, + height=height, + state=state, + cells=tuple( + sorted(cells_by_position.values(), key=lambda cell: (cell.y, cell.x)) + ), + ) + + +def render_dot_sphere_lines(frame: DotSphereFrame) -> list[list[DotCell | None]]: + """Render cells into a sparse row grid for Rich/Textual consumers.""" + grid: list[list[DotCell | None]] = [ + [None for _ in range(frame.width)] for _ in range(frame.height) + ] + for cell in frame.cells: + if 0 <= cell.x < frame.width and 0 <= cell.y < frame.height: + grid[cell.y][cell.x] = cell + return grid + + +def render_dot_sphere_text(frame: DotSphereFrame) -> Text: + """Convert a frame into styled terminal text.""" + rows = render_dot_sphere_lines(frame) + text = Text(no_wrap=True) + for row in rows: + for cell in row: + if cell is None: + text.append(" ") + else: + text.append(cell.glyph, style=cell.style) + text.append("\n") + text.append("\n") + text.append(frame.caption, style=f"bold {frame.state.accent}") + return text + + +def _add_braille_dot( + *, + masks: dict[tuple[int, int], int], + depths: dict[tuple[int, int], float], + sub_x: int, + sub_y: int, + z: float, +) -> None: + cell_x = sub_x // 2 + cell_y = sub_y // 4 + local_x = sub_x % 2 + local_y = sub_y % 4 + position = (cell_x, cell_y) + masks[position] = masks.get(position, 0) | BRAILLE_DOT_BITS[local_x][local_y] + depths[position] = max(z, depths.get(position, -1.0)) + + +def _style_for_depth(z: float, state: SphereState) -> str: + if state.key == "extracting" and z > 0.38: + return EVEROS_ORANGE + if state.key == "indexing" and z > 0.45: + return EVEROS_CYAN + if state.key == "ingesting" and z > 0.5: + return EVEROS_CYAN + if z > 0.58: + return EVEROS_YELLOW + if z > 0.05: + return EVEROS_YELLOW + return EVEROS_AMBER + + +def _highlight_target(width: int, height: int) -> tuple[int, int]: + return (round((width - 1) * 0.66), round((height - 1) * 0.42)) + + +def _state_local_phase(phase: float, state_key: str) -> float: + state_keys = tuple(SPHERE_STATES) + return (phase * len(state_keys) - state_keys.index(state_key)) % 1.0 diff --git a/tests/unit/test_entrypoints/test_cli/test_demo_command.py b/tests/unit/test_entrypoints/test_cli/test_demo_command.py new file mode 100644 index 0000000..cc3f571 --- /dev/null +++ b/tests/unit/test_entrypoints/test_cli/test_demo_command.py @@ -0,0 +1,106 @@ +"""EverOS demo command contracts.""" + +from __future__ import annotations + +import re + +import pytest +import typer +from rich.panel import Panel +from typer.testing import CliRunner + +from everos.entrypoints.cli.commands import demo as demo_command +from everos.entrypoints.tui.demo.data import build_demo_story + + +def test_demo_help_exposes_cinematic_mode() -> None: + app = typer.Typer() + demo_command.register(app) + + result = CliRunner().invoke(app, ["demo", "--help"], terminal_width=120) + + assert result.exit_code == 0 + assert "--cinematic" in _strip_ansi(result.stdout) + + +def test_collect_playable_story_prompts_for_memory_then_query(monkeypatch) -> None: + prompts: list[tuple[str, str]] = [] + replies = iter( + [ + "I keep my Monday design review notes in Notion.", + "Where are my Monday review notes?", + ] + ) + + def fake_prompt(label: str, *, default: str) -> str: + prompts.append((label, default)) + return next(replies) + + monkeypatch.setattr(demo_command.typer, "prompt", fake_prompt) + + story = demo_command._collect_playable_story() + + assert [label for label, _ in prompts] == [ + "Give EverOS one thing to remember", + "Ask EverOS to recall it", + ] + assert story.memory == "I keep my Monday design review notes in Notion." + assert story.query == "Where are my Monday review notes?" + + +def test_interactive_demo_checks_textual_before_prompt(monkeypatch) -> None: + def fail_load_tui() -> object: + raise typer.Exit(code=1) + + def fail_prompt(*_: object, **__: object) -> str: + pytest.fail("demo prompted before checking TUI availability") + + monkeypatch.setattr(demo_command, "_load_run_demo_tui", fail_load_tui) + monkeypatch.setattr(demo_command.typer, "prompt", fail_prompt) + + with pytest.raises(typer.Exit): + demo_command._run_interactive_demo(cinematic=False) + + +def test_plain_demo_uses_poster_gold_brand_primary(monkeypatch) -> None: + printed: list[object] = [] + + class FakeConsole: + def print(self, *renderables: object, **_: object) -> None: + printed.extend(renderables) + + monkeypatch.setattr(demo_command, "Console", FakeConsole) + + demo_command._print_plain_demo() + + panel = next(item for item in printed if isinstance(item, Panel)) + printed_text = "\n".join(str(item) for item in printed) + assert panel.border_style == "#F9B91C" + assert "#F9B91C" in printed_text + assert "#FFE600" not in printed_text + + +def test_plain_demo_prints_custom_story(monkeypatch) -> None: + printed: list[object] = [] + + class FakeConsole: + def print(self, *renderables: object, **_: object) -> None: + printed.extend(renderables) + + monkeypatch.setattr(demo_command, "Console", FakeConsole) + + demo_command._print_plain_demo( + build_demo_story( + "I keep my Monday design review notes in Notion.", + "Where are my Monday review notes?", + ) + ) + + printed_text = "\n".join(str(item) for item in printed) + assert "EverOS remembered" in printed_text + assert "I keep my Monday design review notes in Notion." in printed_text + assert "episode-demo.md" in printed_text + + +def _strip_ansi(value: str) -> str: + return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", value) diff --git a/tests/unit/test_entrypoints/test_cli/test_main.py b/tests/unit/test_entrypoints/test_cli/test_main.py index 0ddd38b..3a4214f 100644 --- a/tests/unit/test_entrypoints/test_cli/test_main.py +++ b/tests/unit/test_entrypoints/test_cli/test_main.py @@ -13,6 +13,7 @@ def test_help_exits_zero() -> None: assert "everos" in result.stdout assert "server" in result.stdout assert "cascade" in result.stdout + assert "demo" in result.stdout def test_no_args_shows_help_and_exits_nonzero() -> None: diff --git a/tests/unit/test_entrypoints/test_tui/__init__.py b/tests/unit/test_entrypoints/test_tui/__init__.py new file mode 100644 index 0000000..7619c7f --- /dev/null +++ b/tests/unit/test_entrypoints/test_tui/__init__.py @@ -0,0 +1 @@ +"""TUI entrypoint tests.""" diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_app.py b/tests/unit/test_entrypoints/test_tui/test_demo_app.py new file mode 100644 index 0000000..6d94fbb --- /dev/null +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -0,0 +1,122 @@ +"""EverOS demo TUI color contracts.""" + +from __future__ import annotations + +import pytest + +from everos.entrypoints.tui.demo.app import ( + SPHERE_FRAME_HEIGHT, + SPHERE_FRAME_WIDTH, + TERMINAL_CELL_HEIGHT_RATIO, + DotSphereWidget, + EverOSDemoApp, + _field_header_text, + _hero_text, + _payoff_text, + _recall_proof_text, + _signal_rail_text, + _source_tree_text, + _sphere_caption, +) +from everos.entrypoints.tui.demo.data import build_demo_story +from everos.entrypoints.tui.demo.widgets.sphere import SPHERE_STATES + + +def test_demo_tui_uses_poster_derived_brand_palette() -> None: + css = EverOSDemoApp.CSS + + assert "#F9B91C" in css + assert "#31302B" in css + assert "#F5EDDC" in css + assert "#918C80" in css + assert "#FFE600" not in css + assert "#55D6FF" not in css + assert "#73F7A7" not in css + assert any(span.style == "bold #F9B91C" for span in _hero_text().spans) + + +def test_demo_tui_uses_elevated_instrument_layout() -> None: + css = EverOSDemoApp.CSS + + assert "#command-strip" in css + assert "#memory-field" in css + assert "#signal-rail" in css + assert "#provenance-strip" in css + assert "#payoff" in css + assert "FooterKey" in css + assert "background: #F9B91C" in css + assert any("on #F9B91C" in span.style for span in _hero_text().spans) + assert "border: double" not in css + assert "border: heavy" not in css + + +def test_demo_tui_uses_balanced_panel_proportions() -> None: + css = EverOSDemoApp.CSS + + command_strip = _css_block(css, "#command-strip") + signal_rail = _css_block(css, "#signal-rail") + payoff = _css_block(css, "#payoff") + + assert "height: 2;" in command_strip + assert "border-left: thick" not in command_strip + assert "background: #31302B" not in command_strip + assert len(_hero_text().plain.splitlines()) == 1 + assert len(_hero_text().plain) <= 56 + + assert "height: 1fr;" in DotSphereWidget.DEFAULT_CSS + + assert "height: 100%;" in signal_rail + assert "source route" in _signal_rail_text().plain + assert "recall proof" in _signal_rail_text().plain + + assert "height: 2;" in payoff + assert "background: #24231E;" in payoff + assert "padding: 0 1;" in payoff + assert _payoff_text().plain.startswith("memory formed:") + assert "bold #F9B91C" in {span.style for span in _payoff_text().spans} + + +def test_demo_tui_sphere_renders_round_in_terminal_cells() -> None: + visual_ratio = (SPHERE_FRAME_WIDTH - 4) / ( + SPHERE_FRAME_HEIGHT * TERMINAL_CELL_HEIGHT_RATIO + ) + + assert visual_ratio == pytest.approx(1.0, abs=0.04) + assert SPHERE_FRAME_WIDTH == 37 + assert SPHERE_FRAME_HEIGHT == 17 + + +def test_demo_tui_celebrates_after_source_reveal() -> None: + assert DotSphereWidget.STATES[-2:] == ("source", "celebrating") + assert set(DotSphereWidget.STATES).issubset(SPHERE_STATES) + + +def test_demo_tui_renders_playable_story_copy() -> None: + story = build_demo_story( + "I keep my Monday design review notes in Notion.", + "Where are my Monday review notes?", + ) + + assert "user=you" in _field_header_text(story).plain + assert "Where are my Monday review notes?" in _sphere_caption(story).plain + assert story.answer in _sphere_caption(story).plain + assert "server wake" not in _signal_rail_text(story).plain + assert "memory core" in _signal_rail_text(story).plain + assert story.source_filename in _source_tree_text(story).plain + assert story.fact_filename in _source_tree_text(story).plain + assert story.answer in _recall_proof_text(story).plain + assert story.answer in _payoff_text(story).plain + + +def test_demo_tui_signal_rail_keeps_source_status_columns_separate() -> None: + rail = _signal_rail_text().plain + + assert "mdattached" not in rail + assert "md7 nodes" not in rail + assert "..." in rail + + +def _css_block(css: str, selector: str) -> str: + start = css.index(f"{selector} {{") + end = css.index("}", start) + return css[start:end] diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_data.py b/tests/unit/test_entrypoints/test_tui/test_demo_data.py new file mode 100644 index 0000000..04311f4 --- /dev/null +++ b/tests/unit/test_entrypoints/test_tui/test_demo_data.py @@ -0,0 +1,29 @@ +"""EverOS playable demo story contracts.""" + +from __future__ import annotations + +from everos.entrypoints.tui.demo.data import ( + DEFAULT_MEMORY_SEED, + DEFAULT_QUERY, + build_demo_story, +) + + +def test_demo_story_preserves_prompted_memory_and_query() -> None: + story = build_demo_story( + "I keep my Monday design review notes in Notion.", + "Where are my Monday review notes?", + ) + + assert story.owner == "you" + assert story.memory == "I keep my Monday design review notes in Notion." + assert story.query == "Where are my Monday review notes?" + assert story.answer == "I keep my Monday design review notes in Notion." + assert story.source_filename == "episode-demo.md" + assert story.fact_filename == "atomic_fact-demo.md" + + +def test_demo_story_keeps_default_yosemite_success_moment() -> None: + story = build_demo_story(DEFAULT_MEMORY_SEED, DEFAULT_QUERY) + + assert story.answer == "Yosemite every spring" diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py b/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py new file mode 100644 index 0000000..d4221e1 --- /dev/null +++ b/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py @@ -0,0 +1,117 @@ +"""EverOS demo README media generation contracts.""" + +from __future__ import annotations + +import html + +from everos.entrypoints.tui.demo.readme_media import ( + ANIMATION_FPS, + ANIMATION_FRAMES_PER_STATE, + ANIMATION_TERMINAL_SIZE, + FRAME_SECONDS, + TERMINAL_SIZE, + FramePlan, + _export_frame, + build_frame_plan, + normalize_svg_terminal_ids, + opacity_schedule, +) + + +def test_readme_animation_frame_plan_closes_on_first_frame() -> None: + plan = build_frame_plan(("booting", "ingesting", "source"), frames_per_state=3) + + assert plan[0] == plan[-1] + assert len(plan) == 10 + assert [frame.state for frame in plan] == [ + "booting", + "booting", + "booting", + "ingesting", + "ingesting", + "ingesting", + "source", + "source", + "source", + "booting", + ] + assert [frame.phase for frame in plan] == [ + 0 / 9, + 1 / 9, + 2 / 9, + 3 / 9, + 4 / 9, + 5 / 9, + 6 / 9, + 7 / 9, + 8 / 9, + 0.0, + ] + + +def test_readme_animation_default_plan_uses_smoother_frame_rate() -> None: + states = ("booting", "ingesting", "source") + + plan = build_frame_plan(states) + + assert len(plan) <= len(states) * 4 + 1 + assert ANIMATION_FRAMES_PER_STATE == 4 + assert ANIMATION_FPS >= 24 + assert FRAME_SECONDS <= 1 / 24 + + +def test_readme_animation_uses_compact_terminal_size_for_load_speed() -> None: + assert ANIMATION_TERMINAL_SIZE[0] < TERMINAL_SIZE[0] + assert ANIMATION_TERMINAL_SIZE[1] < TERMINAL_SIZE[1] + assert ANIMATION_TERMINAL_SIZE[0] * ANIMATION_TERMINAL_SIZE[1] <= 5600 + + +def test_readme_animation_opacity_schedule_has_no_blank_gap() -> None: + schedules = [opacity_schedule(index=idx, total=4) for idx in range(4)] + + assert schedules[0].values == "1;0;0" + assert schedules[-1].values == "0;1;1" + assert all("0.0005" not in schedule.key_times for schedule in schedules) + + +def test_readme_media_normalizes_rich_terminal_random_ids() -> None: + svg = ( + "" + '' + 'EverOS' + ) + + normalized = normalize_svg_terminal_ids(svg) + + assert "terminal-12345" not in normalized + assert normalized.count("terminal-everos") == 4 + + +async def test_export_frame_freezes_animation_before_rendering_requested_state( + tmp_path, monkeypatch +) -> None: + from everos.entrypoints.tui.demo.app import DotSphereWidget + + def fast_mount(self: DotSphereWidget) -> None: + self._animation_timer = self.set_interval(0.001, self._advance) + self._advance() + + monkeypatch.setattr(DotSphereWidget, "on_mount", fast_mount) + path = tmp_path / "frame.svg" + + await _export_frame(path, FramePlan(state="booting", phase=0.0)) + + svg = html.unescape(path.read_text()).replace("\xa0", " ") + assert "forming local memory field" in svg + assert "ingesting conversation dots" not in svg + + +async def test_export_frame_preserves_poster_palette(tmp_path) -> None: + path = tmp_path / "frame.svg" + + await _export_frame(path, FramePlan(state="remembered", phase=0.5)) + + svg = path.read_text().lower() + assert "#f9b91c" in svg + assert "#f5eddc" in svg diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py b/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py new file mode 100644 index 0000000..61fa1af --- /dev/null +++ b/tests/unit/test_entrypoints/test_tui/test_demo_sphere.py @@ -0,0 +1,143 @@ +"""EverOS demo dot-sphere rendering contracts.""" + +from __future__ import annotations + +from everos.entrypoints.tui.demo.widgets.sphere import ( + SPHERE_STATES, + DotSphereFrame, + build_dot_sphere, +) + + +def test_dot_sphere_forms_round_bounded_cloud() -> None: + frame = build_dot_sphere(width=41, height=19, phase=0.0, state_key="extracting") + + assert frame.width == 41 + assert frame.height == 19 + assert frame.caption == "extracting episode -> atomic facts" + assert len(frame.cells) >= 90 + + center_x = (frame.width - 1) / 2 + center_y = (frame.height - 1) / 2 + radius_x = center_x + radius_y = center_y + for cell in frame.cells: + normalized = ((cell.x - center_x) / radius_x) ** 2 + ( + (cell.y - center_y) / radius_y + ) ** 2 + assert normalized <= 1.08 + + row_counts: dict[int, int] = {} + for cell in frame.cells: + row_counts[cell.y] = row_counts.get(cell.y, 0) + 1 + assert row_counts[frame.height // 2] > row_counts[min(row_counts)] + assert row_counts[frame.height // 2] > row_counts[max(row_counts)] + + +def test_dot_sphere_keeps_terminal_poles_visually_round() -> None: + frame = build_dot_sphere(width=37, height=17, phase=0.0, state_key="booting") + row_spans = _row_spans(frame) + + assert row_spans[0] <= 8 + assert row_spans[1] <= 20 + assert row_spans[2] <= 26 + assert row_spans[frame.height // 2] >= 31 + assert row_spans[frame.height - 2] <= 20 + assert row_spans[frame.height - 1] <= 8 + + +def test_dot_sphere_uses_braille_fine_dot_cells() -> None: + for state_key in SPHERE_STATES: + if state_key == "celebrating": + continue + frame = build_dot_sphere( + width=37, + height=17, + phase=0.25, + state_key=state_key, + ) + + assert all(_is_braille_cell(cell.glyph) for cell in frame.cells) + assert not any(cell.glyph in {"·", "•", "●", "◆"} for cell in frame.cells) + assert not any(cell.style.startswith("bold ") for cell in frame.cells) + + +def test_dot_sphere_packs_multiple_subdots_per_terminal_cell() -> None: + frame = build_dot_sphere(width=37, height=17, phase=0.0, state_key="booting") + + subdot_count = sum(_braille_subdot_count(cell.glyph) for cell in frame.cells) + + assert subdot_count > len(frame.cells) * 1.8 + assert subdot_count > frame.width * frame.height * 0.9 + assert any(_braille_subdot_count(cell.glyph) >= 4 for cell in frame.cells) + + +def test_dot_sphere_avoids_flat_sides_in_terminal_frame() -> None: + frame = build_dot_sphere(width=37, height=17, phase=0.0, state_key="booting") + row_spans = _row_spans(frame) + + assert max(row_spans.values()) <= frame.width - 4 + assert row_spans[frame.height // 2] >= frame.width - 4 + for y in range(frame.height // 2): + assert abs(row_spans[y] - row_spans[frame.height - 1 - y]) <= 4 + + +def test_dot_sphere_remembered_state_has_highlighted_node() -> None: + frame = build_dot_sphere(width=41, height=19, phase=0.25, state_key="remembered") + + highlighted = [cell for cell in frame.cells if cell.highlighted] + assert len(highlighted) == 1 + assert _is_braille_cell(highlighted[0].glyph) + assert highlighted[0].style == "#F9B91C" + assert frame.caption == "remembered Yosemite preference" + + +def test_dot_sphere_celebrating_state_bursts_into_confetti() -> None: + frame = build_dot_sphere(width=41, height=19, phase=0.93, state_key="celebrating") + + assert frame.caption == "memory crystallized" + confetti = [cell for cell in frame.cells if cell.glyph in {"*", "+", ".", "x"}] + assert len(confetti) >= 70 + assert all(not _is_braille_cell(cell.glyph) for cell in confetti) + assert not any(cell.style.startswith("bold ") for cell in confetti) + + center_x = (frame.width - 1) / 2 + center_y = (frame.height - 1) / 2 + radius_x = max(1.0, center_x - 3) + radius_y = max(1.0, center_y - 2) + distances = [ + ((cell.x - center_x) / radius_x) ** 2 + ((cell.y - center_y) / radius_y) ** 2 + for cell in confetti + ] + assert max(distances) > 1.10 + assert sum(distance > 0.72 for distance in distances) > len(distances) * 0.4 + + styles = {cell.style for cell in confetti} + assert "#F9B91C" in styles + assert "#F6C23B" in styles + + +def test_dot_sphere_front_light_uses_poster_gold_primary() -> None: + frame = build_dot_sphere(width=41, height=19, phase=0.0, state_key="booting") + + front_styles = {cell.style for cell in frame.cells if cell.z > 0.05} + assert "#F9B91C" in front_styles + assert "bold #F9B91C" not in front_styles + assert "#FFE600" not in front_styles + + +def _row_spans(frame: DotSphereFrame) -> dict[int, int]: + spans: dict[int, int] = {} + for y in range(frame.height): + xs = [cell.x for cell in frame.cells if cell.y == y] + spans[y] = max(xs) - min(xs) + 1 if xs else 0 + return spans + + +def _is_braille_cell(glyph: str) -> bool: + return len(glyph) == 1 and 0x2800 < ord(glyph) <= 0x28FF + + +def _braille_subdot_count(glyph: str) -> int: + assert _is_braille_cell(glyph) + return (ord(glyph) - 0x2800).bit_count() diff --git a/uv.lock b/uv.lock index 584612a..1bcb1e9 100644 --- a/uv.lock +++ b/uv.lock @@ -570,6 +570,7 @@ dependencies = [ { name = "pyyaml" }, { name = "sqlmodel" }, { name = "structlog" }, + { name = "textual" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, { name = "watchdog" }, @@ -619,6 +620,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "sqlmodel", specifier = ">=0.0.22" }, { name = "structlog", specifier = ">=24.0.0" }, + { name = "textual", specifier = ">=8.2.7" }, { name = "typer", specifier = ">=0.12.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, { name = "watchdog", specifier = ">=4.0.0" }, @@ -1083,6 +1085,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/d0/7e44e8143ac2dae8979ba882cc33d4af7b8da4741fb0361497e69b4a4379/lancedb-0.30.2-cp39-abi3-win_amd64.whl", hash = "sha256:531da53002c1c6fda829afccc8ced3056ef58eb036f09ddb2b94a06877ecc66c", size = 50940681, upload-time = "2026-03-31T23:25:52.35Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "mako" version = "1.3.12" @@ -1107,6 +1121,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1182,6 +1201,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2122,6 +2153,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] +[[package]] +name = "textual" +version = "8.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -2259,6 +2307,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"