diff --git a/docs/superpowers/specs/2026-03-16-code-scroll-design.md b/docs/superpowers/specs/2026-03-16-code-scroll-design.md new file mode 100644 index 0000000..719db19 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-code-scroll-design.md @@ -0,0 +1,154 @@ +# Code Scroll Mode — Design Spec + +**Date:** 2026-03-16 +**Branch:** feat/code-scroll +**Status:** Approved + +--- + +## Overview + +Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path. + +--- + +## Goals + +- Mirror the existing `--poetry` mode pattern as closely as possible +- Zero new runtime dependencies (stdlib `ast` and `pathlib` only) +- No changes to `scroll.py` or the render pipeline +- The item tuple shape `(text, src, ts)` is unchanged + +--- + +## New Files + +### `engine/fetch_code.py` + +Single public function `fetch_code()` that returns `(items, line_count, 0)`. + +**Algorithm:** + +1. Glob `engine/*.py` in sorted order +2. For each file: + a. Read source text + b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones. + c. Iterate source lines (1-indexed). Skip if: + - The stripped line is empty + - The stripped line starts with `#` + d. For each kept line emit: + - `text` = `line.rstrip()` (preserve indentation for readability in the big render) + - `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `` for top-level lines + - `ts` = dotted module path derived from filename, e.g. `engine/scroll.py` → `engine.scroll` +3. Return `(items, len(items), 0)` + +**Scope label rules:** +- `FunctionDef` / `AsyncFunctionDef` → `name()` +- `ClassDef` → `name` (no parens) +- No enclosing node → `` + +**Dependencies:** `ast`, `pathlib` — stdlib only. + +--- + +## Modified Files + +### `engine/config.py` + +Extend `MODE` detection to recognise `--code`: + +```python +MODE = ( + "poetry" if "--poetry" in sys.argv or "-p" in sys.argv + else "code" if "--code" in sys.argv + else "news" +) +``` + +### `engine/app.py` + +**Subtitle line** — extend the subtitle dict: + +```python +_subtitle = { + "poetry": "literary consciousness stream", + "code": "source consciousness stream", +}.get(config.MODE, "digital consciousness stream") +``` + +**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch: + +```python +elif config.MODE == "code": + from engine.fetch_code import fetch_code + slow_print(" > INITIALIZING SOURCE ARRAY...\n") + time.sleep(0.2) + print() + items, line_count, _ = fetch_code() + print() + print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}") +``` + +No cache save/load — local source files are read instantly and change only on disk writes. + +--- + +## Data Flow + +``` +engine/*.py (sorted) + │ + ▼ +fetch_code() + │ ast.parse → scope map + │ filter blank + comment lines + │ emit (line, scope(), engine.module) + ▼ +items: List[Tuple[str, str, str]] + │ + ▼ +stream(items, ntfy, mic) ← unchanged + │ + ▼ +next_headline() shuffles + recycles automatically +``` + +--- + +## Error Handling + +- If a file fails to `ast.parse` (malformed source), fall back to `` scope for all lines in that file — do not crash. +- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this. + +--- + +## Testing + +New file: `tests/test_fetch_code.py` + +| Test | Assertion | +|------|-----------| +| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings | +| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` | +| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` | + +No mocking — tests read the real engine source files, keeping them honest against actual content. + +--- + +## CLI + +```bash +python3 mainline.py --code # source consciousness mode +uv run mainline.py --code +``` + +Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.). + +--- + +## Out of Scope + +- Syntax highlighting / token-aware coloring (can be added later) +- `--code-dir` flag for pointing at arbitrary directories (YAGNI) +- Caching code items to disk diff --git a/engine/app.py b/engine/app.py index ec36e11..6c22f25 100644 --- a/engine/app.py +++ b/engine/app.py @@ -272,11 +272,10 @@ def main(): time.sleep(0.07) print() - _subtitle = ( - "literary consciousness stream" - if config.MODE == "poetry" - else "digital consciousness stream" - ) + _subtitle = { + "poetry": "literary consciousness stream", + "code": "source consciousness stream", + }.get(config.MODE, "digital consciousness stream") print(f" {W_DIM}v0.1 · {_subtitle}{RST}") print(f" {W_GHOST}{'─' * (w - 4)}{RST}") print() @@ -297,6 +296,14 @@ def main(): ) print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}") save_cache(items) + elif config.MODE == "code": + from engine.fetch_code import fetch_code + slow_print(" > INITIALIZING SOURCE ARRAY...\n") + time.sleep(0.2) + print() + items, line_count, _ = fetch_code() + print() + print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}") else: slow_print(" > INITIALIZING FEED ARRAY...\n") time.sleep(0.2) diff --git a/engine/config.py b/engine/config.py index 284877c..1a39a04 100644 --- a/engine/config.py +++ b/engine/config.py @@ -188,7 +188,11 @@ def set_config(config: Config) -> None: HEADLINE_LIMIT = 1000 FEED_TIMEOUT = 10 MIC_THRESHOLD_DB = 50 # dB above which glitches intensify -MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news" +MODE = ( + "poetry" if "--poetry" in sys.argv or "-p" in sys.argv + else "code" if "--code" in sys.argv + else "news" +) FIREHOSE = "--firehose" in sys.argv # ─── NTFY MESSAGE QUEUE ────────────────────────────────── diff --git a/engine/fetch_code.py b/engine/fetch_code.py new file mode 100644 index 0000000..3d1160f --- /dev/null +++ b/engine/fetch_code.py @@ -0,0 +1,67 @@ +""" +Source code feed — reads engine/*.py and emits non-blank, non-comment lines +as scroll items. Used by --code mode. +Depends on: nothing (stdlib only). +""" + +import ast +from pathlib import Path + +_ENGINE_DIR = Path(__file__).resolve().parent + + +def _scope_map(source: str) -> dict[int, str]: + """Return {line_number: scope_label} for every line in source. + + Nodes are sorted by range size descending so inner scopes overwrite + outer ones, guaranteeing the narrowest enclosing scope wins. + """ + try: + tree = ast.parse(source) + except SyntaxError: + return {} + + nodes = [] + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + end = getattr(node, "end_lineno", node.lineno) + span = end - node.lineno + nodes.append((span, node)) + + # Largest range first → inner scopes overwrite on second pass + nodes.sort(key=lambda x: x[0], reverse=True) + + scope = {} + for _, node in nodes: + end = getattr(node, "end_lineno", node.lineno) + if isinstance(node, ast.ClassDef): + label = node.name + else: + label = f"{node.name}()" + for ln in range(node.lineno, end + 1): + scope[ln] = label + + return scope + + +def fetch_code(): + """Read engine/*.py and return (items, line_count, 0). + + Each item is (text, src, ts) where: + text = the code line (rstripped, indentation preserved) + src = enclosing function/class name, e.g. 'stream()' or '' + ts = dotted module path, e.g. 'engine.scroll' + """ + items = [] + for path in sorted(_ENGINE_DIR.glob("*.py")): + module = f"engine.{path.stem}" + source = path.read_text(encoding="utf-8") + scope = _scope_map(source) + for lineno, raw in enumerate(source.splitlines(), start=1): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + label = scope.get(lineno, "") + items.append((raw.rstrip(), label, module)) + + return items, len(items), 0 diff --git a/fonts/Kapiler.otf b/fonts/Kapiler.otf new file mode 100644 index 0000000..695140a Binary files /dev/null and b/fonts/Kapiler.otf differ diff --git a/fonts/Kapiler.ttf b/fonts/Kapiler.ttf new file mode 100644 index 0000000..930f77e Binary files /dev/null and b/fonts/Kapiler.ttf differ diff --git a/tests/test_fetch_code.py b/tests/test_fetch_code.py new file mode 100644 index 0000000..5578b1d --- /dev/null +++ b/tests/test_fetch_code.py @@ -0,0 +1,35 @@ +import re + +from engine.fetch_code import fetch_code + + +def test_return_shape(): + items, line_count, ignored = fetch_code() + assert isinstance(items, list) + assert line_count == len(items) + assert ignored == 0 + + +def test_items_are_tuples(): + items, _, _ = fetch_code() + assert items, "expected at least one code line" + for item in items: + assert isinstance(item, tuple) and len(item) == 3 + text, src, ts = item + assert isinstance(text, str) + assert isinstance(src, str) + assert isinstance(ts, str) + + +def test_blank_and_comment_lines_excluded(): + items, _, _ = fetch_code() + for text, _, _ in items: + assert text.strip(), "blank line should have been filtered" + assert not text.strip().startswith("#"), "comment line should have been filtered" + + +def test_module_path_format(): + items, _, _ = fetch_code() + pattern = re.compile(r"^engine\.\w+$") + for _, _, ts in items: + assert pattern.match(ts), f"unexpected module path: {ts!r}"