From 83f1c1f57d598537630113dc3f8c754b77f3175b Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 19:50:15 +0700 Subject: [PATCH] chore: seed repo with spec, plan, and design Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 + CLAUDE.md | 75 + DOCS/MAIN.md | 437 + DOCS/space-sh.pen | 8697 +++++++++++++++++ .../plans/2026-06-09-spacesh-m0-m1.md | 3084 ++++++ .../specs/2026-06-09-spacesh-m0-m1-design.md | 204 + 6 files changed, 12502 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 DOCS/MAIN.md create mode 100644 DOCS/space-sh.pen create mode 100644 DOCS/superpowers/plans/2026-06-09-spacesh-m0-m1.md create mode 100644 DOCS/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d50af48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +**/target/ +node_modules/ +app/dist/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f7b08e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Status + +Pre-implementation. The repo currently contains **only the spec** — `DOCS/MAIN.md` (spacesh tech spec v0.1, in Russian). No source code, no `Cargo.toml`, no git history yet. Read `DOCS/MAIN.md` first; it is the single source of truth for what to build and why. When writing code, follow the crate layout, protocol, and milestones it fixes. + +## What spacesh is + +A terminal-workspace for running multiple AI agents (Claude Code, Codex, Gemini, opencode, shell) in parallel on macOS. Architecture: a **backend daemon (`spaceshd`) owns the live PTY sessions**; the Tauri GUI and the `spacesh` CLI are thin clients that talk to it over one Unix-domain socket (`~/.spacesh/sock`). The daemon outliving the GUI is the core design bet — killing/updating the GUI must not kill a running agent. + +## Architecture invariants + +These are load-bearing decisions from the spec — do not violate them when implementing: + +- **Daemon is the single source of truth.** GUI and CLI hold no state; they only send commands and subscribe to events. A click in the GUI and `spacesh focus s_8f3` from a script are the *same* `focus` command — never duplicate operational logic between GUI and CLI. +- **One socket, one protocol.** Length-prefixed (`u32` BE + payload) JSON frames over async UDS (tokio). Envelope has three kinds: `req` (client→daemon, correlated by `id`), `res` (daemon→client, by `id`), `evt` (daemon→subscribers, push, no id). Serialization is isolated in `spacesh-proto` so JSON→MessagePack is a localized swap. +- **Hybrid terminal parsing.** One PTY byte stream fans out to: (1) GUI `output` event → xterm.js renders; (2) `alacritty_terminal` grid in the daemon = authoritative screen model, used for status detection, scrollback search, OSC 133 last-command extraction, and reattach snapshots. The grid IS the snapshot source — no separate ring buffer. Display (xterm.js) and analysis (alacritty grid) are deliberately split. +- **Reattach via snapshot.** On `attach`, daemon serializes the current grid → ANSI dump → GUI writes it into a fresh xterm.js instance for instant repaint, then live `output` follows. This is how the daemon architecture solves screen restoration. +- **Status is pushed, not guessed.** State arrives as a `set_state` command (from agent hooks calling `spacesh notify`, OSC 133 for shells, pattern-matching fallback last). States: `work | wait | done | error | idle`. `$SPACESH_SURFACE_ID` is injected into each panel's env at spawn so hooks know which surface they report on. +- **Extend through the bus.** New features = new commands/events/subscribers (e.g. Telegram/MAX notifications are a subscriber *inside* `spaceshd` listening to `state`/`exit`). The spine does not move. + +## Planned code structure + +Cargo workspace + Tauri app (per spec §10): + +``` +crates/ + spacesh-proto/ # protocol types, serde, length-prefix framing — shared by all + spacesh-core/ # workspace/split model, alacritty_terminal grid, status engine — NO I/O, unit-testable + spacesh-pty/ # PTY spawn/io (portable-pty), reader-loop, batching, resize, backpressure + spaceshd/ # daemon: owns sessions, hosts socket, event fan-out, layout persist, launchd, notification subscribers + spacesh-cli/ # thin socket client (clap) +app/ + src-tauri/ # Rust: socket client + bridge into webview (tauri 2) + src/ # React/TS front (TerminalView, LayoutEngine, Sidebar, EventCenter, Wizard, Settings, socketBridge) +``` + +`spacesh-core` is deliberately I/O-free so the domain model and status engine are unit-testable in isolation. + +## Build / run (once the workspace exists) + +No build files exist yet. After scaffolding, the conventional commands will be: + +```bash +cargo build # build all crates +cargo test # run all Rust tests +cargo test -p spacesh-core # test one crate +cargo test -p spacesh-core # run a single test by name filter +cargo run -p spaceshd # start the daemon +cargo run -p spacesh-cli -- status --json +cd app && npm install && npm run tauri dev # GUI dev (Tauri 2) +``` + +Verify the actual scripts against `Cargo.toml` / `app/package.json` once they are created — do not assume. + +## Performance budgets (enforce in PTY/render paths) + +- Keypress → echo: < 16 ms under normal load. +- PTY output batching: coalesce ~4–8 ms **or** ~16 KB, whichever first — never emit `output` per byte (a 50k-line build log must not yield 50k events). +- Backpressure: bounded per-subscriber channel; on overflow drop/coalesce frames for that client, but always keep the authoritative daemon grid up to date. +- Reattach repaint: < 100 ms on a typical grid. + +## Implementation order (spec §12) + +M0 live terminal through socket (vertical slice) → M1 persistence + reattach + launchd → M2 layouts/workspaces/presets → M3 statuses (hooks, OSC 133, fallback, notifications UI) → M4 CLI → M5 features (zoom, Telegram/MAX notifications, scrollback search, CodeMirror 6 diff view) → M6 (post-v1) remote via SSH-tunneled socket. + +## Explicitly out of v1 scope + +Token/limit accounting, legal/auth layer, embedded browser, full Monaco-class editor, agent-command guardrails. "Modifications" = external notifications (Telegram + MAX) only. Don't build these unless the scope decision is revisited. + +## Conventions + +Per the user's global rules: English for all code, comments, and docs; camelCase vars/functions, PascalCase types, snake_case files/dirs, UPPER_CASE env vars. No hard-coded values — use env/config (`~/.spacesh/config.toml`); tokens via `env:VAR` references, never plaintext. Spec prose and the docs in `DOCS/` are Russian; keep that, but code stays English. diff --git a/DOCS/MAIN.md b/DOCS/MAIN.md new file mode 100644 index 0000000..83aa57c --- /dev/null +++ b/DOCS/MAIN.md @@ -0,0 +1,437 @@ +# spacesh — техническая спецификация + +> Терминал-воркспейс под AI-агентов. macOS-first. Tauri 2 + Rust + React/TypeScript. +> Версия документа: v0.1 (handoff-ready). Назначение: спецификация для реализации. + +--- + +## 0. Допущения и зафиксированный скоуп + +Документ фиксирует решения, принятые на этапе проектирования. Несколько вещей сознательно **вынесены за рамки v1**: + +- **Учёт токенов и остатков лимитов — НЕ входит в v1.** Решено убрать из уравнения; провайдерские API для остатков грязные/против ToS, а ценность размыта существующими menu-bar трекерами. Может вернуться позже как отдельный подписчик на шину. +- **Юридический слой (авторизация пользователей, хранение чужих токенов, ToS-обвязка) — НЕ входит в v1.** Инструмент в первую очередь делается под себя. Вопрос продажи откладывается. +- **Встроенный браузер — выкинут.** Большая поверхность поддержки, нулевая ценность для агентского флоу. +- **Полноценный редактор (Monaco-класс) — отложен.** В v1 — дерево файлов + diff-просмотр изменений агента (CodeMirror 6), не IDE. + +**Фича «модификации» уточнена как внешние нотификации.** В v1 это рассылка уведомлений о событиях агентов во внешние каналы — **в первую очередь Telegram и MAX**. Реализуется как подписчик внутри демона на события шины (см. §9.2). Guardrails (перехват команд) — не входит в v1. + +--- + +## 1. Идея проекта + +### 1.1. Проблема + +Один AI-агент в терминале — управляем глазами. Пять-восемь агентов параллельно в разных проектах — нет: непонятно, кто закончил, кто упал, кто ждёт ввода, к кому возвращаться. Обычный терминал/tmux не сообщает статус сам, раскладки не переживают перезапуск удобно, а единого способа дёрнуть нужную панель из скрипта нет. + +### 1.2. Что такое spacesh + +Терминал-воркспейс, где **бэкенд-демон владеет живыми сессиями**, а GUI и CLI — тонкие клиенты к нему. Воркспейс на проект, реальные PTY со сплитами, видимый статус каждого агента (детерминированный, через хуки агентов), единый CLI поверх той же командной шины. Всё с клавиатуры, macOS-native. + +### 1.3. Ключевые отличия от аналогов (pilotry и пр.) + +1. **Демон переживает GUI.** Краш/обновление приложения не убивает агента в середине задачи. Прямая дорога к remote/SSH-воркспейсам (демон на ноде — GUI на макбуке). +2. **macOS-native.** Аналоги Windows-first; spacesh строится сразу под WKWebView, launchd, нативные хоткеи и уведомления. +3. **Детерминированный статус** через хуки агентов вместо угадывания по выводу. +4. **Единая командная шина** — GUI и CLI идут через один socket, CLI получается почти бесплатно. + +### 1.4. Чем spacesh НЕ является + +Не IDE, не браузер, не замена tmux в смысле мультиплексора для серверов (хотя архитектурно к remote готов), не агент сам по себе — spacesh запускает чужие официальные CLI (Claude Code, Codex, Gemini, opencode, shell) в PTY и не подменяет их логику. + +### 1.5. Целевой пользователь (v1) + +Senior-инфраструктурщик/разработчик, гоняющий несколько агентов параллельно на macOS, скриптующий своё окружение, ценящий клавиатурный флоу и переживаемость сессий. + +--- + +## 2. Принципы и нефункциональные требования + +| Принцип | Следствие в реализации | +|---|---| +| Демон — единственный источник истины | GUI и CLI не хранят состояние; только шлют команды и слушают события | +| Keyboard-first | Весь рабочий цикл закрывается с клавиатуры; мышь — только ресайз | +| macOS-native | launchd user-agent для демона, нативные уведомления с actions, WKWebView-aware фронт | +| Лёгкость | Системный webview (Tauri), без встроенного Chromium | +| Переживаемость | Сессии живут в демоне между перезапусками GUI | +| Расширяемость через шину | Новые фичи = новые команды/события/подписчики, спина не двигается | + +**Бюджеты производительности:** + +- Латентность ввода (keypress → echo в панели): < 16 мс при нормальной нагрузке. +- Батчинг вывода PTY: коалесцировать ~4–8 мс **или** до ~16 КБ, что раньше; не эмитить событие `output` по байту. +- Backpressure: при отставании рендера демон не должен расти в памяти неограниченно — кольцевой лимит на неподтверждённый поток к клиенту. +- Repaint при reattach: снапшот экрана панели < 100 мс на типовой грид. + +--- + +## 3. Архитектура — топология + +``` +┌─────────────────────────────────────────────────────────┐ +│ spacesh-daemon (spaceshd) │ +│ владеет: │ +│ • PTY-процессы (по surface) │ +│ • терминальный грид каждой панели (alacritty_terminal)│ +│ • модель воркспейсов / дерево сплитов / раскладки │ +│ • движок статусов │ +│ • подписчики (нотификации Telegram/MAX) │ +│ │ +│ хостит: Unix-domain socket ~/.spacesh/sock │ +└───────────────┬───────────────────────┬──────────────────┘ + │ │ + attach/команды/события команды/события (--json) + │ │ + ┌──────────┴─────────┐ ┌────────┴───────────┐ + │ GUI-клиент (Tauri) │ │ CLI-клиент (spacesh)│ + │ рендер xterm.js │ │ тонкий бинарь │ + │ ввод, attach/det. │ │ shell-completions │ + └────────────────────┘ └─────────────────────┘ +``` + +Один socket, один протокол. И клик в GUI, и `spacesh focus s_8f3` из скрипта — это одна и та же команда `focus`. Никакой дубликации операционной логики. + +--- + +## 4. Процессная модель (вариант B — демон) + +### 4.1. Жизненный цикл демона + +- Демон стартует лениво: первый клиент (GUI или CLI), не найдя socket, поднимает `spaceshd` и ждёт готовности. +- Под macOS оборачивается в **launchd user-agent** (`~/Library/LaunchAgents/xyz.spacesh.daemon.plist`) с `KeepAlive` — автоперезапуск при краше, автостарт при логине (опционально, по настройке). +- Демон single-instance: лок-файл `~/.spacesh/daemon.lock` + проверка живости socket. +- Graceful shutdown по команде `shutdown` или при отсутствии сессий и клиентов дольше TTL (настраиваемо; по умолчанию демон живёт, пока живы сессии). + +### 4.2. Переживаемость сессий + +PTY-процессы принадлежат демону, а не GUI. Закрытие/краш/обновление GUI **не убивает** агентов. При следующем запуске GUI выполняет `attach` к живым панелям и репейнтит их из снапшота грида (см. §6.4). + +Раскладка (определение воркспейсов, дерево сплитов, привязка агентов к панелям) персистится на диск отдельно от живых процессов (§8.4) — так что даже полная перезагрузка демона восстанавливает структуру (с перезапуском агентов), а краш GUI восстанавливает и структуру, и живые процессы. + +### 4.3. Эволюция к remote (вне v1, но заложено) + +Socket абстрагирован за трейтом транспорта. UDS локально сегодня → проброс через SSH (`ssh -L` к UDS на ноде) завтра → нативный TCP+mTLS позже. Демон не знает, локальный клиент или удалённый. + +--- + +## 5. Протокол командной шины + +### 5.1. Транспорт и кадрирование + +- Транспорт: Unix-domain socket, асинхронный (tokio). +- Кадрирование: **length-prefixed** (`u32` BE длина + payload). +- Сериализация: **JSON** в v1 (читаемость, отладка, `--json` для CLI). Возможна замена на MessagePack при упоре в объём вывода — изолировано в `spacesh-proto`. +- Мультиплексирование: один socket на клиента; внутри — корреляция запрос/ответ по `id`, плюс независимый поток push-событий. + +### 5.2. Конверт сообщения + +```jsonc +// Запрос (клиент → демон) +{ "id": 42, "kind": "req", "cmd": "focus", "args": { "surface_id": "s_8f3" } } + +// Ответ (демон → клиент), коррелируется по id +{ "id": 42, "kind": "res", "ok": true, "data": { /* ... */ } } +{ "id": 42, "kind": "res", "ok": false, "error": { "code": "NOT_FOUND", "msg": "..." } } + +// Событие (демон → подписчики), без id запроса +{ "kind": "evt", "evt": "state", "data": { "surface_id": "s_8f3", "state": "done" } } +``` + +### 5.3. Команды (request/response) + +| Команда | Аргументы | Ответ | Назначение | +|---|---|---|---| +| `open` | `{ path }` | `{ workspace_id }` | Открыть директорию воркспейсом; повторный вызов не плодит дубль | +| `close_workspace` | `{ workspace_id }` | `{}` | Закрыть воркспейс (с подтверждением при живых панелях) | +| `new_surface` | `{ workspace_id, agent, split? }` | `{ surface_id }` | Поднять панель с агентом; `split: right\|down` — сразу сплитом | +| `close` | `{ surface_id }` | `{}` | Закрыть панель | +| `focus` | `{ surface_id }` | `{}` | Сфокусировать панель и поднять окно | +| `input` | `{ surface_id, bytes }` | `{}` | Ввод с клавиатуры (base64 в JSON) | +| `resize` | `{ surface_id, cols, rows }` | `{}` | Ресайз PTY (→ SIGWINCH) | +| `attach` | `{ surface_id }` | `{ snapshot, cols, rows, cursor }` | Подписаться на панель + получить снапшот для репейнта | +| `detach` | `{ surface_id }` | `{}` | Отписаться от потока панели | +| `status` | `{ json? }` | `{ workspaces[] }` | Список воркспейсов и панелей с состояниями | +| `set_state` | `{ surface_id, state }` | `{}` | Внешний сигнал статуса (от хуков агента, см. §7) | +| `move_surface` | `{ surface_id, target }` | `{}` | Перетаскивание/переупорядочивание | +| `apply_preset` | `{ workspace_id, preset_id }` | `{ surface_ids[] }` | Развернуть пресет раскладки | +| `shutdown` | `{}` | `{}` | Остановить демон | + +### 5.4. События (push) + +| Событие | Данные | Назначение | +|---|---|---| +| `output` | `{ surface_id, bytes }` | Живой поток вывода PTY для рендера (батчированный) | +| `state` | `{ surface_id, state }` | Смена статуса: `done\|wait\|work\|error\|idle` | +| `exit` | `{ surface_id, code }` | Процесс панели завершился | +| `surface_created` | `{ surface_id, workspace_id, agent }` | Появилась панель | +| `surface_closed` | `{ surface_id }` | Панель закрыта | + +### 5.5. Жизненный цикл attach/detach (сердце варианта B) + +``` +GUI стартует + → status // получить структуру + → для каждой видимой панели: attach { surface_id } + ← res { snapshot, cursor } // репейнт xterm.js из снапшота + ← evt output ... // дальше живой поток +GUI сворачивается/закрывается + → detach (или просто рвёт соединение — демон чистит подписку по closed-socket) +GUI возвращается + → attach снова → новый снапшот → живой поток +``` + +Снапшот собирается демоном из Rust-грида (`alacritty_terminal`) и сериализуется в ANSI-дамп, который xterm.js скармливает себе для мгновенного репейнта. Это закрывает главную боль демон-архитектуры — восстановление экрана панели после reattach — без отдельного ring-buffer. + +--- + +## 6. Терминальный слой + +### 6.1. PTY + +- Крейт: **`portable-pty`** (спавн, master/slave, resize). Кроссплатформенно — пригодится, даже если v1 только macOS. +- На surface: один PTY + дочерний процесс (агентский CLI или shell), рабочая директория = папка воркспейса, прокинутый env. + +### 6.2. Reader-loop, батчинг, backpressure + +- Отдельная async-задача на PTY читает в буфер. +- **Коалесцирование:** накапливать вывод и эмитить `output` раз в ~4–8 мс или по достижении ~16 КБ. Билд-лог на 50k строк не должен порождать 50k событий. +- **Backpressure:** на каждого подписчика — ограниченный канал. При переполнении (рендер не успевает) — дропать/сжимать промежуточные кадры, но грид в демоне (§6.3) обновлять всегда — он авторитетный. + +### 6.3. Гибрид-парсинг (xterm.js рендер / Rust-грид семантика) + +Один байтовый поток PTY раздаётся в **три** места: + +1. **GUI** (событие `output`) → **xterm.js** парсит ANSI и рисует. Аддоны: `webgl` (рендер), `search`, `serialize`, `web-links`. +2. **Rust-грид** (`alacritty_terminal`) в демоне → авторитетная модель экрана/скроллбэка. Используется для: + - детекции статуса (fallback-паттерны, §7.3), + - поиска по скроллбэку на стороне демона, + - извлечения вывода последней команды (по маркерам OSC 133), + - снапшота при reattach (§6.4). +3. Тот же грид служит источником снапшота — отдельный буфер не нужен. + +Развод **отображения** (xterm.js) и **анализа** (alacritty-грид) снимает ложный выбор «или рендер, или умная модель». Два парсера на один stream дёшево. + +> Выбор `alacritty_terminal` (а не голого `vte`): он держит полноценный грид с курсором и скроллбэком из коробки, что нужно и для семантики, и для снапшота. `vte` дал бы только парсинг esc-последовательностей без модели экрана. + +### 6.4. Снапшот и репейнт + +При `attach` демон сериализует текущий грид панели → ANSI-дамп (видимая область + позиция курсора + при необходимости часть скроллбэка) → GUI пишет его в свежий инстанс xterm.js. Дальше едет живой `output`. Бюджет — см. §2. + +--- + +## 7. Детекция статуса (инверсия) + +Статус не угадывается из вывода, а **приходит в демон как команда** `set_state`. Источники, по убыванию надёжности: + +### 7.1. Хуки агентов (основной путь) + +- **Claude Code** поддерживает хуки (Stop / Notification и родственные). Вешаем на них вызов CLI: + ``` + spacesh notify --surface $SPACESH_SURFACE_ID --state done # на завершение + spacesh notify --surface $SPACESH_SURFACE_ID --state wait # на запрос ввода + ``` + CLI шлёт `set_state` в socket → демон ставит статус → событие `state` → кольцо панели меняет цвет. Детерминированно. +- `$SPACESH_SURFACE_ID` инжектируется в env панели при спавне — так хук знает, про какую панель сообщает. + +> **Внимание при реализации:** точные имена хуков и формат payload у Claude Code меняются между версиями. Слой интеграции с хуками изолировать в адаптер и версионировать; не зашивать имена в ядро. + +### 7.2. OSC 133 для shell + +Для обычных shell-панелей инжектировать shell-integration с **OSC 133** (semantic prompt marking): чёткие маркеры «команда началась / закончилась + exit code». Даёт статус (`work` между A/C-маркерами, `done`/`error` по exit code), а заодно навигацию по промптам и извлечение вывода последней команды. + +### 7.3. Fallback — паттерны вывода + +Для агентов без хуков — эвристики поверх alacritty-грида (спиннеры, промпт-паттерны, запросы подтверждения). Изолировано в один модуль, считается best-effort. Меньшинство кейсов. + +### 7.4. Состояния + +`work` (работает) · `wait` (ждёт ввода) · `done` (готов) · `error` (ошибка) · `idle` (простаивает). Маппятся на цвет кольца панели и бейдж в сайдбаре. + +--- + +## 8. Модель воркспейсов и раскладки + +### 8.1. Сущности + +- **Workspace** — папка проекта + дерево сплитов + метаданные (имя, цвет, группа, флаг «не забыть»). +- **Surface (панель)** — лист дерева сплитов; PTY + агент + состояние. +- **Split node** — внутренний узел: ориентация (`h`/`v`) + дети + пропорции. +- **Group** — именованная цветная коллекция воркспейсов с порядком. + +### 8.2. Дерево сплитов + +Рекурсивное бинарное (или n-арное) дерево. Ресайз — изменение пропорций узла. 10 пресет-раскладок: `1`, `2↔`, `2↕`, `2+1`, `1+2`, `3`, `2×2`, `4`, `2×3`, `2×4`. + +### 8.3. Пресеты + +Пресет = (раскладка панелей) + (привязка агента к каждой панели). Сохранённый пресет → запуск в один клик (`apply_preset`). Хранится в конфиге пользователя. + +### 8.4. Персистентность + +- Раскладка и метаданные — на диск (`~/.spacesh/state.json` или SQLite, если вырастет), отдельно от живых процессов. +- Сохранение по изменению (debounce), атомарная запись (temp + rename). +- При старте демона без живых сессий — структура восстанавливается, агенты перезапускаются по запросу/настройке. + +### 8.5. UX-метаданные (дёшево, эффект большой) + +- **Цветные группы** воркспейсов. +- **Метка «не забыть»** — unread-модель как в почте: кружок на воркспейсе, пока к нему не вернулись. +- **Свой порядок** — перетаскивание, раскладка сохраняется. +- **Центр событий** — лента уведомлений (готов/ждёт/ошибка) с «отметить прочитанным». + +--- + +## 9. Фичи поверх ядра (v1) + +### 9.1. Зум-панели + +Временный фуллскрин одной панели поверх раскладки. Чисто **UI-состояние GUI** — дерево сплитов не трогается, демона не касается. Хоткей-тоггл, по выходу возврат к прежней раскладке. + +### 9.2. Внешние нотификации (Telegram, MAX) + +Когда окно свёрнуто или ты не за макбуком, событие агента должно догнать снаружи. Реализуется как **подписчик внутри `spaceshd`** на события шины — спине это ничего не двигает, чистое расширение. + +**Механика:** + +- Подписчик слушает события `state` (по умолчанию реагирует на `done`, `wait`, `error`; набор настраивается) и `exit`. +- На совпадении формирует сообщение (`воркспейс · агент · состояние`, время, опционально хвост вывода) и диспетчит во включённые каналы. +- Каждый канал — отдельный **адаптер** за общим трейтом `NotificationChannel { async fn send(&self, msg) }`. Добавить новый канал = новый адаптер, ядро не трогается. + +**Адаптеры v1:** + +| Канал | Транспорт | Конфиг | Заметки | +|---|---|---|---| +| **Telegram** | Bot API, HTTPS `sendMessage` | `bot_token`, `chat_id` | Бот создаётся через @BotFather. Только исходящие — webhook/long-poll не нужны | +| **MAX** | MAX Bot API (`dev.max.ru`), HTTPS, JSON | `bot_token`, `chat_id` | Токен от Master Bot. Авторизация заголовком `Authorization: ` (query-параметр устарел). Только исходящие | + +> MAX Bot API построен по тем же принципам, что Telegram (токен → HTTPS-запросы → JSON), но детали отличаются: тип апдейтов в явном поле `update_type`, свой набор методов и формат сообщений. Для нотификаций нам нужен только метод отправки сообщения — приём апдейтов/webhook не требуется. Есть официальный TS-клиент `@maxhub/max-bot-api` и python-клиенты, если не захочется бить по HTTP напрямую. + +**Поведение:** + +- **Анти-спам / дедуп:** не слать повторное уведомление об одном и том же состоянии панели; коалесцировать всплески (несколько `done` подряд → одно сводное). Окно дедупа настраивается. +- **Rate limits:** соблюдать лимиты каналов (у MAX базово ~30 msg/s на бота; Telegram ~30 msg/s глобально, ~1 msg/s на чат). Для персонального инструмента не критично, но очередь с троттлингом заложить. +- **Фильтр по воркспейсу/группе:** опционально слать только по помеченным воркспейсам (увязывается с меткой «не забыть», §8.5). +- **Доставка best-effort:** сбой канала логируется, ретраится ограниченно, не блокирует работу демона. + +**Конфиг (пример, `~/.spacesh/config.toml`):** + +```toml +[notifications] +on_states = ["done", "wait", "error"] +dedup_window_secs = 30 + +[notifications.telegram] +enabled = true +bot_token = "env:SPACESH_TG_TOKEN" +chat_id = "123456789" + +[notifications.max] +enabled = true +bot_token = "env:SPACESH_MAX_TOKEN" +chat_id = "..." +``` + +> Токены — через ссылку на env-переменную, не хранить в открытом виде в конфиге. + +--- + +## 10. Структура кода + +### 10.1. Rust workspace (cargo) + +``` +spacesh/ +├── crates/ +│ ├── spacesh-proto/ # типы команд/событий, serde, кадрирование. Шарится всеми. +│ ├── spacesh-core/ # модель воркспейсов/сплитов, грид (alacritty_terminal), +│ │ # движок статусов. Без I/O — юнит-тестируемо. +│ ├── spacesh-pty/ # спавн/io PTY (portable-pty), reader-loop, батчинг, resize. +│ ├── spaceshd/ # демон: владеет сессиями, хостит socket, fan-out событий, +│ │ # персист раскладки, launchd-обвязка, подписчики. +│ └── spacesh-cli/ # тонкий бинарь-клиент к socket. +└── app/ # Tauri-приложение + ├── src-tauri/ # Rust-сторона: клиент к socket + мост в webview + └── src/ # React/TS фронт +``` + +| Крейт | Ответственность | Ключевые зависимости | +|---|---|---| +| `spacesh-proto` | Протокол: типы, serde, length-prefix framing | `serde`, `serde_json` | +| `spacesh-core` | Доменная модель, грид, статусы | `alacritty_terminal` | +| `spacesh-pty` | PTY io, батчинг, backpressure | `portable-pty`, `tokio` | +| `spaceshd` | Демон, socket-сервер, fan-out, персист, подписчики-нотификации (Telegram/MAX) | `tokio`, `reqwest`, `spacesh-*` | +| `spacesh-cli` | CLI-клиент | `clap`, `tokio` | +| `app/src-tauri` | Мост socket ↔ webview | `tauri` 2, `tokio` | + +### 10.2. React/TS фронт + +| Компонент | Ответственность | +|---|---| +| `TerminalView` | xterm.js + аддоны (webgl/search/serialize/links) на панель; ввод → `input`, репейнт из снапшота | +| `LayoutEngine` | рекурсивное дерево сплитов, ресайз мышью, **зум-панели** | +| `Sidebar` | воркспейсы/группы, цвета, метка «не забыть», бейджи статуса | +| `EventCenter` | лента уведомлений, «отметить прочитанным» | +| `Wizard` | новый воркспейс (папка+раскладка→агент в панель), пресеты | +| `Settings` | конфиг нотификаций (Telegram/MAX), хоткеи, правила; редактирование `config.toml` | +| `socketBridge` | подписка на события демона через Tauri IPC, отправка команд | + +--- + +## 11. Хоткеи (черновой набор) + +| Действие | Хоткей | +|---|---| +| Новый воркспейс | `Cmd + N` | +| Переключить воркспейс | `Cmd + 1…9` | +| Сплит панели | `Cmd + Shift + T` | +| К свежему агенту | `Cmd + Shift + U` | +| Соседняя панель | `Cmd + Opt + ← ↑ → ↓` | +| Зум панели (тоггл) | `Cmd + Shift + Z` | +| Поиск по скроллбэку | `Cmd + F` | +| Настройки | `Cmd + ,` | + +> macOS-маппинг (Cmd вместо Ctrl). Сверить конфликты с системными/агентскими хоткеями; вынести в настраиваемую схему. + +--- + +## 12. Порядок реализации (milestones) + +**M0 — живой терминал через socket (вертикальный срез).** +`spacesh-proto` + `spaceshd` с командами `open` / `new_surface` / `input` / `output` + реальный PTY (`spacesh-pty`). Цель: байты летают GUI ↔ демон ↔ PTY. Минимальный фронт с одним `TerminalView`. + +**M1 — переживаемость и reattach.** +Грид в демоне (`spacesh-core` + alacritty), `attach`/`detach` со снапшотом, репейнт xterm.js. launchd-обвязка. Цель: убил GUI — агент жив, открыл — экран восстановился. + +**M2 — раскладки и воркспейсы.** +`LayoutEngine`, дерево сплитов, ресайз, персист (`state.json`), сайдбар с воркспейсами. Пресеты + `apply_preset`. Wizard. + +**M3 — статусы.** +`set_state`, интеграция хуков Claude Code, OSC 133 для shell, fallback-паттерны. Кольца + бейджи + центр событий + нативные уведомления. + +**M4 — CLI.** +`spacesh-cli` поверх готовой шины: `open`/`status`/`focus`/`close`/`new-surface`/`notify`, `--json`, shell-completions. + +**M5 — фичи поверх.** +Зум-панели, внешние нотификации (Telegram + MAX, §9.2), поиск по скроллбэку, diff-просмотр изменений агента (CodeMirror 6). + +**M6 (post-v1) — remote.** +Транспорт-трейт → проброс socket через SSH → демон на ноде, GUI на макбуке. + +--- + +## 13. Открытые вопросы и риски + +| # | Вопрос/риск | Заметка | +|---|---|---| +| 1 | Имена/формат хуков Claude Code дрейфуют по версиям | Изолировать в версионируемый адаптер; не зашивать в ядро | +| 2 | Нотификации: блокировка бота за массовые рассылки/спам (особенно MAX) | Только исходящие, низкий объём, дедуп + троттлинг; не использовать для рассылок | +| 3 | WKWebView ≠ WebView2: IME (рус/англ), фокус, DnD, перехват клавиш | Отлаживать на macOS с нуля, не переносить допущения с Windows-аналогов | +| 4 | Производительность xterm.js при шквальном выводе | webgl-аддон обязателен; батчинг §6.2; профилировать на реальных билд-логах | +| 5 | Подпись/нотаризация для раздачи (Developer ID, stapling) | Заложить в CI заранее, иначе Gatekeeper ломает «скачал и запустил» | +| 6 | Автообновление вне App Store | Tauri updater / Sparkle; решить на M1 | +| 7 | Single-instance демона и гонки при старте | Лок-файл + проверка живости socket; обработать «socket есть, демон мёртв» | +| 8 | Объём `output` по JSON-socket | Если упрёмся — MessagePack, изменение локализовано в `spacesh-proto` | + +--- + +*Конец спецификации v0.1.* \ No newline at end of file diff --git a/DOCS/space-sh.pen b/DOCS/space-sh.pen new file mode 100644 index 0000000..4440909 --- /dev/null +++ b/DOCS/space-sh.pen @@ -0,0 +1,8697 @@ +{ + "version": "2.13", + "children": [ + { + "type": "frame", + "id": "j3fyy", + "x": 0, + "y": 0, + "name": "spacesh — Main v1", + "clip": true, + "width": 1440, + "height": 900, + "fill": "$bg-app", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "SwV51", + "name": "TitleBar", + "width": "fill_container", + "height": 40, + "fill": "$bg-sidebar", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 14, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ALv3M", + "name": "TrafficLights", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "lKXxS", + "name": "dot", + "fill": "#F4544E", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "RSGDw", + "name": "dot", + "fill": "#F2B84B", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "LpXSt", + "name": "dot", + "fill": "#3FB950", + "width": 12, + "height": 12 + } + ] + }, + { + "type": "frame", + "id": "VLvW0", + "name": "tog-panel-left", + "width": 26, + "height": 26, + "fill": "$bg-elevated", + "cornerRadius": 6, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "J6klz", + "name": "i", + "width": 15, + "height": 15, + "icon": "panel-left", + "library": "lucide", + "fill": "$text-secondary" + } + ] + }, + { + "type": "frame", + "id": "JjATh", + "name": "TitleWrap", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "rvmGE", + "name": "WsIcon", + "width": 15, + "height": 15, + "icon": "folder-git-2", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "U6vNK3", + "name": "WsName", + "fill": "$text-primary", + "content": "infra-platform", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "M3Xx1", + "name": "Sep", + "fill": "$text-muted", + "content": "/", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "fViRT", + "name": "Layout", + "fill": "$text-secondary", + "content": "2×2 grid", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "nhzzq", + "name": "Spacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "FsV9X", + "name": "TitleRight", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "sPfp4", + "name": "tog-panel-right", + "width": 26, + "height": 26, + "fill": "$bg-elevated", + "cornerRadius": 6, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "f0YiJw", + "name": "i", + "width": 15, + "height": 15, + "icon": "panel-right", + "library": "lucide", + "fill": "$text-secondary" + } + ] + }, + { + "type": "icon", + "id": "ihO7S", + "name": "search", + "width": 16, + "height": 16, + "icon": "search", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "icon", + "id": "pU6Tz", + "name": "bell", + "width": 16, + "height": 16, + "icon": "bell", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "icon", + "id": "iAyXd", + "name": "settings", + "width": 16, + "height": 16, + "icon": "settings", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "rectangle", + "id": "mOUCJ", + "name": "sep", + "fill": "$border-strong", + "width": 1, + "height": 18 + }, + { + "id": "MVIH6", + "type": "ref", + "ref": "HyPQi", + "name": "AccountMenu" + } + ] + } + ] + }, + { + "type": "frame", + "id": "tZWa8", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "XLm8Z", + "name": "Sidebar", + "clip": true, + "width": 248, + "height": "fill_container", + "fill": "$bg-sidebar", + "stroke": "$border-subtle", + "strokeWidth": { + "right": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 18, + "padding": 14, + "children": [ + { + "type": "frame", + "id": "WF0JR", + "name": "NewWorkspace", + "width": "fill_container", + "height": 34, + "fill": "$bg-elevated", + "cornerRadius": 7, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "R9ZsK", + "name": "plus", + "width": 15, + "height": 15, + "icon": "plus", + "library": "lucide", + "fill": "$text-primary" + }, + { + "type": "text", + "id": "nMSWi", + "name": "lbl", + "fill": "$text-primary", + "content": "New workspace", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "DdaNP", + "name": "hk", + "fill": "$text-muted", + "content": "⌘N", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "R4PifQ", + "name": "Group production", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "BSC4B", + "name": "GroupHead", + "width": "fill_container", + "height": 24, + "gap": 7, + "padding": [ + 0, + 4 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Kralo", + "name": "chev", + "width": 13, + "height": 13, + "icon": "chevron-down", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "hc1Gn", + "name": "clr", + "fill": "#F4544E", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "ayHhT", + "name": "gname", + "fill": "$text-secondary", + "content": "PRODUCTION", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "700", + "letterSpacing": 0.5 + } + ] + }, + { + "type": "frame", + "id": "wfeTP", + "name": "WS infra-platform", + "width": "fill_container", + "height": 34, + "fill": "$bg-elevated", + "cornerRadius": 6, + "gap": 10, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "c78EMF", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-work", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "a64aDQ", + "name": "nm", + "fill": "$text-primary", + "content": "infra-platform", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "W7b1n8", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "TLSuh", + "name": "count", + "height": 18, + "fill": "$bg-app", + "cornerRadius": 9, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "QaXIo", + "name": "c", + "fill": "$text-secondary", + "content": "4", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "K0toIh", + "name": "WS api-gateway", + "width": "fill_container", + "height": 34, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 10, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "WqDGB", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-wait", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "ulwwC", + "name": "nm", + "fill": "$text-secondary", + "content": "api-gateway", + "fontFamily": "$font-ui", + "fontSize": 13 + }, + { + "type": "frame", + "id": "I7azp", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "ellipse", + "id": "p1osF", + "name": "unread", + "fill": "$accent", + "width": 7, + "height": 7 + }, + { + "type": "frame", + "id": "YsP67", + "name": "count", + "height": 18, + "fill": "$bg-app", + "cornerRadius": 9, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "k7EoU", + "name": "c", + "fill": "$text-secondary", + "content": "2", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "d3dK9", + "name": "WS billing-svc", + "width": "fill_container", + "height": 34, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 10, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "iqJV3", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-error", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "xwTgv", + "name": "nm", + "fill": "$text-secondary", + "content": "billing-svc", + "fontFamily": "$font-ui", + "fontSize": 13 + }, + { + "type": "frame", + "id": "x3Y2jb", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "ellipse", + "id": "YtEjw", + "name": "unread", + "fill": "$accent", + "width": 7, + "height": 7 + }, + { + "type": "frame", + "id": "VU5mW", + "name": "count", + "height": 18, + "fill": "$bg-app", + "cornerRadius": 9, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wOhoa", + "name": "c", + "fill": "$text-secondary", + "content": "1", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rKjgE", + "name": "WS k8s-upgrade", + "width": "fill_container", + "height": 34, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 10, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "yKiqv", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-done", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "Jj6yb", + "name": "nm", + "fill": "$text-secondary", + "content": "k8s-upgrade", + "fontFamily": "$font-ui", + "fontSize": 13 + }, + { + "type": "frame", + "id": "OmdfX", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "cumPC", + "name": "count", + "height": 18, + "fill": "$bg-app", + "cornerRadius": 9, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ZIbRu", + "name": "c", + "fill": "$text-secondary", + "content": "3", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "gflm8", + "name": "Group personal", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "jVfgG", + "name": "GroupHead", + "width": "fill_container", + "height": 24, + "gap": 7, + "padding": [ + 0, + 4 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "HoREo", + "name": "chev", + "width": 13, + "height": 13, + "icon": "chevron-down", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "rD1Pm", + "name": "clr", + "fill": "#4C8DFF", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "e1MoG", + "name": "gname", + "fill": "$text-secondary", + "content": "PERSONAL", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "700", + "letterSpacing": 0.5 + } + ] + }, + { + "type": "frame", + "id": "i2YP3", + "name": "WS dotfiles", + "width": "fill_container", + "height": 34, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 10, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "YiEkk", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-idle", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "FvTN6", + "name": "nm", + "fill": "$text-secondary", + "content": "dotfiles", + "fontFamily": "$font-ui", + "fontSize": 13 + }, + { + "type": "frame", + "id": "UeoO3", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "x6kB5", + "name": "count", + "height": 18, + "fill": "$bg-app", + "cornerRadius": 9, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OFMSC", + "name": "c", + "fill": "$text-secondary", + "content": "1", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "G8Z2S", + "name": "WS spacesh", + "width": "fill_container", + "height": 34, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 10, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "oZh5n", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-work", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "OrC1e", + "name": "nm", + "fill": "$text-secondary", + "content": "spacesh", + "fontFamily": "$font-ui", + "fontSize": 13 + }, + { + "type": "frame", + "id": "V7pAcT", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "AXuJE", + "name": "count", + "height": 18, + "fill": "$bg-app", + "cornerRadius": 9, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NR4CC", + "name": "c", + "fill": "$text-secondary", + "content": "2", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dt8P1", + "name": "WS blog", + "width": "fill_container", + "height": 34, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 10, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "ZpMpj", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-idle", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "IUOiL", + "name": "nm", + "fill": "$text-secondary", + "content": "blog", + "fontFamily": "$font-ui", + "fontSize": 13 + }, + { + "type": "frame", + "id": "gAhgS", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "W0n8Tv", + "name": "count", + "height": 18, + "fill": "$bg-app", + "cornerRadius": 9, + "padding": [ + 0, + 6 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "axoeS", + "name": "c", + "fill": "$text-secondary", + "content": "0", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Mi1dB", + "name": "SidebarSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "dFhKf", + "name": "DaemonStatus", + "width": "fill_container", + "height": 30, + "fill": "$bg-panel", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 0, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "O6rTVj", + "name": "live", + "fill": "$st-done", + "width": 7, + "height": 7 + }, + { + "type": "text", + "id": "SEvZY", + "name": "dt", + "fill": "$text-secondary", + "content": "spaceshd · live", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "etGFp", + "name": "sp2", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "ZD9am", + "name": "up", + "fill": "$text-muted", + "content": "3d 4h", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "f1TSX0", + "name": "Center", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$bg-app", + "layout": "vertical", + "gap": 10, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "Kxl4a", + "name": "CenterToolbar", + "width": "fill_container", + "height": 30, + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "B1ewN", + "name": "Presets", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MLTxF", + "name": "preset 1", + "height": 24, + "fill": "#00000000", + "cornerRadius": 6, + "stroke": "#00000000", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 5, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "wSWeT", + "name": "i", + "width": 13, + "height": 13, + "icon": "square", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "Pa9yY", + "name": "l", + "fill": "$text-muted", + "content": "1", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "tcrtN", + "name": "preset 2↔", + "height": 24, + "fill": "#00000000", + "cornerRadius": 6, + "stroke": "#00000000", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 5, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Giz9T", + "name": "i", + "width": 13, + "height": 13, + "icon": "columns-2", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "ky9Yd", + "name": "l", + "fill": "$text-muted", + "content": "2↔", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LD2dV", + "name": "preset 2↕", + "height": 24, + "fill": "#00000000", + "cornerRadius": 6, + "stroke": "#00000000", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 5, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "GajWo", + "name": "i", + "width": 13, + "height": 13, + "icon": "rows-2", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "T9LvZ", + "name": "l", + "fill": "$text-muted", + "content": "2↕", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OSuwG", + "name": "preset 2×2", + "height": 24, + "fill": "$bg-elevated", + "cornerRadius": 6, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 5, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "pfr1D", + "name": "i", + "width": 13, + "height": 13, + "icon": "layout-grid", + "library": "lucide", + "fill": "$text-primary" + }, + { + "type": "text", + "id": "onf04", + "name": "l", + "fill": "$text-primary", + "content": "2×2", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xZ7Ar", + "name": "preset 2+1", + "height": 24, + "fill": "#00000000", + "cornerRadius": 6, + "stroke": "#00000000", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 5, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "F77Wn", + "name": "i", + "width": 13, + "height": 13, + "icon": "layout-dashboard", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "Yl3tD", + "name": "l", + "fill": "$text-muted", + "content": "2+1", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "f4U7ET", + "name": "tsp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "kLn0K", + "name": "ScrollbackSearch", + "height": 24, + "fill": "$bg-panel", + "cornerRadius": 6, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 6, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "MVAV6", + "name": "s", + "width": 12, + "height": 12, + "icon": "search", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "ykcn3", + "name": "sl", + "fill": "$text-muted", + "content": "Search scrollback", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "UKsO6", + "name": "sk", + "fill": "$text-muted", + "content": "⌘F", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "NgA88", + "name": "Grid", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "ZfxQy", + "name": "Row1", + "width": "fill_container", + "height": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "y9Ave6", + "name": "Panel claude", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 8, + "stroke": "$accent", + "strokeWidth": 2, + "strokeAlignment": "inner", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "wc3DI", + "name": "Head", + "width": "fill_container", + "height": 30, + "fill": "$bg-elevated", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "X3jKiv", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-work", + "width": 9, + "height": 9 + }, + { + "type": "text", + "id": "UcTw4", + "name": "ag", + "fill": "$text-primary", + "content": "claude", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "s0r9Rb", + "name": "pt", + "fill": "$text-muted", + "content": "~/infra-platform", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "aqzzJ", + "name": "hsp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "XxFOh", + "name": "badge", + "height": 16, + "fill": "#000000", + "cornerRadius": 8, + "padding": [ + 0, + 7 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K6MfZy", + "name": "bt", + "fill": "$st-work", + "content": "work", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "600" + } + ] + }, + { + "type": "icon", + "id": "tUA3l", + "name": "zoom", + "width": 13, + "height": 13, + "icon": "maximize-2", + "library": "lucide", + "fill": "$text-muted" + } + ] + }, + { + "type": "frame", + "id": "BFGtj", + "name": "Body", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 3, + "padding": 10, + "children": [ + { + "type": "text", + "id": "d7YN8n", + "name": "l", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "› refactor terraform modules into reusable stacks", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "by77A", + "name": "l", + "fill": "$accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "● Editing modules/network/main.tf", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "i7CEI", + "name": "l", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " +42 −18 · 3 files changed", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wucZ0", + "name": "l", + "fill": "$st-work", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "⠋ Running terraform validate…", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vnHz0", + "name": "Panel codex", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 8, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "C5UQog", + "name": "Head", + "width": "fill_container", + "height": 30, + "fill": "$bg-elevated", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "N6qXcR", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-wait", + "width": 9, + "height": 9 + }, + { + "type": "text", + "id": "MCs1V", + "name": "ag", + "fill": "$text-primary", + "content": "codex", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "UJSXn", + "name": "pt", + "fill": "$text-muted", + "content": "~/infra-platform/api", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "N7T1s", + "name": "hsp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "c1p5dd", + "name": "badge", + "height": 16, + "fill": "#000000", + "cornerRadius": 8, + "padding": [ + 0, + 7 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RA6fr", + "name": "bt", + "fill": "$st-wait", + "content": "wait", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "600" + } + ] + }, + { + "type": "icon", + "id": "Z4syYO", + "name": "zoom", + "width": 13, + "height": 13, + "icon": "maximize-2", + "library": "lucide", + "fill": "$text-muted" + } + ] + }, + { + "type": "frame", + "id": "VTX94", + "name": "Body", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 3, + "padding": 10, + "children": [ + { + "type": "text", + "id": "V7e2dc", + "name": "l", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "› add rate-limit middleware to gateway", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "picqt", + "name": "l", + "fill": "$st-done", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "✓ wrote middleware/ratelimit.go", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "m3WPzn", + "name": "prompt", + "width": "fill_container", + "fill": "#000000", + "cornerRadius": 6, + "stroke": "$st-wait", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 4, + "padding": 8, + "children": [ + { + "type": "text", + "id": "oEYZ1", + "name": "q", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Apply changes to gateway/router.go?", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "gTASV", + "name": "opts", + "gap": 6, + "children": [ + { + "type": "text", + "id": "QnjSf", + "name": "y", + "fill": "$st-wait", + "content": "❯ 1. Yes", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + }, + { + "type": "text", + "id": "BrOJ8", + "name": "n", + "fill": "$text-muted", + "content": "2. No", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zfxOJ", + "name": "Row2", + "width": "fill_container", + "height": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "Xbdov", + "name": "Panel gemini", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 8, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "abyyq", + "name": "Head", + "width": "fill_container", + "height": 30, + "fill": "$bg-elevated", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "EGltW", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-done", + "width": 9, + "height": 9 + }, + { + "type": "text", + "id": "kGxea", + "name": "ag", + "fill": "$text-primary", + "content": "gemini", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "R6xnKw", + "name": "pt", + "fill": "$text-muted", + "content": "~/infra-platform/db", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "li6do", + "name": "hsp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "jgKCu", + "name": "badge", + "height": 16, + "fill": "#000000", + "cornerRadius": 8, + "padding": [ + 0, + 7 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "f1W3B", + "name": "bt", + "fill": "$st-done", + "content": "done", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "600" + } + ] + }, + { + "type": "icon", + "id": "pdUbd", + "name": "zoom", + "width": 13, + "height": 13, + "icon": "maximize-2", + "library": "lucide", + "fill": "$text-muted" + } + ] + }, + { + "type": "frame", + "id": "gPZMh", + "name": "Body", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 3, + "padding": 10, + "children": [ + { + "type": "text", + "id": "R391Qr", + "name": "l", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "› generate migration for billing table", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Q3zBo", + "name": "l", + "fill": "$st-done", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "✓ created 0042_billing.sql", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "eghI9", + "name": "l", + "fill": "$st-done", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "✓ applied · 1 migration up", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "a6IGrC", + "name": "l", + "fill": "$text-muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "done in 12.4s", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qdVHx", + "name": "Panel shell", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 8, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "nbr5U", + "name": "Head", + "width": "fill_container", + "height": 30, + "fill": "$bg-elevated", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "M0KyA", + "name": "ring", + "innerRadius": 0.5, + "fill": "$st-error", + "width": 9, + "height": 9 + }, + { + "type": "text", + "id": "WiS3z", + "name": "ag", + "fill": "$text-primary", + "content": "shell", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "NSltf", + "name": "pt", + "fill": "$text-muted", + "content": "~/infra-platform", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "XOcAL", + "name": "hsp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "W2PSg", + "name": "badge", + "height": 16, + "fill": "#000000", + "cornerRadius": 8, + "padding": [ + 0, + 7 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FTwSk", + "name": "bt", + "fill": "$st-error", + "content": "error", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "600" + } + ] + }, + { + "type": "icon", + "id": "HU7Yz", + "name": "zoom", + "width": 13, + "height": 13, + "icon": "maximize-2", + "library": "lucide", + "fill": "$text-muted" + } + ] + }, + { + "type": "frame", + "id": "qHBxP", + "name": "Body", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 3, + "padding": 10, + "children": [ + { + "type": "text", + "id": "A8ddY", + "name": "l", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "$ make deploy ENV=staging", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "dbqZ8", + "name": "l", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "==> building image…", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "APN7Q", + "name": "l", + "fill": "$st-error", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Error: exit status 1", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "zWQ36", + "name": "l", + "fill": "$st-error", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " pull access denied for registry/api", + "lineHeight": 1.35, + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "SSGSD", + "name": "EventCenter", + "clip": true, + "width": 300, + "height": "fill_container", + "fill": "$bg-sidebar", + "stroke": "$border-subtle", + "strokeWidth": { + "left": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 14, + "padding": 14, + "children": [ + { + "type": "frame", + "id": "c1fHu", + "name": "ECHead", + "width": "fill_container", + "height": 24, + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "uLt1H", + "name": "t", + "fill": "$text-primary", + "content": "Event Center", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "xhT6c", + "name": "hsp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "H1H4o", + "name": "clr", + "fill": "$accent", + "content": "Mark all read", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gbeBu", + "name": "ECTabs", + "width": "fill_container", + "height": 26, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "YLmv4", + "name": "tab All", + "height": 22, + "fill": "#00000000", + "cornerRadius": 11, + "stroke": "#00000000", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": [ + 0, + 9 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "tqHcd", + "name": "l", + "fill": "$text-muted", + "content": "All", + "fontFamily": "$font-ui", + "fontSize": 11 + } + ] + }, + { + "type": "frame", + "id": "bVcup", + "name": "tab Unread", + "height": 22, + "fill": "$bg-elevated", + "cornerRadius": 11, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": [ + 0, + 9 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rlQTo", + "name": "l", + "fill": "$text-primary", + "content": "Unread", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "FFSp3", + "name": "tab Errors", + "height": 22, + "fill": "#00000000", + "cornerRadius": 11, + "stroke": "#00000000", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": [ + 0, + 9 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kC6h3", + "name": "l", + "fill": "$text-muted", + "content": "Errors", + "fontFamily": "$font-ui", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "frame", + "id": "b4OBb", + "name": "Feed", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "C6iO9", + "name": "ev shell", + "width": "fill_container", + "fill": "$bg-elevated", + "cornerRadius": 8, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "padding": 10, + "children": [ + { + "type": "icon", + "id": "kECJ7", + "name": "ic", + "width": 15, + "height": 15, + "icon": "circle-x", + "library": "lucide", + "fill": "$st-error" + }, + { + "type": "frame", + "id": "q3c6J", + "name": "col", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "ZwYot", + "name": "top", + "width": "fill_container", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FPDtw", + "name": "src", + "fill": "$text-secondary", + "content": "billing-svc · shell", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "tRu1K", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "WDEwu", + "name": "tm", + "fill": "$text-muted", + "content": "2m", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "rE2mg", + "name": "msg", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "deploy failed: pull access denied for registry/api", + "lineHeight": 1.35, + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "ellipse", + "id": "EAjrv", + "name": "u", + "fill": "$accent", + "width": 7, + "height": 7 + } + ] + }, + { + "type": "frame", + "id": "qbARl", + "name": "ev codex", + "width": "fill_container", + "fill": "$bg-elevated", + "cornerRadius": 8, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "padding": 10, + "children": [ + { + "type": "icon", + "id": "wIRPV", + "name": "ic", + "width": 15, + "height": 15, + "icon": "keyboard", + "library": "lucide", + "fill": "$st-wait" + }, + { + "type": "frame", + "id": "N2V3Er", + "name": "col", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "ZG4s8", + "name": "top", + "width": "fill_container", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "F6cPXr", + "name": "src", + "fill": "$text-secondary", + "content": "api-gateway · codex", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "EbNyp", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "CtMxz", + "name": "tm", + "fill": "$text-muted", + "content": "5m", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "aBv7X", + "name": "msg", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "needs confirmation: apply changes to router.go", + "lineHeight": 1.35, + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "ellipse", + "id": "CTSZT", + "name": "u", + "fill": "$accent", + "width": 7, + "height": 7 + } + ] + }, + { + "type": "frame", + "id": "nQuR8", + "name": "ev gemini", + "width": "fill_container", + "fill": "#00000000", + "cornerRadius": 8, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "padding": 10, + "children": [ + { + "type": "icon", + "id": "tnmjv", + "name": "ic", + "width": 15, + "height": 15, + "icon": "circle-check", + "library": "lucide", + "fill": "$st-done" + }, + { + "type": "frame", + "id": "ysgOR", + "name": "col", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "Wi0GE", + "name": "top", + "width": "fill_container", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CpkqO", + "name": "src", + "fill": "$text-secondary", + "content": "infra-platform · gemini", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "L8QgO", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "VgKUs", + "name": "tm", + "fill": "$text-muted", + "content": "11m", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "RjBOQ", + "name": "msg", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "migration 0042_billing applied · 1 up", + "lineHeight": 1.35, + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zCiPP", + "name": "ev claude", + "width": "fill_container", + "fill": "#00000000", + "cornerRadius": 8, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "padding": 10, + "children": [ + { + "type": "icon", + "id": "T4CxOE", + "name": "ic", + "width": 15, + "height": 15, + "icon": "circle-check", + "library": "lucide", + "fill": "$st-done" + }, + { + "type": "frame", + "id": "WZMhv", + "name": "col", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "zeE5R", + "name": "top", + "width": "fill_container", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TFyeX", + "name": "src", + "fill": "$text-secondary", + "content": "k8s-upgrade · claude", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "I4n9ff", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "jFk8C", + "name": "tm", + "fill": "$text-muted", + "content": "1h", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "n21eV", + "name": "msg", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "task complete · 3 files changed", + "lineHeight": 1.35, + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "z4GIQ8", + "name": "ev claude", + "width": "fill_container", + "fill": "#00000000", + "cornerRadius": 8, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "padding": 10, + "children": [ + { + "type": "icon", + "id": "fWFmW", + "name": "ic", + "width": 15, + "height": 15, + "icon": "loader", + "library": "lucide", + "fill": "$st-work" + }, + { + "type": "frame", + "id": "Ls0Zs", + "name": "col", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "gteZS", + "name": "top", + "width": "fill_container", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "BDlet", + "name": "src", + "fill": "$text-secondary", + "content": "infra-platform · claude", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "qgZRp", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "RGbD1", + "name": "tm", + "fill": "$text-muted", + "content": "1h", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "CtyV7", + "name": "msg", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "started: refactor terraform modules", + "lineHeight": 1.35, + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "AQqGl", + "name": "Channels", + "width": "fill_container", + "stroke": "$border-subtle", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": [ + 10, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "zsrE6", + "name": "ct", + "fill": "$text-muted", + "content": "EXTERNAL NOTIFY", + "fontFamily": "$font-ui", + "fontSize": 10, + "fontWeight": "700", + "letterSpacing": 0.5 + }, + { + "type": "frame", + "id": "NQdeS", + "name": "chrow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "y43Pp", + "name": "Telegram", + "width": "fill_container", + "height": 30, + "fill": "$bg-panel", + "cornerRadius": 7, + "gap": 7, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "rj6ip", + "name": "i", + "width": 13, + "height": 13, + "icon": "send", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "J6CKjW", + "name": "n", + "fill": "$text-primary", + "content": "Telegram", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "kbeMc", + "name": "s", + "width": "fill_container", + "height": 1 + }, + { + "type": "ellipse", + "id": "mkXnI", + "name": "on", + "fill": "$st-done", + "width": 7, + "height": 7 + } + ] + }, + { + "type": "frame", + "id": "QokP3", + "name": "MAX", + "width": "fill_container", + "height": 30, + "fill": "$bg-panel", + "cornerRadius": 7, + "gap": 7, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "rrp0p", + "name": "i", + "width": 13, + "height": 13, + "icon": "message-circle", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "F91u5", + "name": "n", + "fill": "$text-primary", + "content": "MAX", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "J2Hp8", + "name": "s", + "width": "fill_container", + "height": 1 + }, + { + "type": "ellipse", + "id": "XOaDq", + "name": "on", + "fill": "$st-done", + "width": 7, + "height": 7 + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "J5j1Uy", + "x": 1520, + "y": 0, + "name": "spacesh — Code & Diff", + "clip": true, + "width": 1440, + "height": 900, + "fill": "$bg-app", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "YYCoz", + "name": "TitleBar", + "width": "fill_container", + "height": 40, + "fill": "$bg-sidebar", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 14, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gwbXe", + "name": "lights", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "RdICv", + "name": "d", + "fill": "#F4544E", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "DnBVo", + "name": "d", + "fill": "#F2B84B", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "zBaOa", + "name": "d", + "fill": "#3FB950", + "width": 12, + "height": 12 + } + ] + }, + { + "type": "frame", + "id": "T5dev", + "name": "tog-panel-left", + "width": 26, + "height": 26, + "fill": "$bg-elevated", + "cornerRadius": 6, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "m4Jvnk", + "name": "i", + "width": 15, + "height": 15, + "icon": "panel-left", + "library": "lucide", + "fill": "$text-secondary" + } + ] + }, + { + "type": "frame", + "id": "M3f03", + "name": "tw", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "zlFVm", + "name": "i", + "width": 15, + "height": 15, + "icon": "folder-git-2", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "cv8KG", + "name": "n", + "fill": "$text-primary", + "content": "infra-platform", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "m3YRt", + "name": "s", + "fill": "$text-muted", + "content": "/", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "h7gMW", + "name": "gi", + "width": 14, + "height": 14, + "icon": "git-compare", + "library": "lucide", + "fill": "$accent" + }, + { + "type": "text", + "id": "p5ib1f", + "name": "l", + "fill": "$text-secondary", + "content": "Review changes", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZLYm1", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "P7P9pW", + "name": "ViewSeg", + "clip": true, + "cornerRadius": 7, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "children": [ + { + "type": "frame", + "id": "fSVw6", + "name": "Unified", + "height": 24, + "fill": "$bg-elevated", + "padding": [ + 0, + 11 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cFi3d", + "name": "t", + "fill": "$text-primary", + "content": "Unified", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "vIq8x", + "name": "Split", + "height": 24, + "fill": "#00000000", + "padding": [ + 0, + 11 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "EAwwk", + "name": "t", + "fill": "$text-muted", + "content": "Split", + "fontFamily": "$font-ui", + "fontSize": 11 + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "QZa32", + "name": "sep", + "fill": "$border-strong", + "width": 1, + "height": 18 + }, + { + "id": "AtXna", + "type": "ref", + "ref": "HyPQi", + "name": "AccountMenu" + } + ] + }, + { + "type": "frame", + "id": "ABggG", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "YkVOj", + "name": "ChangeTree", + "clip": true, + "width": 300, + "height": "fill_container", + "fill": "$bg-sidebar", + "stroke": "$border-subtle", + "strokeWidth": { + "right": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 2, + "padding": 14, + "children": [ + { + "type": "frame", + "id": "p66Dd", + "name": "th", + "width": "fill_container", + "height": 26, + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "WJMWh", + "name": "r", + "innerRadius": 0.5, + "fill": "$st-work", + "width": 9, + "height": 9 + }, + { + "type": "text", + "id": "COQ35", + "name": "a", + "fill": "$text-primary", + "content": "claude", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "pKbY1", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "g24BJh", + "name": "c", + "fill": "$text-muted", + "content": "5 files", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "AY1V1", + "name": "gap", + "width": "fill_container", + "height": 8 + }, + { + "type": "frame", + "id": "XpgV2", + "name": "f network/main.tf", + "width": "fill_container", + "height": 32, + "fill": "$bg-elevated", + "cornerRadius": 6, + "gap": 9, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OoTXU", + "name": "st", + "fill": "$st-wait", + "content": "M", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + }, + { + "type": "text", + "id": "a8ssbS", + "name": "nm", + "fill": "$text-primary", + "content": "network/main.tf", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "s6nnB", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "oKsEq", + "name": "ad", + "fill": "$st-done", + "content": "+42", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "r6pKD", + "name": "dl", + "fill": "$st-error", + "content": "−18", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bIWaS", + "name": "f network/vars.tf", + "width": "fill_container", + "height": 32, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 9, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "l45JM", + "name": "st", + "fill": "$st-wait", + "content": "M", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + }, + { + "type": "text", + "id": "w8H1fx", + "name": "nm", + "fill": "$text-secondary", + "content": "network/vars.tf", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "TWrTv", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "e6sxI", + "name": "ad", + "fill": "$st-done", + "content": "+8", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "SgyPd", + "name": "dl", + "fill": "$st-error", + "content": "−2", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "f0Wr3", + "name": "f modules/vpc.tf", + "width": "fill_container", + "height": 32, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 9, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Y3CVNl", + "name": "st", + "fill": "$st-done", + "content": "A", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + }, + { + "type": "text", + "id": "ZZLK6", + "name": "nm", + "fill": "$text-secondary", + "content": "modules/vpc.tf", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Jy1uM", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "lnlts", + "name": "ad", + "fill": "$st-done", + "content": "+64", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mMDVi", + "name": "f legacy/old.tf", + "width": "fill_container", + "height": 32, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 9, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Gcg6w", + "name": "st", + "fill": "$st-error", + "content": "D", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + }, + { + "type": "text", + "id": "hDUdq", + "name": "nm", + "fill": "$text-secondary", + "content": "legacy/old.tf", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "w0xenx", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "PCunf", + "name": "dl", + "fill": "$st-error", + "content": "−51", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "X5VbD9", + "name": "f README.md", + "width": "fill_container", + "height": 32, + "fill": "#00000000", + "cornerRadius": 6, + "gap": 9, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ZrSzm", + "name": "st", + "fill": "$st-wait", + "content": "M", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + }, + { + "type": "text", + "id": "u1FlLv", + "name": "nm", + "fill": "$text-secondary", + "content": "README.md", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "EC8XA", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "oPjzE", + "name": "ad", + "fill": "$st-done", + "content": "+3", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "kPdlC", + "name": "dl", + "fill": "$st-error", + "content": "−1", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FKyDH", + "name": "tsp", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "cAi7h", + "name": "TreeActions", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "q7kYL", + "name": "Accept", + "width": "fill_container", + "height": 34, + "fill": "$st-done", + "cornerRadius": 7, + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "MeRPX", + "name": "i", + "width": 15, + "height": 15, + "icon": "check", + "library": "lucide", + "fill": "#0A0D12" + }, + { + "type": "text", + "id": "t4tJZ", + "name": "t", + "fill": "#0A0D12", + "content": "Accept all", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "TFUav", + "name": "Reject", + "width": "fill_container", + "height": 32, + "fill": "#00000000", + "cornerRadius": 7, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rbDvq", + "name": "t", + "fill": "$text-secondary", + "content": "Reject changes", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "QrHUD", + "name": "DiffView", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$bg-panel", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "LDf4i", + "name": "FileHead", + "width": "fill_container", + "height": 38, + "fill": "$bg-elevated", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 10, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "cIWE0", + "name": "fi", + "width": 14, + "height": 14, + "icon": "file-code", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "gqEB8", + "name": "fp", + "fill": "$text-primary", + "content": "modules/network/main.tf", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "M5bsI", + "name": "fs", + "fill": "$text-muted", + "content": "+42 −18", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "H36bk", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "yQobH", + "name": "nav", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "N3bDV", + "name": "chevron-up", + "width": 15, + "height": 15, + "icon": "chevron-up", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "icon", + "id": "XOT66", + "name": "chevron-down", + "width": 15, + "height": 15, + "icon": "chevron-down", + "library": "lucide", + "fill": "$text-secondary" + } + ] + }, + { + "type": "text", + "id": "Msc2e", + "name": "hint", + "fill": "$text-muted", + "content": "hunk 1/3", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "b7wRGq", + "name": "Code", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 6, + 0 + ], + "children": [ + { + "type": "frame", + "id": "oMhEO", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "$bg-elevated", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "I8otl", + "name": "pad", + "width": 76, + "height": 1 + }, + { + "type": "text", + "id": "Yye8q", + "name": "t", + "fill": "$accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "@@ -12,7 +12,9 @@ module \"network\"", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "XiE9h", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#00000000", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "c2frT", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Nuc6K", + "name": "t", + "fill": "$text-muted", + "content": "12", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Ma124", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Vqvqj", + "name": "t", + "fill": "$text-muted", + "content": "12", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wAHOz", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "Rf2xj", + "name": "s", + "fill": "$text-muted", + "content": " ", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "fSaGT", + "name": "t", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "resource \"aws_vpc\" \"main\" {", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qFDwi", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#00000000", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gbZ78", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jNeND", + "name": "t", + "fill": "$text-muted", + "content": "13", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e9X8Wn", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qjxZT", + "name": "t", + "fill": "$text-muted", + "content": "13", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eVWyA", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "GLL96", + "name": "s", + "fill": "$text-muted", + "content": " ", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "mOxZw", + "name": "t", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " cidr_block = var.cidr", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sXqbY", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#F4544E18", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "WBVnR", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cULZl", + "name": "t", + "fill": "$text-muted", + "content": "14", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DM2GR", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iB9lt", + "name": "t", + "fill": "$text-muted", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "beRIJ", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "hxPKP", + "name": "s", + "fill": "$st-error", + "content": "-", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "kqHRJ", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " enable_dns = true", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "F5KMe", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#3FB95018", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "o1ZqR", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "E35zaK", + "name": "t", + "fill": "$text-muted", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "i93bjC", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WnlKK", + "name": "t", + "fill": "$text-muted", + "content": "14", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IXreF", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "BWyEh", + "name": "s", + "fill": "$st-done", + "content": "+", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "duB1a", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " enable_dns_support = true", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FqJ8A", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#3FB95018", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "B9uHb", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "BoUfs", + "name": "t", + "fill": "$text-muted", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "P1tCPh", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Zco7n", + "name": "t", + "fill": "$text-muted", + "content": "15", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HzOwS", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "aOhfI", + "name": "s", + "fill": "$st-done", + "content": "+", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "NZzaR", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " enable_dns_hostnames = true", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "YrBdg", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#00000000", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "H5uRmF", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sjk5b", + "name": "t", + "fill": "$text-muted", + "content": "15", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oSJUZ", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AQoZv", + "name": "t", + "fill": "$text-muted", + "content": "16", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DyuQf", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ETYL7", + "name": "s", + "fill": "$text-muted", + "content": " ", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "IjeQe", + "name": "t", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " tags = local.common_tags", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IAMNB", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#00000000", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DDqYq", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bSzFr", + "name": "t", + "fill": "$text-muted", + "content": "16", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "YAsnK", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wnSIU", + "name": "t", + "fill": "$text-muted", + "content": "17", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "UoNBB", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "D6Lhq", + "name": "s", + "fill": "$text-muted", + "content": " ", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "TOMGd", + "name": "t", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "}", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "SwjUZ", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "$bg-elevated", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "bl8N4", + "name": "pad", + "width": 76, + "height": 1 + }, + { + "type": "text", + "id": "B7u27u", + "name": "t", + "fill": "$accent", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "@@ -28,4 +30,8 @@ locals", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "c89TEn", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#00000000", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "t4Vp3", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "gc3q9", + "name": "t", + "fill": "$text-muted", + "content": "28", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "n8jNr", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "O8L73", + "name": "t", + "fill": "$text-muted", + "content": "30", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jAexF", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "DDBDV", + "name": "s", + "fill": "$text-muted", + "content": " ", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "UXIxN", + "name": "t", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "locals {", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "o7QvS", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#F4544E18", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "coaZ4", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "T68aI4", + "name": "t", + "fill": "$text-muted", + "content": "29", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cHdTq", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "J83D5", + "name": "t", + "fill": "$text-muted", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Pm9SQ", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "wz54v", + "name": "s", + "fill": "$st-error", + "content": "-", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "lTnda", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " common_tags = { env = \"dev\" }", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "nN8cM", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#3FB95018", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rJ5MX", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zcDwk", + "name": "t", + "fill": "$text-muted", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "C4he6j", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IaBxU", + "name": "t", + "fill": "$text-muted", + "content": "31", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "VcYid", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "f3Tmx", + "name": "s", + "fill": "$st-done", + "content": "+", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "X1Oih", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " common_tags = {", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Axcw2", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#3FB95018", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "yirdz", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Vdd9W", + "name": "t", + "fill": "$text-muted", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PIYGU", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mOW98", + "name": "t", + "fill": "$text-muted", + "content": "32", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ibnJP", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "A6lsy", + "name": "s", + "fill": "$st-done", + "content": "+", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "NSc97", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " env = var.environment", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sudEo", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#3FB95018", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hYxDJ", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "r8mEu", + "name": "t", + "fill": "$text-muted", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "I5qAR", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "o6IMgC", + "name": "t", + "fill": "$text-muted", + "content": "33", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e0jbA", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "e3LlA", + "name": "s", + "fill": "$st-done", + "content": "+", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "S1p4Iq", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " managed_by = \"spacesh\"", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "q5PnV", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#3FB95018", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "OzVwz", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "l7Ofs", + "name": "t", + "fill": "$text-muted", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xMNBC", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "gxzQL", + "name": "t", + "fill": "$text-muted", + "content": "34", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "V5elV", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "iIAC8", + "name": "s", + "fill": "$st-done", + "content": "+", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "vkwcl", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": " }", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "w2LSn", + "name": "ln", + "width": "fill_container", + "height": 20, + "fill": "#00000000", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "D3ELp8", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "n8Kz6R", + "name": "t", + "fill": "$text-muted", + "content": "30", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JV9OU", + "name": "g", + "width": 38, + "padding": [ + 0, + 8, + 0, + 0 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "N6Zhv", + "name": "t", + "fill": "$text-muted", + "content": "35", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "d7JXQ", + "name": "sg", + "width": 16, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "G3ZB0", + "name": "s", + "fill": "$text-muted", + "content": " ", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "sXy9r", + "name": "t", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "}", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "G8n4SJ", + "x": 3040, + "y": 0, + "name": "spacesh — New Workspace Wizard", + "clip": true, + "width": 1440, + "height": 900, + "fill": "#070A0E", + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "J1PzQ", + "name": "Modal", + "clip": true, + "width": 720, + "fill": "$bg-app", + "cornerRadius": 14, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#000000AA", + "offset": { + "x": 0, + "y": 24 + }, + "blur": 60 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "OYK51", + "name": "Head", + "width": "fill_container", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 10, + "padding": [ + 18, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "A9DAgr", + "name": "i", + "width": 18, + "height": 18, + "icon": "folder-plus", + "library": "lucide", + "fill": "$accent" + }, + { + "type": "frame", + "id": "fLT4D", + "name": "ht", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "BqWfy", + "name": "t", + "fill": "$text-primary", + "content": "New workspace", + "fontFamily": "$font-ui", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "nXeLQ", + "name": "s", + "fill": "$text-secondary", + "content": "Pick a folder, a layout, and assign an agent to each panel", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LeRgu", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "adpdM", + "name": "x", + "width": 18, + "height": 18, + "icon": "x", + "library": "lucide", + "fill": "$text-muted" + } + ] + }, + { + "type": "frame", + "id": "RzycV", + "name": "WBody", + "width": "fill_container", + "layout": "vertical", + "gap": 22, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "Uv3qC", + "name": "sec Project folder", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "fxCpV", + "name": "sh", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "z2cesu", + "name": "num", + "width": 18, + "height": 18, + "fill": "$bg-elevated", + "cornerRadius": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "T14af", + "name": "d", + "fill": "$accent", + "content": "1", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "M16zTf", + "name": "t", + "fill": "$text-secondary", + "content": "PROJECT FOLDER", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "700", + "letterSpacing": 0.3 + } + ] + }, + { + "type": "frame", + "id": "vaOOa", + "name": "Folder", + "width": "fill_container", + "height": 42, + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 10, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "EO6g0", + "name": "fi", + "width": 16, + "height": 16, + "icon": "folder", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "rumY5", + "name": "p", + "fill": "$text-primary", + "content": "~/Developers/infra-platform", + "fontFamily": "$font-mono", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "avbMo", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "XTJ8y", + "name": "Browse", + "height": 28, + "fill": "$bg-elevated", + "cornerRadius": 6, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vU9Cy", + "name": "t", + "fill": "$text-primary", + "content": "Browse…", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Ec7lc", + "name": "sec Layout preset", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "Zd4QT", + "name": "sh", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "oRzGP", + "name": "num", + "width": 18, + "height": 18, + "fill": "$bg-elevated", + "cornerRadius": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wdCsR", + "name": "d", + "fill": "$accent", + "content": "2", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "ww2B4", + "name": "t", + "fill": "$text-secondary", + "content": "LAYOUT PRESET", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "700", + "letterSpacing": 0.3 + } + ] + }, + { + "type": "frame", + "id": "JzYQP", + "name": "Presets", + "width": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "NCw9d", + "name": "pc 1", + "width": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 10, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ybKTJ", + "name": "thumb", + "width": "fill_container", + "height": 54, + "fill": "$bg-app", + "cornerRadius": 5, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": 4, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "hH6B4", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + } + ] + }, + { + "type": "text", + "id": "b694KP", + "name": "l", + "fill": "$text-secondary", + "content": "1", + "fontFamily": "$font-mono", + "fontSize": 12 + } + ] + }, + { + "type": "frame", + "id": "w5cr0U", + "name": "pc 2↔", + "width": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 10, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DbKTt", + "name": "thumb", + "width": "fill_container", + "height": 54, + "fill": "$bg-app", + "cornerRadius": 5, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": 4, + "children": [ + { + "type": "frame", + "id": "fQy3o", + "name": "r", + "width": "fill_container", + "height": "fill_container", + "gap": 3, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "h81rTk", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "s7fu6y", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + } + ] + } + ] + }, + { + "type": "text", + "id": "wmOLL", + "name": "l", + "fill": "$text-secondary", + "content": "2↔", + "fontFamily": "$font-mono", + "fontSize": 12 + } + ] + }, + { + "type": "frame", + "id": "pGt1k", + "name": "pc 2↕", + "width": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 10, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vHOnG", + "name": "thumb", + "width": "fill_container", + "height": 54, + "fill": "$bg-app", + "cornerRadius": 5, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": 4, + "children": [ + { + "type": "frame", + "id": "b5OHCs", + "name": "r", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "LsFg0", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "woxm4", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + } + ] + } + ] + }, + { + "type": "text", + "id": "VmM0z", + "name": "l", + "fill": "$text-secondary", + "content": "2↕", + "fontFamily": "$font-mono", + "fontSize": 12 + } + ] + }, + { + "type": "frame", + "id": "QESwW", + "name": "pc 2×2", + "width": "fill_container", + "fill": "$bg-elevated", + "cornerRadius": 10, + "stroke": "$accent", + "strokeWidth": 2, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "JeZKE", + "name": "thumb", + "width": "fill_container", + "height": 54, + "fill": "$bg-app", + "cornerRadius": 5, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": 4, + "children": [ + { + "type": "frame", + "id": "DThF7", + "name": "col", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "Uh3oW", + "name": "rw", + "width": "fill_container", + "height": "fill_container", + "gap": 3, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "SKOUD", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "W44pk2", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "U8VL7", + "name": "rw", + "width": "fill_container", + "height": "fill_container", + "gap": 3, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "NWH16", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "m6STl", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "ttcJJ", + "name": "l", + "fill": "$text-primary", + "content": "2×2", + "fontFamily": "$font-mono", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "eb9hy", + "name": "pc 2+1", + "width": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 10, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "f9c3A", + "name": "thumb", + "width": "fill_container", + "height": 54, + "fill": "$bg-app", + "cornerRadius": 5, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": 4, + "children": [ + { + "type": "frame", + "id": "aoxhj", + "name": "r", + "width": "fill_container", + "height": "fill_container", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "sWQWo", + "name": "lc", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "LIWCb", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "d1AK3", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + } + ] + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "Lj6e7", + "name": "c", + "fill": "#3A4452", + "width": "fill_container", + "height": "fill_container" + } + ] + } + ] + }, + { + "type": "text", + "id": "apUiR", + "name": "l", + "fill": "$text-secondary", + "content": "2+1", + "fontFamily": "$font-mono", + "fontSize": 12 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "R8Life", + "name": "sec Assign agents", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "CewVb", + "name": "sh", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "c887N", + "name": "num", + "width": 18, + "height": 18, + "fill": "$bg-elevated", + "cornerRadius": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "k0129c", + "name": "d", + "fill": "$accent", + "content": "3", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "olh9n", + "name": "t", + "fill": "$text-secondary", + "content": "ASSIGN AGENTS", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "700", + "letterSpacing": 0.3 + } + ] + }, + { + "type": "frame", + "id": "UZHGq", + "name": "AgentGrid", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "pUlEl", + "name": "ar1", + "width": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "rtjGV", + "name": "ac 1", + "width": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "hoo45", + "name": "t", + "width": "fill_container", + "gap": 7, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dt8cw", + "name": "pn", + "fill": "$text-muted", + "content": "PANEL 1", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "y6ShL", + "name": "dd", + "width": "fill_container", + "height": 34, + "fill": "$bg-elevated", + "cornerRadius": 7, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 0, + 11 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "FDUAP", + "name": "ai", + "width": 14, + "height": 14, + "icon": "sparkles", + "library": "lucide", + "fill": "$accent" + }, + { + "type": "text", + "id": "W7bQq", + "name": "an", + "fill": "$text-primary", + "content": "claude", + "fontFamily": "$font-mono", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "OZaUo", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "WpJ8I", + "name": "ch", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$text-muted" + } + ] + } + ] + }, + { + "type": "frame", + "id": "QnERO", + "name": "ac 2", + "width": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "g0bdf", + "name": "t", + "width": "fill_container", + "gap": 7, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "maz1Q", + "name": "pn", + "fill": "$text-muted", + "content": "PANEL 2", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "lUkLe", + "name": "dd", + "width": "fill_container", + "height": 34, + "fill": "$bg-elevated", + "cornerRadius": 7, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 0, + 11 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "N2upC", + "name": "ai", + "width": 14, + "height": 14, + "icon": "bot", + "library": "lucide", + "fill": "$accent" + }, + { + "type": "text", + "id": "BTprV", + "name": "an", + "fill": "$text-primary", + "content": "codex", + "fontFamily": "$font-mono", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "aAbB1", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "z7gtH", + "name": "ch", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$text-muted" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "vkOQ7", + "name": "ar2", + "width": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "wQoPt", + "name": "ac 3", + "width": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "Zqy02", + "name": "t", + "width": "fill_container", + "gap": 7, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sn0ef", + "name": "pn", + "fill": "$text-muted", + "content": "PANEL 3", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "RKTtg", + "name": "dd", + "width": "fill_container", + "height": 34, + "fill": "$bg-elevated", + "cornerRadius": 7, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 0, + 11 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "jUPPo", + "name": "ai", + "width": 14, + "height": 14, + "icon": "gem", + "library": "lucide", + "fill": "$accent" + }, + { + "type": "text", + "id": "njJYF", + "name": "an", + "fill": "$text-primary", + "content": "gemini", + "fontFamily": "$font-mono", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "K9CFfy", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "Fhpuq", + "name": "ch", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$text-muted" + } + ] + } + ] + }, + { + "type": "frame", + "id": "shgZ3", + "name": "ac 4", + "width": "fill_container", + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "d6dQa", + "name": "t", + "width": "fill_container", + "gap": 7, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "eBCB7", + "name": "pn", + "fill": "$text-muted", + "content": "PANEL 4", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "Bnjv3", + "name": "dd", + "width": "fill_container", + "height": 34, + "fill": "$bg-elevated", + "cornerRadius": 7, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 8, + "padding": [ + 0, + 11 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "TnqXG", + "name": "ai", + "width": 14, + "height": 14, + "icon": "terminal", + "library": "lucide", + "fill": "$accent" + }, + { + "type": "text", + "id": "i9Ajs", + "name": "an", + "fill": "$text-primary", + "content": "shell", + "fontFamily": "$font-mono", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "fEGFs", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "G7VNt", + "name": "ch", + "width": 14, + "height": 14, + "icon": "chevron-down", + "library": "lucide", + "fill": "$text-muted" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "J0d0oh", + "name": "Footer", + "width": "fill_container", + "fill": "$bg-sidebar", + "stroke": "$border-subtle", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "gap": 12, + "padding": [ + 16, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "J6atd", + "name": "hint", + "fill": "$text-muted", + "content": "⌘⏎ to create", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ukfmW", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "p3E0H", + "name": "Cancel", + "height": 36, + "fill": "#00000000", + "cornerRadius": 8, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": [ + 0, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "e8g7q", + "name": "t", + "fill": "$text-secondary", + "content": "Cancel", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "A0h9iv", + "name": "Create", + "height": 36, + "fill": "$accent", + "cornerRadius": 8, + "gap": 8, + "padding": [ + 0, + 18 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "HtQQR", + "name": "i", + "width": 15, + "height": 15, + "icon": "arrow-right", + "library": "lucide", + "fill": "#0A0D12" + }, + { + "type": "text", + "id": "E1VOf", + "name": "t", + "fill": "#0A0D12", + "content": "Create workspace", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "HyPQi", + "x": 0, + "y": -100, + "name": "component/AccountMenu", + "reusable": true, + "gap": 7, + "padding": [ + 0, + 4, + 0, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kqPgD", + "name": "Avatar", + "width": 26, + "height": 26, + "fill": "$accent", + "cornerRadius": 13, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "outer", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Bs5GV", + "name": "Initials", + "fill": "#0A0D12", + "content": "VK", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "700" + } + ] + }, + { + "type": "icon", + "id": "H2CuVX", + "name": "Chevron", + "width": 13, + "height": 13, + "icon": "chevron-down", + "library": "lucide", + "fill": "$text-muted" + } + ] + }, + { + "type": "frame", + "id": "etDzB", + "x": 0, + "y": 980, + "name": "spacesh — Account Dropdown", + "clip": true, + "width": 260, + "fill": "$bg-elevated", + "cornerRadius": 12, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#000000AA", + "offset": { + "x": 0, + "y": 16 + }, + "blur": 40 + }, + "layout": "vertical", + "gap": 4, + "padding": 6, + "children": [ + { + "type": "frame", + "id": "fpuAk", + "name": "Profile", + "width": "fill_container", + "gap": 10, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "KN7YM", + "name": "av", + "width": 38, + "height": 38, + "fill": "$accent", + "cornerRadius": 19, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "e2DaK", + "name": "in", + "fill": "#0A0D12", + "content": "VK", + "fontFamily": "$font-ui", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "uGp8f", + "name": "col", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "RLA1g", + "name": "nm", + "fill": "$text-primary", + "content": "Vasya Krg", + "fontFamily": "$font-ui", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "eyJMK", + "name": "em", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "vasyakrg@gmail.com", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "KnyPE", + "name": "div", + "fill": "$border-subtle", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "eHGo2", + "name": "mi Profile", + "width": "fill_container", + "height": 36, + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "WILxB", + "name": "i", + "width": 16, + "height": 16, + "icon": "user", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "u8ht79", + "name": "l", + "fill": "$text-primary", + "content": "Profile", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "YQHFo", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "hwyV6", + "name": "ch", + "width": 14, + "height": 14, + "icon": "chevron-right", + "library": "lucide", + "fill": "$text-muted" + } + ] + }, + { + "type": "frame", + "id": "m8DNs", + "name": "mi Settings", + "width": "fill_container", + "height": 36, + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "s7deO", + "name": "i", + "width": 16, + "height": 16, + "icon": "settings", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "zrcba", + "name": "l", + "fill": "$text-primary", + "content": "Settings", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "OlESu", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "al386", + "name": "k", + "fill": "$text-muted", + "content": "⌘,", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gouAk", + "name": "mi Billing & Plan", + "width": "fill_container", + "height": 36, + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "uI7hR", + "name": "i", + "width": 16, + "height": 16, + "icon": "credit-card", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "j7XDsL", + "name": "l", + "fill": "$text-primary", + "content": "Billing & Plan", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ljUej", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "QSXF0", + "name": "plan", + "height": 18, + "fill": "#000000", + "cornerRadius": 9, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vILbC", + "name": "t", + "fill": "$st-done", + "content": "PRO", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "r0X78f", + "name": "mi Notification channels", + "width": "fill_container", + "height": 36, + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "DJ3ON", + "name": "i", + "width": 16, + "height": 16, + "icon": "bell", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "ymAhN", + "name": "l", + "fill": "$text-primary", + "content": "Notification channels", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nluA2", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "KwMgF", + "name": "ch", + "width": 14, + "height": 14, + "icon": "chevron-right", + "library": "lucide", + "fill": "$text-muted" + } + ] + }, + { + "type": "rectangle", + "id": "IjLVY", + "name": "div2", + "fill": "$border-subtle", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "oBNTx", + "name": "mi Sign out", + "width": "fill_container", + "height": 36, + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "GCo10", + "name": "i", + "width": 16, + "height": 16, + "icon": "log-out", + "library": "lucide", + "fill": "$st-error" + }, + { + "type": "text", + "id": "vFtXN", + "name": "l", + "fill": "$st-error", + "content": "Sign out", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "jVtZB", + "name": "sp", + "width": "fill_container", + "height": 1 + } + ] + } + ] + }, + { + "type": "frame", + "id": "hij6M", + "x": 4560, + "y": 0, + "name": "spacesh — Sign In", + "clip": true, + "width": 1440, + "height": 900, + "fill": "#070A0E", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "df9H8", + "name": "AuthCard", + "width": 420, + "fill": "$bg-app", + "cornerRadius": 16, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#000000AA", + "offset": { + "x": 0, + "y": 24 + }, + "blur": 60 + }, + "layout": "vertical", + "gap": 22, + "padding": 36, + "children": [ + { + "type": "frame", + "id": "jXd9D", + "name": "Brand", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "H9NOC", + "name": "bi", + "width": 34, + "height": 34, + "fill": "$accent", + "cornerRadius": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Jj90x", + "name": "i", + "width": 20, + "height": 20, + "icon": "square-terminal", + "library": "lucide", + "fill": "#0A0D12" + } + ] + }, + { + "type": "text", + "id": "h45AV", + "name": "w", + "fill": "$text-primary", + "content": "spacesh", + "fontFamily": "$font-mono", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "U9zQeG", + "name": "Titles", + "width": "fill_container", + "layout": "vertical", + "gap": 5, + "children": [ + { + "type": "text", + "id": "SuZdS", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Sign in to spacesh", + "fontFamily": "$font-ui", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "text", + "id": "vi5an", + "name": "s", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Sync workspaces and notifications across devices", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "twA3E", + "name": "Form", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "PtEDX", + "name": "fld Email", + "width": "fill_container", + "layout": "vertical", + "gap": 7, + "children": [ + { + "type": "frame", + "id": "QNsEa", + "name": "lr", + "width": "fill_container", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wjtf2", + "name": "l", + "fill": "$text-secondary", + "content": "Email", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "a6MHw", + "name": "inp", + "width": "fill_container", + "height": 44, + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 10, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Sydsu", + "name": "i", + "width": 16, + "height": 16, + "icon": "mail", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "Q4sO2a", + "name": "v", + "fill": "$text-primary", + "content": "vasyakrg@gmail.com", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "LcDEO", + "name": "sp2", + "width": "fill_container", + "height": 1 + } + ] + } + ] + }, + { + "type": "frame", + "id": "CRVeI", + "name": "fld Password", + "width": "fill_container", + "layout": "vertical", + "gap": 7, + "children": [ + { + "type": "frame", + "id": "ioFoa", + "name": "lr", + "width": "fill_container", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "tYRHS", + "name": "l", + "fill": "$text-secondary", + "content": "Password", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Tg96Z", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "mLkBJ", + "name": "fp", + "fill": "$accent", + "content": "Forgot?", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ApCMT", + "name": "inp", + "width": "fill_container", + "height": 44, + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 10, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "P88tje", + "name": "i", + "width": 16, + "height": 16, + "icon": "lock", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "ur0BO", + "name": "v", + "fill": "$text-primary", + "content": "••••••••••", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "x3kVIs", + "name": "sp2", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "t8R3w", + "name": "eye", + "width": 16, + "height": 16, + "icon": "eye-off", + "library": "lucide", + "fill": "$text-muted" + } + ] + } + ] + }, + { + "type": "frame", + "id": "byvRP", + "name": "Submit", + "width": "fill_container", + "height": 46, + "fill": "$accent", + "cornerRadius": 9, + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XXMRI", + "name": "t", + "fill": "#0A0D12", + "content": "Sign in", + "fontFamily": "$font-ui", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "V12Uj", + "name": "Or", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "id": "E6Wee", + "name": "l", + "fill": "$border-subtle", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "DnKTO", + "name": "o", + "fill": "$text-muted", + "content": "OR", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "rectangle", + "id": "ObF1C", + "name": "r", + "fill": "$border-subtle", + "width": "fill_container", + "height": 1 + } + ] + }, + { + "type": "frame", + "id": "z47v9W", + "name": "OAuth", + "width": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "AJR8c", + "name": "GitHub", + "width": "fill_container", + "height": 42, + "fill": "$bg-elevated", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "U88aTn", + "name": "i", + "width": 16, + "height": 16, + "icon": "github", + "library": "lucide", + "fill": "$text-primary" + }, + { + "type": "text", + "id": "YHf4Q", + "name": "t", + "fill": "$text-primary", + "content": "GitHub", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "aS8hU", + "name": "Google", + "width": "fill_container", + "height": 42, + "fill": "$bg-elevated", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Egjxq", + "name": "i", + "width": 16, + "height": 16, + "icon": "globe", + "library": "lucide", + "fill": "$text-primary" + }, + { + "type": "text", + "id": "ioMXL", + "name": "t", + "fill": "$text-primary", + "content": "Google", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "PQ1Gg", + "name": "Foot", + "width": "fill_container", + "gap": 5, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "m3wl6U", + "name": "a", + "fill": "$text-secondary", + "content": "No account?", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "o1ifUf", + "name": "b", + "fill": "$accent", + "content": "Create one", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "yZwzo", + "x": 6080, + "y": 0, + "name": "spacesh — Sign Up", + "clip": true, + "width": 1440, + "height": 900, + "fill": "#070A0E", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "KtFkq", + "name": "AuthCard", + "width": 420, + "fill": "$bg-app", + "cornerRadius": 16, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#000000AA", + "offset": { + "x": 0, + "y": 24 + }, + "blur": 60 + }, + "layout": "vertical", + "gap": 22, + "padding": 36, + "children": [ + { + "type": "frame", + "id": "QD3Dx", + "name": "Brand", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cMhDD", + "name": "bi", + "width": 34, + "height": 34, + "fill": "$accent", + "cornerRadius": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "I9JZX", + "name": "i", + "width": 20, + "height": 20, + "icon": "square-terminal", + "library": "lucide", + "fill": "#0A0D12" + } + ] + }, + { + "type": "text", + "id": "yjLvL", + "name": "w", + "fill": "$text-primary", + "content": "spacesh", + "fontFamily": "$font-mono", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "gqs3d", + "name": "Titles", + "width": "fill_container", + "layout": "vertical", + "gap": 5, + "children": [ + { + "type": "text", + "id": "sciE6", + "name": "t", + "fill": "$text-primary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Create your account", + "fontFamily": "$font-ui", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "text", + "id": "r0iRv4", + "name": "s", + "fill": "$text-secondary", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start syncing workspaces and external notifications", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Ts8kj", + "name": "Form", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "kLDNF", + "name": "fld Name", + "width": "fill_container", + "layout": "vertical", + "gap": 7, + "children": [ + { + "type": "text", + "id": "MFh1c", + "name": "l", + "fill": "$text-secondary", + "content": "Name", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "B3neN", + "name": "inp", + "width": "fill_container", + "height": 44, + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 10, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "qpcky", + "name": "i", + "width": 16, + "height": 16, + "icon": "user", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "yI446", + "name": "v", + "fill": "$text-primary", + "content": "Vasya Krg", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Fhujo", + "name": "sp2", + "width": "fill_container", + "height": 1 + } + ] + } + ] + }, + { + "type": "frame", + "id": "MKPYw", + "name": "fld Email", + "width": "fill_container", + "layout": "vertical", + "gap": 7, + "children": [ + { + "type": "text", + "id": "GwVf9", + "name": "l", + "fill": "$text-secondary", + "content": "Email", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "WyXBc", + "name": "inp", + "width": "fill_container", + "height": 44, + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 10, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "FcVaD", + "name": "i", + "width": 16, + "height": 16, + "icon": "mail", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "wiCGO", + "name": "v", + "fill": "$text-muted", + "content": "you@example.com", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "z680O5", + "name": "sp2", + "width": "fill_container", + "height": 1 + } + ] + } + ] + }, + { + "type": "frame", + "id": "kNCfE", + "name": "fld Password", + "width": "fill_container", + "layout": "vertical", + "gap": 7, + "children": [ + { + "type": "text", + "id": "Pf3ve", + "name": "l", + "fill": "$text-secondary", + "content": "Password", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "zzjy3", + "name": "inp", + "width": "fill_container", + "height": 44, + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 10, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "ipPsM", + "name": "i", + "width": 16, + "height": 16, + "icon": "lock", + "library": "lucide", + "fill": "$text-muted" + }, + { + "type": "text", + "id": "nawfv", + "name": "v", + "fill": "$text-muted", + "content": "At least 8 characters", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "g2SOQ", + "name": "sp2", + "width": "fill_container", + "height": 1 + }, + { + "type": "icon", + "id": "E3M8eY", + "name": "eye", + "width": 16, + "height": 16, + "icon": "eye-off", + "library": "lucide", + "fill": "$text-muted" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zom3P", + "name": "Submit", + "width": "fill_container", + "height": 46, + "fill": "$accent", + "cornerRadius": 9, + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K3hRX9", + "name": "t", + "fill": "#0A0D12", + "content": "Create account", + "fontFamily": "$font-ui", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "h6gRx", + "name": "tos", + "fill": "$text-muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "By creating an account you agree to the Terms and Privacy Policy", + "lineHeight": 1.4, + "textAlign": "center", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OjHBX", + "name": "Or", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "id": "MyN2j", + "name": "l", + "fill": "$border-subtle", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "qXhnC", + "name": "o", + "fill": "$text-muted", + "content": "OR", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "rectangle", + "id": "F1ohEM", + "name": "r", + "fill": "$border-subtle", + "width": "fill_container", + "height": 1 + } + ] + }, + { + "type": "frame", + "id": "dfnlV", + "name": "OAuth", + "width": "fill_container", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "OPm89", + "name": "GitHub", + "width": "fill_container", + "height": 42, + "fill": "$bg-elevated", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "eHP2H", + "name": "i", + "width": 16, + "height": 16, + "icon": "github", + "library": "lucide", + "fill": "$text-primary" + }, + { + "type": "text", + "id": "Keju1", + "name": "t", + "fill": "$text-primary", + "content": "GitHub", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JIx3C", + "name": "Google", + "width": "fill_container", + "height": 42, + "fill": "$bg-elevated", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "U62ggJ", + "name": "i", + "width": 16, + "height": 16, + "icon": "globe", + "library": "lucide", + "fill": "$text-primary" + }, + { + "type": "text", + "id": "E0uFbb", + "name": "t", + "fill": "$text-primary", + "content": "Google", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Gbrrx", + "name": "Foot", + "width": "fill_container", + "gap": 5, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "gV3lN", + "name": "a", + "fill": "$text-secondary", + "content": "Already have an account?", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "MYjiI", + "name": "b", + "fill": "$accent", + "content": "Sign in", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "uIvRx", + "x": 7600, + "y": 0, + "name": "spacesh — Account Settings", + "clip": true, + "width": 1440, + "height": 900, + "fill": "$bg-app", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "d1hkIj", + "name": "TitleBar", + "width": "fill_container", + "height": 40, + "fill": "$bg-sidebar", + "stroke": "$border-subtle", + "strokeWidth": { + "bottom": 1 + }, + "strokeAlignment": "inner", + "gap": 14, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "iIpEW", + "name": "lights", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "B8wt6k", + "name": "d", + "fill": "#F4544E", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "fMNTv", + "name": "d", + "fill": "#F2B84B", + "width": 12, + "height": 12 + }, + { + "type": "ellipse", + "id": "TQfOB", + "name": "d", + "fill": "#3FB950", + "width": 12, + "height": 12 + } + ] + }, + { + "type": "icon", + "id": "xv4C1", + "name": "bk", + "width": 16, + "height": 16, + "icon": "arrow-left", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "tvDOY", + "name": "t", + "fill": "$text-primary", + "content": "Account", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "m8sVG", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "id": "f5N87", + "type": "ref", + "ref": "HyPQi", + "name": "AccountMenu" + } + ] + }, + { + "type": "frame", + "id": "ja3YK", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "GlIRK", + "name": "SettingsNav", + "width": 228, + "height": "fill_container", + "fill": "$bg-sidebar", + "stroke": "$border-subtle", + "strokeWidth": { + "right": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 3, + "padding": 14, + "children": [ + { + "type": "text", + "id": "rxIrJ", + "name": "hd", + "fill": "$text-muted", + "content": "SETTINGS", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "700", + "letterSpacing": 0.5 + }, + { + "type": "frame", + "id": "ck3cb", + "name": "g", + "width": "fill_container", + "height": 6 + }, + { + "type": "frame", + "id": "RxHup", + "name": "nv Profile", + "width": "fill_container", + "height": 36, + "fill": "$bg-elevated", + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "W3qjqe", + "name": "i", + "width": 16, + "height": 16, + "icon": "user", + "library": "lucide", + "fill": "$accent" + }, + { + "type": "text", + "id": "V9l0L2", + "name": "l", + "fill": "$text-primary", + "content": "Profile", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "wuRsb", + "name": "nv Plan & Billing", + "width": "fill_container", + "height": 36, + "fill": "#00000000", + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "yaanA", + "name": "i", + "width": 16, + "height": 16, + "icon": "credit-card", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "x13cC", + "name": "l", + "fill": "$text-secondary", + "content": "Plan & Billing", + "fontFamily": "$font-ui", + "fontSize": 13 + } + ] + }, + { + "type": "frame", + "id": "PTEz9", + "name": "nv Notifications", + "width": "fill_container", + "height": 36, + "fill": "#00000000", + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "PBBmP", + "name": "i", + "width": 16, + "height": 16, + "icon": "bell", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "jHuMU", + "name": "l", + "fill": "$text-secondary", + "content": "Notifications", + "fontFamily": "$font-ui", + "fontSize": 13 + } + ] + }, + { + "type": "frame", + "id": "oLwZN", + "name": "nv Security", + "width": "fill_container", + "height": 36, + "fill": "#00000000", + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "TYICP", + "name": "i", + "width": 16, + "height": 16, + "icon": "shield", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "RW9ao", + "name": "l", + "fill": "$text-secondary", + "content": "Security", + "fontFamily": "$font-ui", + "fontSize": 13 + } + ] + }, + { + "type": "frame", + "id": "jC3r0", + "name": "nv Appearance", + "width": "fill_container", + "height": 36, + "fill": "#00000000", + "cornerRadius": 7, + "gap": 11, + "padding": [ + 0, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "VPGJv", + "name": "i", + "width": 16, + "height": 16, + "icon": "palette", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "o5CQ9", + "name": "l", + "fill": "$text-secondary", + "content": "Appearance", + "fontFamily": "$font-ui", + "fontSize": 13 + } + ] + } + ] + }, + { + "type": "frame", + "id": "PVawO", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": [ + 28, + 32 + ], + "children": [ + { + "type": "frame", + "id": "VTgXx", + "name": "ProfileSec", + "width": "fill_container", + "layout": "vertical", + "gap": 14, + "children": [ + { + "type": "text", + "id": "tyCK3", + "name": "h", + "fill": "$text-primary", + "content": "Profile", + "fontFamily": "$font-ui", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "wQTuP", + "name": "prow", + "width": "fill_container", + "gap": 18, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "p8SSFv", + "name": "av", + "width": 64, + "height": 64, + "fill": "$accent", + "cornerRadius": 32, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "PbkQC", + "name": "in", + "fill": "#0A0D12", + "content": "VK", + "fontFamily": "$font-ui", + "fontSize": 24, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "T8WqTM", + "name": "pcol", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "FCqYi", + "name": "change", + "height": 32, + "fill": "$bg-elevated", + "cornerRadius": 7, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 7, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "DXZnd", + "name": "i", + "width": 14, + "height": 14, + "icon": "upload", + "library": "lucide", + "fill": "$text-secondary" + }, + { + "type": "text", + "id": "MkA7c", + "name": "t", + "fill": "$text-primary", + "content": "Change photo", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "gNGV6", + "name": "hint", + "fill": "$text-muted", + "content": "PNG or JPG, max 2MB", + "fontFamily": "$font-ui", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "EyVl5", + "name": "frow", + "width": "fill_container", + "gap": 14, + "children": [ + { + "type": "frame", + "id": "dNiOS", + "name": "fld Name", + "width": "fill_container", + "layout": "vertical", + "gap": 7, + "children": [ + { + "type": "text", + "id": "s3PYm", + "name": "l", + "fill": "$text-secondary", + "content": "Name", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Pf9Iw", + "name": "inp", + "width": "fill_container", + "height": 42, + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qC8yq", + "name": "v", + "fill": "$text-primary", + "content": "Vasya Krg", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "fVs7N", + "name": "fld Email", + "width": "fill_container", + "layout": "vertical", + "gap": 7, + "children": [ + { + "type": "text", + "id": "W8rKc1", + "name": "l", + "fill": "$text-secondary", + "content": "Email", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "m9aRzY", + "name": "inp", + "width": "fill_container", + "height": 42, + "fill": "$bg-panel", + "cornerRadius": 9, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YGKJ1", + "name": "v", + "fill": "$text-primary", + "content": "vasyakrg@gmail.com", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "NG1fw", + "name": "PlanSec", + "width": "fill_container", + "layout": "vertical", + "gap": 14, + "children": [ + { + "type": "text", + "id": "gvrZw", + "name": "h", + "fill": "$text-primary", + "content": "Plan & Billing", + "fontFamily": "$font-ui", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "ovUmi", + "name": "PlanCard", + "width": "fill_container", + "fill": "$bg-sidebar", + "cornerRadius": 12, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "R1kZ5", + "name": "pl", + "layout": "vertical", + "gap": 5, + "children": [ + { + "type": "frame", + "id": "RvYnf", + "name": "pb", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "uDDiI", + "name": "n", + "fill": "$text-primary", + "content": "Pro", + "fontFamily": "$font-ui", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "C1qz6", + "name": "bdg", + "height": 18, + "fill": "#000000", + "cornerRadius": 9, + "padding": [ + 0, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "F2deA", + "name": "t", + "fill": "$st-done", + "content": "ACTIVE", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "text", + "id": "rd3lu", + "name": "d", + "fill": "$text-secondary", + "content": "$9/mo · renews Jul 9, 2026 · unlimited workspaces & sync", + "fontFamily": "$font-ui", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HdD6z", + "name": "sp", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "HeMLZ", + "name": "manage", + "height": 36, + "fill": "#00000000", + "cornerRadius": 8, + "stroke": "$border-strong", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": [ + 0, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "uzCXN", + "name": "t", + "fill": "$text-primary", + "content": "Manage plan", + "fontFamily": "$font-ui", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "IrTHb", + "name": "ChannelsSec", + "width": "fill_container", + "layout": "vertical", + "gap": 14, + "children": [ + { + "type": "text", + "id": "i2TgE3", + "name": "h", + "fill": "$text-primary", + "content": "Notification channels", + "fontFamily": "$font-ui", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "q80Nb", + "name": "ch Telegram", + "width": "fill_container", + "fill": "$bg-sidebar", + "cornerRadius": 12, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 14, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "jWUvG", + "name": "ib", + "width": 36, + "height": 36, + "fill": "$bg-elevated", + "cornerRadius": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "JbtK4", + "name": "i", + "width": 17, + "height": 17, + "icon": "send", + "library": "lucide", + "fill": "$text-primary" + } + ] + }, + { + "type": "frame", + "id": "iGSiV", + "name": "col", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "nKJaW", + "name": "tr", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "H3iBm", + "name": "n", + "fill": "$text-primary", + "content": "Telegram", + "fontFamily": "$font-ui", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "X2R22y", + "name": "st", + "height": 16, + "fill": "#000000", + "cornerRadius": 8, + "gap": 5, + "padding": [ + 0, + 7 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "Qhtgr", + "name": "o", + "fill": "$st-done", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "a7RShs", + "name": "t", + "fill": "$st-done", + "content": "connected", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "XR3aS", + "name": "tok", + "fill": "$text-muted", + "content": "env:SPACESH_TG_TOKEN", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Wwg4k", + "name": "toggle", + "width": 40, + "height": 22, + "fill": "$accent", + "cornerRadius": 11, + "stroke": "#00000000", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": 2, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "EVrUi", + "name": "knob", + "fill": "#0A0D12", + "width": 16, + "height": 16 + } + ] + } + ] + }, + { + "type": "frame", + "id": "EWUpy", + "name": "ch MAX", + "width": "fill_container", + "fill": "$bg-sidebar", + "cornerRadius": 12, + "stroke": "$border-subtle", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 14, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "k68K9V", + "name": "ib", + "width": 36, + "height": 36, + "fill": "$bg-elevated", + "cornerRadius": 9, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "twNq4", + "name": "i", + "width": 17, + "height": 17, + "icon": "message-circle", + "library": "lucide", + "fill": "$text-primary" + } + ] + }, + { + "type": "frame", + "id": "dmdwl", + "name": "col", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "Xi7yX", + "name": "tr", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "F5LXf8", + "name": "n", + "fill": "$text-primary", + "content": "MAX", + "fontFamily": "$font-ui", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "aJQCy", + "name": "st", + "height": 16, + "fill": "#000000", + "cornerRadius": 8, + "gap": 5, + "padding": [ + 0, + 7 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "pISgP", + "name": "o", + "fill": "$st-done", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "n0A74", + "name": "t", + "fill": "$st-done", + "content": "connected", + "fontFamily": "$font-mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "b7TcKj", + "name": "tok", + "fill": "$text-muted", + "content": "env:SPACESH_MAX_TOKEN", + "fontFamily": "$font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IYw1p", + "name": "toggle", + "width": 40, + "height": 22, + "fill": "$accent", + "cornerRadius": 11, + "stroke": "#00000000", + "strokeWidth": 1, + "strokeAlignment": "inner", + "padding": 2, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "GZWqK", + "name": "knob", + "fill": "#0A0D12", + "width": 16, + "height": 16 + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "variables": { + "accent": { + "type": "color", + "value": "#4C8DFF" + }, + "bg-app": { + "type": "color", + "value": "#0E1116" + }, + "bg-elevated": { + "type": "color", + "value": "#1A2029" + }, + "bg-hover": { + "type": "color", + "value": "#222A35" + }, + "bg-panel": { + "type": "color", + "value": "#0A0D12" + }, + "bg-sidebar": { + "type": "color", + "value": "#13171F" + }, + "border-strong": { + "type": "color", + "value": "#323C49" + }, + "border-subtle": { + "type": "color", + "value": "#232A33" + }, + "font-mono": { + "type": "string", + "value": "JetBrains Mono" + }, + "font-ui": { + "type": "string", + "value": "Inter" + }, + "st-done": { + "type": "color", + "value": "#3FB950" + }, + "st-error": { + "type": "color", + "value": "#F4544E" + }, + "st-idle": { + "type": "color", + "value": "#5A6573" + }, + "st-wait": { + "type": "color", + "value": "#F2B84B" + }, + "st-work": { + "type": "color", + "value": "#4C8DFF" + }, + "text-muted": { + "type": "color", + "value": "#5A6573" + }, + "text-primary": { + "type": "color", + "value": "#E6EDF3" + }, + "text-secondary": { + "type": "color", + "value": "#8B97A6" + } + } +} \ No newline at end of file diff --git a/DOCS/superpowers/plans/2026-06-09-spacesh-m0-m1.md b/DOCS/superpowers/plans/2026-06-09-spacesh-m0-m1.md new file mode 100644 index 0000000..4473675 --- /dev/null +++ b/DOCS/superpowers/plans/2026-06-09-spacesh-m0-m1.md @@ -0,0 +1,3084 @@ +# spacesh M0+M1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the M0 vertical slice (bytes flying GUI↔daemon↔PTY over a Unix socket) plus M1 persistence (daemon outlives GUI; reattach repaints the screen from a daemon-side grid snapshot). + +**Architecture:** A Rust Cargo workspace with four crates. `spacesh-proto` defines the wire protocol (length-prefixed JSON). `spacesh-pty` spawns and reads PTYs with output batching. `spacesh-core` keeps an authoritative terminal grid (`alacritty_terminal`) and serializes it to an ANSI snapshot. `spaceshd` is the daemon: a Unix-domain-socket server with one actor task per surface that owns the PTY, the grid, and a broadcast fan-out. A Tauri 2 app is the thin GUI: `src-tauri` bridges the socket into the webview (commands via `invoke`, the output stream via `ipc::Channel`, rare events via `emit`); the React front renders one `xterm.js` panel at a time and switches between surfaces. + +**Tech Stack:** Rust (tokio, tokio-util codec, serde/serde_json, bytes, base64, anyhow, thiserror), `portable-pty` 0.8, `alacritty_terminal` 0.25, Tauri 2, React + TypeScript + Vite, `@xterm/xterm` with `@xterm/addon-webgl`. + +**Spec:** `docs/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md`. Base spec: `DOCS/MAIN.md`. + +**Conventions:** English for all code/comments. camelCase vars/functions, PascalCase types, snake_case files, UPPER_CASE env vars. No hard-coded secrets. Socket path `~/.spacesh/sock`, lock `~/.spacesh/daemon.lock`. + +--- + +## File Structure + +``` +spacesh/ +├── Cargo.toml # [virtual workspace manifest] +├── crates/ +│ ├── spacesh-proto/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ ├── lib.rs # re-exports +│ │ ├── ids.rs # SurfaceId, WorkspaceId newtypes +│ │ ├── message.rs # Envelope, Req, Res, Evt, Cmd, ErrorBody +│ │ └── codec.rs # length-prefix framing helpers (read_frame/write_frame) +│ ├── spacesh-pty/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ └── lib.rs # PtyHandle: spawn, output rx (batched), input, resize, kill +│ ├── spacesh-core/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ ├── lib.rs # re-exports +│ │ ├── grid.rs # GridSurface: feed bytes into alacritty Term +│ │ └── snapshot.rs # snapshot_ansi(&Term) -> Snapshot { ansi, cols, rows, cursor } +│ └── spaceshd/ +│ ├── Cargo.toml +│ └── src/ +│ ├── main.rs # entrypoint: parse argv, run daemon or `install-agent` +│ ├── lifecycle.rs # socket path, lock file, single-instance, lazy-start helper +│ ├── registry.rs # Registry: workspaces + surface senders +│ ├── surface.rs # surface actor (owns PTY, grid, broadcast) +│ ├── server.rs # accept loop + client task + command dispatch +│ └── launchd.rs # plist template + install +└── app/ + ├── package.json + ├── vite.config.ts + ├── index.html + ├── src/ + │ ├── main.tsx + │ ├── App.tsx # holds surface list + active surface + │ ├── socketBridge.ts # invoke wrappers + Channel/event subscriptions + │ ├── TerminalView.tsx # xterm.js instance bound to one surface + │ └── SurfaceList.tsx # switch active surface + └── src-tauri/ + ├── Cargo.toml + ├── tauri.conf.json + ├── build.rs + └── src/ + ├── main.rs + ├── lib.rs # builder, manage(BridgeState), invoke_handler + └── bridge.rs # UDS connection, req/res correlation, output channels, evt emit +``` + +--- + +## Phase 0 — Workspace scaffold + +### Task 0: Create the Cargo workspace and crate skeletons + +**Files:** +- Create: `Cargo.toml` +- Create: `crates/spacesh-proto/Cargo.toml`, `crates/spacesh-proto/src/lib.rs` +- Create: `crates/spacesh-pty/Cargo.toml`, `crates/spacesh-pty/src/lib.rs` +- Create: `crates/spacesh-core/Cargo.toml`, `crates/spacesh-core/src/lib.rs` +- Create: `crates/spaceshd/Cargo.toml`, `crates/spaceshd/src/main.rs` + +- [ ] **Step 1: Create the workspace manifest** + +`Cargo.toml`: +```toml +[workspace] +resolver = "2" +members = [ + "crates/spacesh-proto", + "crates/spacesh-pty", + "crates/spacesh-core", + "crates/spaceshd", +] + +[workspace.package] +edition = "2021" +version = "0.1.0" + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +bytes = "1" +base64 = "0.22" +anyhow = "1" +thiserror = "1" +futures = "0.3" +portable-pty = "0.8" +alacritty_terminal = "0.25" +fs2 = "0.4" +dirs = "5" +``` + +- [ ] **Step 2: Create `spacesh-proto` skeleton** + +`crates/spacesh-proto/Cargo.toml`: +```toml +[package] +name = "spacesh-proto" +edition.workspace = true +version.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +bytes.workspace = true +thiserror.workspace = true +tokio = { workspace = true } +tokio-util.workspace = true +``` + +`crates/spacesh-proto/src/lib.rs`: +```rust +pub mod codec; +pub mod ids; +pub mod message; + +pub use ids::{SurfaceId, WorkspaceId}; +pub use message::{Cmd, Envelope, ErrorBody, Evt}; +``` + +- [ ] **Step 3: Create `spacesh-pty` skeleton** + +`crates/spacesh-pty/Cargo.toml`: +```toml +[package] +name = "spacesh-pty" +edition.workspace = true +version.workspace = true + +[dependencies] +portable-pty.workspace = true +tokio.workspace = true +bytes.workspace = true +anyhow.workspace = true +``` + +`crates/spacesh-pty/src/lib.rs`: +```rust +// PtyHandle implemented in Task 3. +``` + +- [ ] **Step 4: Create `spacesh-core` skeleton** + +`crates/spacesh-core/Cargo.toml`: +```toml +[package] +name = "spacesh-core" +edition.workspace = true +version.workspace = true + +[dependencies] +alacritty_terminal.workspace = true +serde.workspace = true +``` + +`crates/spacesh-core/src/lib.rs`: +```rust +pub mod grid; +pub mod snapshot; + +pub use grid::GridSurface; +pub use snapshot::Snapshot; +``` + +(Leave `grid.rs` / `snapshot.rs` to Tasks 11–12; create them empty now so it compiles after those tasks. For Task 0, comment the `mod` lines out and restore them in Task 11.) + +`crates/spacesh-core/src/lib.rs` (Task 0 version): +```rust +// modules added in Tasks 11-12 +``` + +- [ ] **Step 5: Create `spaceshd` skeleton** + +`crates/spaceshd/Cargo.toml`: +```toml +[package] +name = "spaceshd" +edition.workspace = true +version.workspace = true + +[[bin]] +name = "spaceshd" +path = "src/main.rs" + +[dependencies] +spacesh-proto = { path = "../spacesh-proto" } +spacesh-pty = { path = "../spacesh-pty" } +spacesh-core = { path = "../spacesh-core" } +tokio.workspace = true +tokio-util.workspace = true +serde.workspace = true +serde_json.workspace = true +bytes.workspace = true +base64.workspace = true +anyhow.workspace = true +thiserror.workspace = true +futures.workspace = true +fs2.workspace = true +dirs.workspace = true +``` + +`crates/spaceshd/src/main.rs`: +```rust +fn main() { + println!("spaceshd skeleton"); +} +``` + +- [ ] **Step 6: Verify the workspace builds** + +Run: `cargo build` +Expected: PASS (compiles all four crates; `spaceshd` prints nothing yet but builds). + +- [ ] **Step 7: Commit** + +```bash +git add Cargo.toml crates/ +git commit -m "chore: scaffold cargo workspace and crate skeletons" +``` + +--- + +## Phase 1 — spacesh-proto + +### Task 1: Id newtypes and message types + +**Files:** +- Create: `crates/spacesh-proto/src/ids.rs` +- Create: `crates/spacesh-proto/src/message.rs` +- Test: inline `#[cfg(test)]` in `message.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `crates/spacesh-proto/src/message.rs`: +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::ids::{SurfaceId, WorkspaceId}; + + #[test] + fn req_round_trips_through_json() { + let env = Envelope::Req { + id: 42, + cmd: Cmd::Focus { surface_id: SurfaceId("s_8f3".into()) }, + }; + let json = serde_json::to_string(&env).unwrap(); + let back: Envelope = serde_json::from_str(&json).unwrap(); + assert_eq!(env, back); + } + + #[test] + fn res_ok_and_err_serialize_distinctly() { + let ok = Envelope::Res { id: 1, ok: true, data: serde_json::json!({"workspace_id":"w_1"}), error: None }; + let err = Envelope::Res { id: 2, ok: false, data: serde_json::Value::Null, + error: Some(ErrorBody { code: "NOT_FOUND".into(), msg: "no surface".into() }) }; + assert!(serde_json::to_string(&ok).unwrap().contains("\"ok\":true")); + assert!(serde_json::to_string(&err).unwrap().contains("NOT_FOUND")); + } + + #[test] + fn evt_output_carries_workspace_scoped_surface() { + let evt = Envelope::Evt(Evt::Output { + surface_id: SurfaceId("s_1".into()), + bytes: vec![104, 105], + }); + let json = serde_json::to_string(&evt).unwrap(); + let back: Envelope = serde_json::from_str(&json).unwrap(); + assert_eq!(evt, back); + } + + #[test] + fn new_surface_defaults_cmd_to_none() { + let json = r#"{"kind":"req","id":7,"cmd":{"cmd":"new_surface","args":{"workspace_id":"w_1","cols":80,"rows":24}}}"#; + let env: Envelope = serde_json::from_str(json).unwrap(); + match env { + Envelope::Req { cmd: Cmd::NewSurface { command, args, .. }, .. } => { + assert!(command.is_none()); + assert!(args.is_empty()); + } + _ => panic!("wrong variant"), + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p spacesh-proto` +Expected: FAIL to compile (`Envelope`, `Cmd`, `Evt`, `ErrorBody`, `SurfaceId` not defined). + +- [ ] **Step 3: Write the id newtypes** + +`crates/spacesh-proto/src/ids.rs`: +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SurfaceId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct WorkspaceId(pub String); + +impl std::fmt::Display for SurfaceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +impl std::fmt::Display for WorkspaceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +``` + +- [ ] **Step 4: Write the message types** + +Prepend to `crates/spacesh-proto/src/message.rs` (above the test module): +```rust +use serde::{Deserialize, Serialize}; +use crate::ids::{SurfaceId, WorkspaceId}; + +/// Wire envelope. `kind` is the serde tag. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Envelope { + Req { + id: u64, + cmd: Cmd, + }, + Res { + id: u64, + ok: bool, + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + data: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + Evt(Evt), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ErrorBody { + pub code: String, + pub msg: String, +} + +/// Client → daemon commands. The active subset for M0+M1. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "cmd", content = "args", rename_all = "snake_case")] +pub enum Cmd { + Open { path: String }, + NewSurface { + workspace_id: WorkspaceId, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + args: Vec, + cols: u16, + rows: u16, + }, + Input { + surface_id: SurfaceId, + /// base64-encoded keyboard bytes. + bytes: String, + }, + Resize { surface_id: SurfaceId, cols: u16, rows: u16 }, + Attach { surface_id: SurfaceId }, + Detach { surface_id: SurfaceId }, + Focus { surface_id: SurfaceId }, + Close { surface_id: SurfaceId }, + Status, + Shutdown, +} + +/// Daemon → subscribers push events. The active subset for M0+M1. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "evt", content = "data", rename_all = "snake_case")] +pub enum Evt { + Output { surface_id: SurfaceId, bytes: Vec }, + Exit { surface_id: SurfaceId, code: i32 }, + SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId }, + SurfaceClosed { surface_id: SurfaceId }, +} +``` + +Note on the `Cmd::Input.bytes` field: it is base64 text so it survives JSON cleanly. `Evt::Output.bytes` is `Vec` and serde_json encodes it as a JSON array of numbers — acceptable for M0+M1; the MessagePack swap noted in the spec would change this later. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p spacesh-proto` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add crates/spacesh-proto/src/ids.rs crates/spacesh-proto/src/message.rs +git commit -m "feat(proto): envelope, commands, events, ids with serde round-trip tests" +``` + +--- + +### Task 2: Length-prefix codec helpers + +**Files:** +- Create: `crates/spacesh-proto/src/codec.rs` +- Test: inline `#[cfg(test)]` in `codec.rs` + +- [ ] **Step 1: Write the failing test** + +`crates/spacesh-proto/src/codec.rs`: +```rust +use crate::message::Envelope; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +/// Maximum frame size we will accept (16 MiB). Guards against a corrupt length prefix. +pub const MAX_FRAME: u32 = 16 * 1024 * 1024; + +#[derive(Debug, thiserror::Error)] +pub enum CodecError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("frame too large: {0} bytes")] + FrameTooLarge(u32), +} + +/// Write one envelope as `u32` BE length prefix + JSON payload. +pub async fn write_frame(w: &mut W, env: &Envelope) -> Result<(), CodecError> { + let payload = serde_json::to_vec(env)?; + let len = payload.len() as u32; + if len > MAX_FRAME { + return Err(CodecError::FrameTooLarge(len)); + } + w.write_all(&len.to_be_bytes()).await?; + w.write_all(&payload).await?; + w.flush().await?; + Ok(()) +} + +/// Read one length-prefixed envelope. Returns `Ok(None)` on clean EOF. +pub async fn read_frame(r: &mut R) -> Result, CodecError> { + let mut len_buf = [0u8; 4]; + match r.read_exact(&mut len_buf).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e.into()), + } + let len = u32::from_be_bytes(len_buf); + if len > MAX_FRAME { + return Err(CodecError::FrameTooLarge(len)); + } + let mut payload = vec![0u8; len as usize]; + r.read_exact(&mut payload).await?; + let env: Envelope = serde_json::from_slice(&payload)?; + Ok(Some(env)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ids::SurfaceId; + use crate::message::{Cmd, Envelope}; + + #[tokio::test] + async fn frame_round_trips_over_a_pipe() { + let (mut client, mut server) = tokio::io::duplex(1024); + let env = Envelope::Req { id: 9, cmd: Cmd::Status }; + write_frame(&mut client, &env).await.unwrap(); + let got = read_frame(&mut server).await.unwrap().unwrap(); + assert_eq!(got, env); + } + + #[tokio::test] + async fn two_frames_are_decoded_independently() { + let (mut client, mut server) = tokio::io::duplex(4096); + let a = Envelope::Req { id: 1, cmd: Cmd::Status }; + let b = Envelope::Req { id: 2, cmd: Cmd::Close { surface_id: SurfaceId("s_1".into()) } }; + write_frame(&mut client, &a).await.unwrap(); + write_frame(&mut client, &b).await.unwrap(); + assert_eq!(read_frame(&mut server).await.unwrap().unwrap(), a); + assert_eq!(read_frame(&mut server).await.unwrap().unwrap(), b); + } + + #[tokio::test] + async fn clean_eof_returns_none() { + let (client, mut server) = tokio::io::duplex(16); + drop(client); + assert!(read_frame(&mut server).await.unwrap().is_none()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p spacesh-proto codec` +Expected: FAIL to compile until `codec` is wired (it is already declared in `lib.rs` from Task 0 Step 2). If `lib.rs` lacks `pub mod codec;`, it is present — confirm. + +- [ ] **Step 3: Implementation already written in Step 1** + +The module body above is the implementation. No additional code. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p spacesh-proto` +Expected: PASS (all proto tests, including 3 new codec tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/spacesh-proto/src/codec.rs +git commit -m "feat(proto): length-prefixed frame read/write with EOF handling" +``` + +--- + +## Phase 2 — spacesh-pty + +### Task 3: PtyHandle — spawn, batched output, input, resize, kill + +**Files:** +- Modify: `crates/spacesh-pty/src/lib.rs` +- Test: inline `#[cfg(test)]` in `lib.rs` + +The PTY APIs are blocking (`portable-pty` reader is a blocking `Read`). We run the reader on a dedicated OS thread and forward raw chunks over an `mpsc`. Batching/coalescing happens in the daemon's surface actor (Task 13), not here — this crate exposes raw chunks plus a small read buffer. Keeping batching out of `spacesh-pty` keeps it a thin, testable I/O layer. + +- [ ] **Step 1: Write the failing test** + +`crates/spacesh-pty/src/lib.rs`: +```rust +use std::io::{Read, Write}; +use anyhow::Result; +use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; +use tokio::sync::mpsc; + +/// A spawned PTY with its child process. Output chunks arrive on `output`. +pub struct PtyHandle { + master: Box, + writer: Box, + child: Box, + /// Raw output chunks read off the PTY master (already on the async side). + pub output: mpsc::Receiver>, +} + +/// Parameters for spawning a surface's process. +pub struct SpawnSpec { + pub command: String, + pub args: Vec, + pub cwd: std::path::PathBuf, + pub cols: u16, + pub rows: u16, + /// Extra environment variables (e.g. SPACESH_SURFACE_ID). + pub env: Vec<(String, String)>, +} + +impl PtyHandle { + pub fn spawn(spec: SpawnSpec) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows: spec.rows, + cols: spec.cols, + pixel_width: 0, + pixel_height: 0, + })?; + + let mut cmd = CommandBuilder::new(&spec.command); + for a in &spec.args { + cmd.arg(a); + } + cmd.cwd(&spec.cwd); + for (k, v) in &spec.env { + cmd.env(k, v); + } + + let child = pair.slave.spawn_command(cmd)?; + // The slave handle must be dropped so the child is the only holder; otherwise + // EOF is never observed on the master after the child exits. + drop(pair.slave); + + let writer = pair.master.take_writer()?; + let mut reader = pair.master.try_clone_reader()?; + + let (tx, rx) = mpsc::channel::>(256); + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, // EOF: child closed the pty + Ok(n) => { + if tx.blocking_send(buf[..n].to_vec()).is_err() { + break; // receiver gone + } + } + Err(_) => break, + } + } + }); + + Ok(Self { + master: pair.master, + writer, + child, + output: rx, + }) + } + + pub fn write_input(&mut self, bytes: &[u8]) -> Result<()> { + self.writer.write_all(bytes)?; + self.writer.flush()?; + Ok(()) + } + + pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { + self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })?; + Ok(()) + } + + /// Best-effort wait for the child's exit code (blocking). + pub fn wait(&mut self) -> i32 { + match self.child.wait() { + Ok(status) => status.exit_code() as i32, + Err(_) => -1, + } + } + + pub fn kill(&mut self) { + let _ = self.child.kill(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn shell_spec(script: &str) -> SpawnSpec { + SpawnSpec { + command: "/bin/sh".into(), + args: vec!["-c".into(), script.into()], + cwd: std::env::temp_dir(), + cols: 80, + rows: 24, + env: vec![("SPACESH_SURFACE_ID".into(), "s_test".into())], + } + } + + #[tokio::test] + async fn spawn_echo_produces_output() { + let mut handle = PtyHandle::spawn(shell_spec("printf SPACESH_OK")).unwrap(); + let mut collected = Vec::new(); + // Drain until EOF (channel closes when the reader thread sees EOF). + while let Some(chunk) = handle.output.recv().await { + collected.extend_from_slice(&chunk); + } + let text = String::from_utf8_lossy(&collected); + assert!(text.contains("SPACESH_OK"), "got: {text:?}"); + } + + #[tokio::test] + async fn resize_does_not_error() { + let handle = PtyHandle::spawn(shell_spec("sleep 0.2")).unwrap(); + handle.resize(120, 40).unwrap(); + } + + #[tokio::test] + async fn input_is_echoed_back() { + // `cat` echoes stdin back to stdout on a pty. + let mut handle = PtyHandle::spawn(shell_spec("cat")).unwrap(); + handle.write_input(b"hello\n").unwrap(); + let mut collected = Vec::new(); + // Read a few chunks then kill cat to end the stream. + if let Some(chunk) = handle.output.recv().await { + collected.extend_from_slice(&chunk); + } + handle.kill(); + let text = String::from_utf8_lossy(&collected); + assert!(text.contains("hello"), "got: {text:?}"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p spacesh-pty` +Expected: PASS actually — the implementation is included with the tests in this task. (Write the file in full as above; the "failing first" step here is degenerate because the type and tests are introduced together for an I/O wrapper. If you prefer strict red-green, paste only the `#[cfg(test)]` block first, run `cargo test -p spacesh-pty` → FAIL "cannot find PtyHandle", then paste the implementation.) + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `cargo test -p spacesh-pty` +Expected: PASS (3 tests). If `input_is_echoed_back` is flaky on slow CI, the kill+single-recv keeps it bounded; re-run. + +- [ ] **Step 4: Commit** + +```bash +git add crates/spacesh-pty/src/lib.rs +git commit -m "feat(pty): PtyHandle spawn/read/input/resize/kill over portable-pty" +``` + +--- + +## Phase 3 — spaceshd (M0 core: bytes flying) + +### Task 4: Lifecycle helpers — paths, lock, lazy start + +**Files:** +- Create: `crates/spaceshd/src/lifecycle.rs` +- Modify: `crates/spaceshd/src/main.rs` (add `mod lifecycle;`) +- Test: inline `#[cfg(test)]` in `lifecycle.rs` + +- [ ] **Step 1: Write the failing test** + +`crates/spaceshd/src/lifecycle.rs`: +```rust +use std::path::PathBuf; +use anyhow::{Context, Result}; + +/// `~/.spacesh` directory, created if missing. +pub fn spacesh_dir() -> Result { + let home = dirs::home_dir().context("no home dir")?; + let dir = home.join(".spacesh"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +pub fn socket_path() -> Result { + Ok(spacesh_dir()?.join("sock")) +} + +pub fn lock_path() -> Result { + Ok(spacesh_dir()?.join("daemon.lock")) +} + +/// Hold the single-instance lock for the lifetime of the daemon. +pub struct InstanceLock { + _file: std::fs::File, +} + +/// Acquire the exclusive daemon lock. Returns `Ok(None)` if another live daemon holds it. +pub fn acquire_instance_lock() -> Result> { + use fs2::FileExt; + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .open(lock_path()?)?; + match file.try_lock_exclusive() { + Ok(()) => Ok(Some(InstanceLock { _file: file })), + Err(_) => Ok(None), + } +} + +/// If a stale socket file exists but no daemon answers, remove it so we can bind. +pub fn clear_stale_socket() -> Result<()> { + let path = socket_path()?; + if path.exists() { + // We hold the instance lock, so any existing socket is stale. + std::fs::remove_file(&path)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_live_under_spacesh_dir() { + let dir = spacesh_dir().unwrap(); + assert!(socket_path().unwrap().starts_with(&dir)); + assert!(lock_path().unwrap().starts_with(&dir)); + } + + #[test] + fn lock_is_exclusive_within_process() { + let first = acquire_instance_lock().unwrap(); + assert!(first.is_some(), "first acquire should succeed"); + // A second attempt from the same process on the same fd path: + // fs2 advisory locks are per-handle; opening a new handle and locking + // should fail while `first` is held. + let second = acquire_instance_lock().unwrap(); + assert!(second.is_none(), "second acquire should be blocked"); + drop(first); + } +} +``` + +- [ ] **Step 2: Wire the module** + +`crates/spaceshd/src/main.rs`: +```rust +mod lifecycle; + +fn main() { + println!("spaceshd skeleton"); +} +``` + +- [ ] **Step 3: Run test to verify behavior** + +Run: `cargo test -p spaceshd lifecycle` +Expected: PASS (2 tests). `lock_is_exclusive_within_process` depends on `fs2` advisory locking being per-open-handle; if your platform coalesces locks per-process, change the assertion to acquire from a spawned thread holding its own handle. (macOS `flock` is per-handle → the test as written passes.) + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/lifecycle.rs crates/spaceshd/src/main.rs +git commit -m "feat(daemon): lifecycle paths, single-instance lock, stale-socket cleanup" +``` + +--- + +### Task 5: Surface actor (M0 — no grid yet) + +**Files:** +- Create: `crates/spaceshd/src/surface.rs` +- Modify: `crates/spaceshd/src/main.rs` (add `mod surface;`) +- Test: inline `#[cfg(test)]` in `surface.rs` + +The actor owns the PTY and a `broadcast` sender for output fan-out. In M0 it forwards raw chunks straight to the broadcast (no grid feed, no coalescing yet — both added in Task 13). Commands arrive on an `mpsc`. + +- [ ] **Step 1: Write the failing test** + +`crates/spaceshd/src/surface.rs`: +```rust +use spacesh_proto::{SurfaceId, WorkspaceId}; +use spacesh_pty::{PtyHandle, SpawnSpec}; +use tokio::sync::{broadcast, mpsc, oneshot}; + +/// Output broadcast capacity (chunks). Lagging subscribers drop intermediate frames. +const BROADCAST_CAP: usize = 1024; + +/// Messages sent to a surface actor. +pub enum SurfaceMsg { + Input(Vec), + Resize { cols: u16, rows: u16 }, + /// Subscribe to the output stream. Reply carries a fresh receiver. + Attach { reply: oneshot::Sender>> }, + Close, +} + +/// Handle the daemon keeps for a live surface. +pub struct SurfaceHandle { + pub id: SurfaceId, + pub workspace_id: WorkspaceId, + pub tx: mpsc::Sender, +} + +/// Spawn the actor; returns its handle. `exit_tx` is fired once with the exit code. +pub fn spawn_surface( + id: SurfaceId, + workspace_id: WorkspaceId, + mut pty: PtyHandle, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, +) -> SurfaceHandle { + let (tx, mut rx) = mpsc::channel::(64); + let (bcast, _) = broadcast::channel::>(BROADCAST_CAP); + let actor_id = id.clone(); + + tokio::spawn(async move { + loop { + tokio::select! { + msg = rx.recv() => { + match msg { + Some(SurfaceMsg::Input(bytes)) => { let _ = pty.write_input(&bytes); } + Some(SurfaceMsg::Resize { cols, rows }) => { let _ = pty.resize(cols, rows); } + Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); } + Some(SurfaceMsg::Close) | None => { pty.kill(); break; } + } + } + chunk = pty.output.recv() => { + match chunk { + Some(bytes) => { let _ = bcast.send(bytes); } + None => { break; } // PTY EOF + } + } + } + } + let code = pty.wait(); + let _ = exit_tx.send((actor_id, code)); + }); + + SurfaceHandle { id, workspace_id, tx } +} + +#[cfg(test)] +mod tests { + use super::*; + use spacesh_pty::SpawnSpec; + + fn spec(script: &str) -> SpawnSpec { + SpawnSpec { + command: "/bin/sh".into(), + args: vec!["-c".into(), script.into()], + cwd: std::env::temp_dir(), + cols: 80, + rows: 24, + env: vec![], + } + } + + #[tokio::test] + async fn attach_receives_output() { + let pty = PtyHandle::spawn(spec("printf HELLO; sleep 0.3")).unwrap(); + let (exit_tx, _exit_rx) = mpsc::unbounded_channel(); + let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, exit_tx); + + let (reply_tx, reply_rx) = oneshot::channel(); + handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap(); + let mut sub = reply_rx.await.unwrap(); + + let mut collected = Vec::new(); + // Collect for a short bounded window. + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await { + Ok(Ok(bytes)) => collected.extend_from_slice(&bytes), + _ => {} + } + if String::from_utf8_lossy(&collected).contains("HELLO") { break; } + } + assert!(String::from_utf8_lossy(&collected).contains("HELLO")); + } + + #[tokio::test] + async fn exit_is_reported() { + let pty = PtyHandle::spawn(spec("exit 7")).unwrap(); + let (exit_tx, mut exit_rx) = mpsc::unbounded_channel(); + let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, exit_tx); + let (sid, code) = tokio::time::timeout(tokio::time::Duration::from_secs(3), exit_rx.recv()) + .await.unwrap().unwrap(); + assert_eq!(sid, SurfaceId("s_2".into())); + assert_eq!(code, 7); + } +} +``` + +- [ ] **Step 2: Wire the module** + +`crates/spaceshd/src/main.rs`: +```rust +mod lifecycle; +mod surface; + +fn main() { + println!("spaceshd skeleton"); +} +``` + +- [ ] **Step 3: Run test to verify it fails then passes** + +Run: `cargo test -p spaceshd surface` +Expected: PASS (2 tests). If the unused `SpawnSpec` import in the actor file warns, remove it from the top-level `use` (it is only used in tests). + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/surface.rs crates/spaceshd/src/main.rs +git commit -m "feat(daemon): surface actor owning pty + broadcast fan-out (M0, no grid)" +``` + +--- + +### Task 6: Registry + +**Files:** +- Create: `crates/spaceshd/src/registry.rs` +- Modify: `crates/spaceshd/src/main.rs` (add `mod registry;`) +- Test: inline `#[cfg(test)]` in `registry.rs` + +- [ ] **Step 1: Write the failing test** + +`crates/spaceshd/src/registry.rs`: +```rust +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use spacesh_proto::{SurfaceId, WorkspaceId}; +use crate::surface::SurfaceHandle; + +#[derive(Clone)] +pub struct WorkspaceMeta { + pub id: WorkspaceId, + pub path: PathBuf, +} + +/// Single-threaded owner of all live surfaces and workspaces. +/// Lives inside the server task; not shared across threads. +#[derive(Default)] +pub struct Registry { + counter: AtomicU64, + workspaces: HashMap, + /// path → workspace, so `open` is idempotent. + by_path: HashMap, + surfaces: HashMap, +} + +impl Registry { + pub fn new() -> Self { + Self::default() + } + + fn next_id(&self, prefix: &str) -> String { + let n = self.counter.fetch_add(1, Ordering::Relaxed); + format!("{prefix}_{n:x}") + } + + /// Idempotent: opening the same canonicalized path returns the existing workspace. + pub fn open_workspace(&mut self, path: PathBuf) -> WorkspaceMeta { + let canonical = path.canonicalize().unwrap_or(path); + if let Some(id) = self.by_path.get(&canonical) { + return self.workspaces[id].clone(); + } + let id = WorkspaceId(self.next_id("w")); + let meta = WorkspaceMeta { id: id.clone(), path: canonical.clone() }; + self.workspaces.insert(id.clone(), meta.clone()); + self.by_path.insert(canonical, id); + meta + } + + pub fn workspace(&self, id: &WorkspaceId) -> Option<&WorkspaceMeta> { + self.workspaces.get(id) + } + + pub fn new_surface_id(&self) -> SurfaceId { + SurfaceId(self.next_id("s")) + } + + pub fn insert_surface(&mut self, handle: SurfaceHandle) { + self.surfaces.insert(handle.id.clone(), handle); + } + + pub fn surface(&self, id: &SurfaceId) -> Option<&SurfaceHandle> { + self.surfaces.get(id) + } + + pub fn remove_surface(&mut self, id: &SurfaceId) -> Option { + self.surfaces.remove(id) + } + + /// Snapshot for the `status` command: (workspace, its surface ids). + pub fn status(&self) -> Vec<(WorkspaceMeta, Vec)> { + self.workspaces + .values() + .map(|w| { + let sids = self + .surfaces + .values() + .filter(|s| s.workspace_id == w.id) + .map(|s| s.id.clone()) + .collect(); + (w.clone(), sids) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_is_idempotent_per_path() { + let mut reg = Registry::new(); + let dir = std::env::temp_dir(); + let a = reg.open_workspace(dir.clone()); + let b = reg.open_workspace(dir.clone()); + assert_eq!(a.id, b.id); + } + + #[test] + fn ids_are_unique_and_prefixed() { + let reg = Registry::new(); + let s1 = reg.new_surface_id(); + let s2 = reg.new_surface_id(); + assert!(s1.0.starts_with("s_")); + assert_ne!(s1, s2); + } +} +``` + +- [ ] **Step 2: Wire the module** + +Update `crates/spaceshd/src/main.rs` to add `mod registry;` alongside the others. + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p spaceshd registry` +Expected: PASS (2 tests). + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/registry.rs crates/spaceshd/src/main.rs +git commit -m "feat(daemon): registry for workspaces and surfaces with idempotent open" +``` + +--- + +### Task 7: Socket server — accept loop, client task, command dispatch + +**Files:** +- Create: `crates/spaceshd/src/server.rs` +- Modify: `crates/spaceshd/src/main.rs` +- Test: inline `#[cfg(test)]` integration test in `server.rs` + +Design: the `Registry` is owned by a single **router task** reachable via an `mpsc`. Each accepted connection spawns a **client task** that owns the socket write half and forwards (a) parsed requests to the router, (b) a subscription channel for events. The router executes commands against the registry and replies through per-client `mpsc` senders. This keeps the registry lock-free (single owner) and lets the router fan events out to all connected clients. + +- [ ] **Step 1: Write the failing integration test** + +`crates/spaceshd/src/server.rs`: +```rust +use std::collections::HashMap; +use std::path::Path; +use anyhow::Result; +use base64::Engine; +use spacesh_proto::codec::{read_frame, write_frame}; +use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId}; +use spacesh_pty::{PtyHandle, SpawnSpec}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::{mpsc, oneshot}; +use crate::registry::Registry; +use crate::surface::{spawn_surface, SurfaceMsg}; + +/// Per-client outbound channel: the router pushes envelopes the client task writes out. +type ClientTx = mpsc::Sender; + +/// Messages into the single router task. +enum ServerMsg { + /// A request from a client; reply routed to that client's `out`. + Request { id: u64, cmd: Cmd, client: ClientId, out: ClientTx }, + /// Forward an output chunk to all subscribers of `surface_id`. + Output { surface_id: SurfaceId, bytes: Vec }, + /// A surface process exited. + Exit { surface_id: SurfaceId, code: i32 }, + /// Register a new client's event sink. + ClientConnected { client: ClientId, out: ClientTx }, + /// Drop a client and all its subscriptions. + ClientDisconnected { client: ClientId }, +} + +type ClientId = u64; + +pub async fn serve(socket: &Path) -> Result<()> { + let listener = UnixListener::bind(socket)?; + let (router_tx, router_rx) = mpsc::channel::(256); + + // Exit events from surfaces are funneled into the router. + let (exit_tx, mut exit_rx) = mpsc::unbounded_channel::<(SurfaceId, i32)>(); + let router_for_exit = router_tx.clone(); + tokio::spawn(async move { + while let Some((sid, code)) = exit_rx.recv().await { + let _ = router_for_exit.send(ServerMsg::Exit { surface_id: sid, code }).await; + } + }); + + let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx)); + + let mut next_client: ClientId = 0; + loop { + let (stream, _addr) = listener.accept().await?; + let client_id = next_client; + next_client += 1; + let router_tx = router_tx.clone(); + tokio::spawn(handle_client(stream, client_id, router_tx)); + if shutdown.is_finished() { + break; + } + } + Ok(()) +} + +async fn handle_client(stream: UnixStream, client_id: ClientId, router_tx: mpsc::Sender) { + let (mut read_half, mut write_half) = stream.into_split(); + let (out_tx, mut out_rx) = mpsc::channel::(256); + + let _ = router_tx + .send(ServerMsg::ClientConnected { client: client_id, out: out_tx.clone() }) + .await; + + // Writer task: drain outbound envelopes to the socket. + let writer = tokio::spawn(async move { + while let Some(env) = out_rx.recv().await { + if write_frame(&mut write_half, &env).await.is_err() { + break; + } + } + }); + + // Reader loop: parse frames and forward requests to the router. + loop { + match read_frame(&mut read_half).await { + Ok(Some(Envelope::Req { id, cmd })) => { + let _ = router_tx + .send(ServerMsg::Request { id, cmd, client: client_id, out: out_tx.clone() }) + .await; + } + Ok(Some(_)) => { /* clients don't send res/evt; ignore */ } + Ok(None) => break, // EOF + Err(_) => break, + } + } + + let _ = router_tx.send(ServerMsg::ClientDisconnected { client: client_id }).await; + writer.abort(); +} + +async fn router( + mut rx: mpsc::Receiver, + router_tx: mpsc::Sender, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, +) { + let mut reg = Registry::new(); + let mut clients: HashMap = HashMap::new(); + // surface_id → set of client ids subscribed (attached). + let mut subs: HashMap> = HashMap::new(); + + while let Some(msg) = rx.recv().await { + match msg { + ServerMsg::ClientConnected { client, out } => { + clients.insert(client, out); + } + ServerMsg::ClientDisconnected { client } => { + clients.remove(&client); + for list in subs.values_mut() { + list.retain(|c| *c != client); + } + } + ServerMsg::Output { surface_id, bytes } => { + if let Some(list) = subs.get(&surface_id) { + let evt = Envelope::Evt(Evt::Output { surface_id: surface_id.clone(), bytes }); + for c in list { + if let Some(out) = clients.get(c) { + let _ = out.try_send(evt.clone()); + } + } + } + } + ServerMsg::Exit { surface_id, code } => { + let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code }); + broadcast_evt(&clients, &evt); + } + ServerMsg::Request { id, cmd, client, out } => { + handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx).await; + } + } + } +} + +fn broadcast_evt(clients: &HashMap, evt: &Envelope) { + for out in clients.values() { + let _ = out.try_send(evt.clone()); + } +} + +fn ok(id: u64, data: serde_json::Value) -> Envelope { + Envelope::Res { id, ok: true, data, error: None } +} +fn err(id: u64, code: &str, msg: &str) -> Envelope { + Envelope::Res { id, ok: false, data: serde_json::Value::Null, + error: Some(ErrorBody { code: code.into(), msg: msg.into() }) } +} + +#[allow(clippy::too_many_arguments)] +async fn handle_request( + id: u64, + cmd: Cmd, + client: ClientId, + out: ClientTx, + reg: &mut Registry, + subs: &mut HashMap>, + clients: &HashMap, + router_tx: &mpsc::Sender, + exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>, +) { + match cmd { + Cmd::Open { path } => { + let meta = reg.open_workspace(path.into()); + let _ = out.send(ok(id, serde_json::json!({ "workspace_id": meta.id.0 }))).await; + } + Cmd::NewSurface { workspace_id, command, args, cols, rows } => { + let Some(ws) = reg.workspace(&workspace_id).cloned() else { + let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; + return; + }; + let sid = reg.new_surface_id(); + let shell = command.unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); + let spec = SpawnSpec { + command: shell, + args, + cwd: ws.path.clone(), + cols, + rows, + env: vec![("SPACESH_SURFACE_ID".into(), sid.0.clone())], + }; + match PtyHandle::spawn(spec) { + Ok(pty) => { + let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, exit_tx.clone()); + // Bridge the surface's broadcast into the router as Output messages. + spawn_output_bridge(sid.clone(), &handle, router_tx.clone()); + reg.insert_surface(handle); + let created = Envelope::Evt(Evt::SurfaceCreated { + surface_id: sid.clone(), workspace_id: workspace_id.clone(), + }); + broadcast_evt(clients, &created); + let _ = out.send(ok(id, serde_json::json!({ "surface_id": sid.0 }))).await; + } + Err(e) => { + let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; + } + } + } + Cmd::Input { surface_id, bytes } => { + let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) else { + let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await; + return; + }; + if let Some(s) = reg.surface(&surface_id) { + let _ = s.tx.send(SurfaceMsg::Input(decoded)).await; + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; + } + } + Cmd::Resize { surface_id, cols, rows } => { + if let Some(s) = reg.surface(&surface_id) { + let _ = s.tx.send(SurfaceMsg::Resize { cols, rows }).await; + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; + } + } + Cmd::Attach { surface_id } => { + // M0 attach: register subscription, no snapshot yet (snapshot added in Task 13). + if reg.surface(&surface_id).is_some() { + subs.entry(surface_id.clone()).or_default().push(client); + let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0 }))).await; + } else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; + } + } + Cmd::Detach { surface_id } => { + if let Some(list) = subs.get_mut(&surface_id) { + list.retain(|c| *c != client); + } + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + Cmd::Focus { surface_id: _ } => { + // Focus is a no-op in this slice (window raise is GUI-side; CLI parity later). + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } + Cmd::Close { surface_id } => { + if let Some(handle) = reg.remove_surface(&surface_id) { + let _ = handle.tx.send(SurfaceMsg::Close).await; + subs.remove(&surface_id); + let closed = Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() }); + broadcast_evt(clients, &closed); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; + } + } + Cmd::Status => { + let workspaces: Vec<_> = reg.status().into_iter().map(|(w, sids)| { + serde_json::json!({ + "workspace_id": w.id.0, + "path": w.path.to_string_lossy(), + "surfaces": sids.iter().map(|s| s.0.clone()).collect::>(), + }) + }).collect(); + let _ = out.send(ok(id, serde_json::json!({ "workspaces": workspaces }))).await; + } + Cmd::Shutdown => { + let _ = out.send(ok(id, serde_json::Value::Null)).await; + std::process::exit(0); + } + } +} + +/// Pump a surface's broadcast output into the router as `ServerMsg::Output`. +fn spawn_output_bridge( + surface_id: SurfaceId, + handle: &crate::surface::SurfaceHandle, + router_tx: mpsc::Sender, +) { + let tx = handle.tx.clone(); + tokio::spawn(async move { + // Ask the actor for a subscription receiver. + let (reply_tx, reply_rx) = oneshot::channel(); + if tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.is_err() { + return; + } + let Ok(mut sub) = reply_rx.await else { return }; + loop { + match sub.recv().await { + Ok(bytes) => { + if router_tx.send(ServerMsg::Output { surface_id: surface_id.clone(), bytes }).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, // surface closed + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + + async fn req(stream: &mut UnixStream, id: u64, cmd: Cmd) -> Envelope { + write_frame(stream, &Envelope::Req { id, cmd }).await.unwrap(); + // Read until we see the matching res (skip interleaved evts). + loop { + let env = read_frame(stream).await.unwrap().unwrap(); + if let Envelope::Res { id: rid, .. } = &env { + if *rid == id { return env; } + } + } + } + + #[tokio::test] + async fn open_new_surface_attach_streams_output() { + let dir = tempdir_path(); + let sock = dir.join("sock"); + let sock_for_task = sock.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task).await; }); + wait_for_socket(&sock).await; + + let mut s = UnixStream::connect(&sock).await.unwrap(); + + let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; + let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + + let r = req(&mut s, 2, Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws), + command: Some("/bin/sh".into()), + args: vec!["-c".into(), "printf STREAM_OK; sleep 0.5".into()], + cols: 80, rows: 24, + }).await; + let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); + let surface_id = spacesh_proto::SurfaceId(sid); + + let _ = req(&mut s, 3, Cmd::Attach { surface_id: surface_id.clone() }).await; + + // Now read frames looking for an Output evt containing STREAM_OK. + let mut got = String::new(); + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + if let Ok(Ok(Some(Envelope::Evt(Evt::Output { bytes, .. })))) = + tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut s)).await { + got.push_str(&String::from_utf8_lossy(&bytes)); + if got.contains("STREAM_OK") { break; } + } + } + assert!(got.contains("STREAM_OK"), "got: {got:?}"); + } + + #[tokio::test] + async fn unknown_surface_returns_not_found() { + let dir = tempdir_path(); + let sock = dir.join("sock"); + let sock_for_task = sock.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task).await; }); + wait_for_socket(&sock).await; + let mut s = UnixStream::connect(&sock).await.unwrap(); + let r = req(&mut s, 1, Cmd::Input { + surface_id: spacesh_proto::SurfaceId("s_nope".into()), + bytes: base64::engine::general_purpose::STANDARD.encode(b"x"), + }).await; + match r { + Envelope::Res { ok, error, .. } => { + assert!(!ok); + assert_eq!(error.unwrap().code, "NOT_FOUND"); + } + _ => panic!(), + } + } + + fn res_data(env: &Envelope) -> &serde_json::Value { + match env { Envelope::Res { data, .. } => data, _ => panic!("not a res") } + } + + fn tempdir_path() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + p.push(format!("spaceshd-test-{n}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + async fn wait_for_socket(sock: &Path) { + for _ in 0..100 { + if UnixStream::connect(sock).await.is_ok() { return; } + tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + } + panic!("socket never came up"); + } +} +``` + +Add `tempfile`-free temp dirs as above (no new dependency). Note `Cmd::Shutdown` calls `std::process::exit` — do **not** exercise it in tests (it would kill the test runner). It is covered by manual verification. + +- [ ] **Step 2: Wire the module** + +`crates/spaceshd/src/main.rs`: +```rust +mod lifecycle; +mod registry; +mod server; +mod surface; + +fn main() { + println!("spaceshd skeleton"); +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p spaceshd server` +Expected: PASS (2 tests). The streaming test depends on the output bridge subscribing before the child writes; `sleep 0.5` in the script gives margin. + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/server.rs crates/spaceshd/src/main.rs +git commit -m "feat(daemon): socket server with router task, command dispatch, event fan-out (M0)" +``` + +--- + +### Task 8: Daemon entrypoint with single-instance startup + +**Files:** +- Modify: `crates/spaceshd/src/main.rs` + +- [ ] **Step 1: Write the entrypoint** + +`crates/spaceshd/src/main.rs`: +```rust +mod launchd; +mod lifecycle; +mod registry; +mod server; +mod surface; + +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + let arg = std::env::args().nth(1); + match arg.as_deref() { + Some("install-agent") => { + launchd::install_agent()?; + println!("launchd agent installed"); + Ok(()) + } + Some("--help") | Some("-h") => { + println!("spaceshd [install-agent]"); + Ok(()) + } + _ => run_daemon().await, + } +} + +async fn run_daemon() -> Result<()> { + let Some(_lock) = lifecycle::acquire_instance_lock()? else { + eprintln!("another spaceshd is already running"); + return Ok(()); + }; + lifecycle::clear_stale_socket()?; + let sock = lifecycle::socket_path()?; + eprintln!("spaceshd listening on {}", sock.display()); + server::serve(&sock).await +} +``` + +(`launchd::install_agent` is created in Task 16; to keep Task 8 compiling on its own, add a temporary stub now and replace it in Task 16.) + +Temporary stub — `crates/spaceshd/src/launchd.rs`: +```rust +use anyhow::Result; +pub fn install_agent() -> Result<()> { + anyhow::bail!("install-agent implemented in Task 16") +} +``` + +- [ ] **Step 2: Build and smoke-run the daemon manually** + +Run: `cargo build -p spaceshd` +Expected: PASS. + +Manual smoke test (own terminal): +```bash +cargo run -p spaceshd & +sleep 1 +ls -l ~/.spacesh/sock # socket exists +kill %1 +rm -f ~/.spacesh/sock +``` +Expected: socket file present while running. + +- [ ] **Step 3: Commit** + +```bash +git add crates/spaceshd/src/main.rs crates/spaceshd/src/launchd.rs +git commit -m "feat(daemon): entrypoint with single-instance lock and lazy socket bind" +``` + +--- + +## Phase 4 — Tauri app (M0: bytes flying in the GUI) + +### Task 9: Tauri scaffold + bridge state + +**Files:** +- Create: `app/package.json`, `app/vite.config.ts`, `app/index.html`, `app/tsconfig.json` +- Create: `app/src-tauri/Cargo.toml`, `app/src-tauri/tauri.conf.json`, `app/src-tauri/build.rs` +- Create: `app/src-tauri/src/main.rs`, `app/src-tauri/src/lib.rs` + +This task scaffolds the app and proves it launches. The bridge logic is Task 10's `bridge.rs`. Use the official scaffolder if preferred (`npm create tauri-app@latest`), but the files below are the minimum that integrates with our crates. + +- [ ] **Step 1: Frontend manifest** + +`app/package.json`: +```json +{ + "name": "spacesh-app", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-webgl": "^0.18.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.0", + "vite": "^5.4.0" + } +} +``` + +`app/vite.config.ts`: +```ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { port: 1420, strictPort: true }, +}); +``` + +`app/index.html`: +```html + + + + + spacesh + + + +
+ + + +``` + +`app/tsconfig.json`: +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "noEmit": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 2: Tauri Rust side manifest and config** + +`app/src-tauri/Cargo.toml`: +```toml +[package] +name = "spacesh-app" +version = "0.1.0" +edition = "2021" + +[lib] +name = "spacesh_app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +spacesh-proto = { path = "../../crates/spacesh-proto" } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +anyhow = "1" +dirs = "5" +``` + +`app/src-tauri/build.rs`: +```rust +fn main() { + tauri_build::build() +} +``` + +`app/src-tauri/tauri.conf.json`: +```json +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "spacesh", + "version": "0.1.0", + "identifier": "xyz.spacesh.app", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:1420", + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build" + }, + "app": { + "windows": [{ "title": "spacesh", "width": 1100, "height": 720 }], + "security": { "csp": null } + } +} +``` + +- [ ] **Step 3: Minimal Rust entry that launches a window** + +`app/src-tauri/src/main.rs`: +```rust +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + spacesh_app_lib::run(); +} +``` + +`app/src-tauri/src/lib.rs` (Task 9 version — expanded in Task 10): +```rust +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .run(tauri::generate_context!()) + .expect("error while running spacesh"); +} +``` + +- [ ] **Step 4: Minimal React entry** + +`app/src/main.tsx`: +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + +
+ spacesh — scaffold OK +
+
+); +``` + +- [ ] **Step 5: Install and launch** + +Run: +```bash +cd app && npm install && npm run tauri dev +``` +Expected: a window opens showing "spacesh — scaffold OK". Close it. + +- [ ] **Step 6: Commit** + +```bash +git add app/ +git commit -m "chore(app): scaffold tauri 2 + react + vite, window launches" +``` + +--- + +### Task 10: Bridge (UDS ↔ webview) + React terminal + +**Files:** +- Create: `app/src-tauri/src/bridge.rs` +- Modify: `app/src-tauri/src/lib.rs` +- Create: `app/src/socketBridge.ts`, `app/src/TerminalView.tsx`, `app/src/SurfaceList.tsx`, `app/src/App.tsx` +- Modify: `app/src/main.tsx` + +Bridge design (scheme B): one persistent UDS connection. A writer task owns the write half; a reader task demuxes frames — `Res` go to a pending-request map (oneshot per `id`), `Evt::Output` go to the registered per-surface `Channel`, other `Evt` are `emit`ted to the webview. The daemon must be auto-spawned if the socket is absent. + +- [ ] **Step 1: Implement the bridge** + +`app/src-tauri/src/bridge.rs`: +```rust +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use base64::Engine; +use serde_json::Value; +use spacesh_proto::codec::{read_frame, write_frame}; +use spacesh_proto::{Cmd, Envelope, Evt, SurfaceId}; +use tauri::ipc::Channel; +use tauri::{AppHandle, Emitter}; +use tokio::net::UnixStream; +use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::sync::{mpsc, oneshot, Mutex}; + +pub struct Bridge { + next_id: AtomicU64, + /// Outbound frames to the daemon. + tx: mpsc::Sender, + /// Pending request id → reply slot. + pending: Arc>>>, + /// surface id → output channel into the webview. + out_channels: Arc>>>>, +} + +fn socket_path() -> Result { + Ok(dirs::home_dir().context("no home")?.join(".spacesh").join("sock")) +} + +async fn ensure_daemon(sock: &PathBuf) -> Result { + if let Ok(s) = UnixStream::connect(sock).await { + return Ok(s); + } + // Lazy start: spawn the daemon binary, then poll for the socket. + let exe = std::env::current_exe()?; + let daemon = exe.with_file_name("spaceshd"); + let _ = std::process::Command::new(daemon).spawn(); + for _ in 0..100 { + if let Ok(s) = UnixStream::connect(sock).await { + return Ok(s); + } + tokio::time::sleep(tokio::time::Duration::from_millis(30)).await; + } + anyhow::bail!("daemon did not come up") +} + +impl Bridge { + pub async fn connect(app: AppHandle) -> Result { + let sock = socket_path()?; + let stream = ensure_daemon(&sock).await?; + let (read_half, write_half) = stream.into_split(); + + let (tx, rx) = mpsc::channel::(256); + let pending: Arc>>> = Arc::default(); + let out_channels: Arc>>>> = Arc::default(); + + spawn_writer(write_half, rx); + spawn_reader(read_half, app, pending.clone(), out_channels.clone()); + + Ok(Self { next_id: AtomicU64::new(1), tx, pending, out_channels }) + } + + pub async fn request(&self, cmd: Cmd) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (reply_tx, reply_rx) = oneshot::channel(); + self.pending.lock().await.insert(id, reply_tx); + self.tx.send(Envelope::Req { id, cmd }).await?; + Ok(reply_rx.await?) + } + + pub async fn register_output(&self, surface_id: String, channel: Channel>) { + self.out_channels.lock().await.insert(surface_id, channel); + } + + pub async fn unregister_output(&self, surface_id: &str) { + self.out_channels.lock().await.remove(surface_id); + } +} + +fn spawn_writer(mut write_half: OwnedWriteHalf, mut rx: mpsc::Receiver) { + tokio::spawn(async move { + while let Some(env) = rx.recv().await { + if write_frame(&mut write_half, &env).await.is_err() { + break; + } + } + }); +} + +fn spawn_reader( + mut read_half: OwnedReadHalf, + app: AppHandle, + pending: Arc>>>, + out_channels: Arc>>>>, +) { + tokio::spawn(async move { + loop { + match read_frame(&mut read_half).await { + Ok(Some(env)) => match env { + Envelope::Res { id, .. } => { + if let Some(slot) = pending.lock().await.remove(&id) { + let _ = slot.send(env); + } + } + Envelope::Evt(Evt::Output { surface_id, bytes }) => { + if let Some(ch) = out_channels.lock().await.get(&surface_id.0) { + let _ = ch.send(bytes); + } + } + Envelope::Evt(other) => { + // exit / surface_created / surface_closed → emit to webview. + let _ = app.emit("spacesh:evt", &other); + } + Envelope::Req { .. } => {} + }, + Ok(None) | Err(_) => break, + } + } + }); +} + +// ---- Tauri commands ---- + +type BridgeState<'a> = tauri::State<'a, Bridge>; + +fn data_of(env: Envelope) -> Result { + match env { + Envelope::Res { ok: true, data, .. } => Ok(data), + Envelope::Res { ok: false, error, .. } => { + Err(error.map(|e| format!("{}: {}", e.code, e.msg)).unwrap_or_else(|| "error".into())) + } + _ => Err("unexpected reply".into()), + } +} + +#[tauri::command] +pub async fn open(state: BridgeState<'_>, path: String) -> Result { + data_of(state.request(Cmd::Open { path }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn new_surface( + state: BridgeState<'_>, + workspace_id: String, + command: Option, + args: Vec, + cols: u16, + rows: u16, +) -> Result { + let cmd = Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(workspace_id), + command, + args, + cols, + rows, + }; + data_of(state.request(cmd).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn input(state: BridgeState<'_>, surface_id: String, data: Vec) -> Result { + let b64 = base64::engine::general_purpose::STANDARD.encode(&data); + data_of(state.request(Cmd::Input { surface_id: SurfaceId(surface_id), bytes: b64 }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn resize(state: BridgeState<'_>, surface_id: String, cols: u16, rows: u16) -> Result { + data_of(state.request(Cmd::Resize { surface_id: SurfaceId(surface_id), cols, rows }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn attach(state: BridgeState<'_>, surface_id: String, on_output: Channel>) -> Result { + state.register_output(surface_id.clone(), on_output).await; + data_of(state.request(Cmd::Attach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn detach(state: BridgeState<'_>, surface_id: String) -> Result { + state.unregister_output(&surface_id).await; + data_of(state.request(Cmd::Detach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn status(state: BridgeState<'_>) -> Result { + data_of(state.request(Cmd::Status).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn close_surface(state: BridgeState<'_>, surface_id: String) -> Result { + data_of(state.request(Cmd::Close { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) +} +``` + +- [ ] **Step 2: Wire the bridge into the builder** + +`app/src-tauri/src/lib.rs`: +```rust +mod bridge; + +use tauri::Manager; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .setup(|app| { + let handle = app.handle().clone(); + // Connect the bridge on a tokio runtime, then manage it. + tauri::async_runtime::block_on(async move { + let bridge = bridge::Bridge::connect(handle.clone()) + .await + .expect("failed to connect to spaceshd"); + handle.manage(bridge); + }); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + bridge::open, + bridge::new_surface, + bridge::input, + bridge::resize, + bridge::attach, + bridge::detach, + bridge::status, + bridge::close_surface, + ]) + .run(tauri::generate_context!()) + .expect("error while running spacesh"); +} +``` + +- [ ] **Step 3: Frontend socket bridge** + +`app/src/socketBridge.ts`: +```ts +import { invoke, Channel } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +export interface WorkspaceStatus { + workspace_id: string; + path: string; + surfaces: string[]; +} + +export async function openWorkspace(path: string): Promise { + const data = await invoke<{ workspace_id: string }>("open", { path }); + return data.workspace_id; +} + +export async function newSurface( + workspaceId: string, + cols: number, + rows: number, + command?: string, + args: string[] = [] +): Promise { + const data = await invoke<{ surface_id: string }>("new_surface", { + workspaceId, + command: command ?? null, + args, + cols, + rows, + }); + return data.surface_id; +} + +export async function sendInput(surfaceId: string, data: Uint8Array): Promise { + await invoke("input", { surfaceId, data: Array.from(data) }); +} + +export async function resizeSurface(surfaceId: string, cols: number, rows: number): Promise { + await invoke("resize", { surfaceId, cols, rows }); +} + +export interface AttachResult { + snapshot: string; + cols: number; + rows: number; +} + +export async function attachSurface( + surfaceId: string, + onOutput: (bytes: Uint8Array) => void +): Promise { + const channel = new Channel(); + channel.onmessage = (msg) => onOutput(new Uint8Array(msg)); + return await invoke("attach", { surfaceId, onOutput: channel }); +} + +export async function detachSurface(surfaceId: string): Promise { + await invoke("detach", { surfaceId }); +} + +export async function getStatus(): Promise { + const data = await invoke<{ workspaces: WorkspaceStatus[] }>("status"); + return data.workspaces; +} + +export type DaemonEvt = + | { evt: "exit"; data: { surface_id: string; code: number } } + | { evt: "surface_created"; data: { surface_id: string; workspace_id: string } } + | { evt: "surface_closed"; data: { surface_id: string } }; + +export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> { + return listen("spacesh:evt", (e) => handler(e.payload)); +} +``` + +- [ ] **Step 4: TerminalView** + +`app/src/TerminalView.tsx`: +```tsx +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +export function TerminalView({ surfaceId }: { surfaceId: string }) { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) return; + const term = new Terminal({ fontFamily: "monospace", fontSize: 13, convertEol: false }); + try { + term.loadAddon(new WebglAddon()); + } catch { + // webgl unavailable → fall back to canvas/dom renderer silently + } + term.open(ref.current); + + // Input → daemon. + const inputDisposable = term.onData((data) => { + void sendInput(surfaceId, encoder.encode(data)); + }); + + let disposed = false; + + // Attach: fresh xterm instance, write snapshot, then stream live output. + void attachSurface(surfaceId, (bytes) => { + if (!disposed) term.write(decoder.decode(bytes)); + }).then((res) => { + if (disposed) return; + if (res.snapshot) term.write(res.snapshot); + if (res.cols && res.rows) { + term.resize(res.cols, res.rows); + void resizeSurface(surfaceId, res.cols, res.rows); + } + }); + + return () => { + disposed = true; + inputDisposable.dispose(); + void detachSurface(surfaceId); + term.dispose(); + }; + }, [surfaceId]); + + return
; +} +``` + +- [ ] **Step 5: SurfaceList** + +`app/src/SurfaceList.tsx`: +```tsx +export function SurfaceList({ + surfaces, + active, + onSelect, +}: { + surfaces: string[]; + active: string | null; + onSelect: (id: string) => void; +}) { + return ( +
+
SURFACES
+ {surfaces.map((id) => ( +
onSelect(id)} + style={{ + padding: "4px 6px", + cursor: "pointer", + borderRadius: 4, + background: id === active ? "#333" : "transparent", + fontFamily: "monospace", + fontSize: 12, + }} + > + {id} +
+ ))} +
+ ); +} +``` + +- [ ] **Step 6: App wiring** + +`app/src/App.tsx`: +```tsx +import { useEffect, useState } from "react"; +import { TerminalView } from "./TerminalView"; +import { SurfaceList } from "./SurfaceList"; +import { openWorkspace, newSurface, getStatus, onDaemonEvent } from "./socketBridge"; + +export function App() { + const [surfaces, setSurfaces] = useState([]); + const [active, setActive] = useState(null); + const [workspaceId, setWorkspaceId] = useState(null); + + useEffect(() => { + void (async () => { + const ws = await getStatus(); + const flat = ws.flatMap((w) => w.surfaces); + setSurfaces(flat); + if (flat.length) setActive(flat[0]); + })(); + + const unlisten = onDaemonEvent((evt) => { + if (evt.evt === "surface_created") { + setSurfaces((s) => [...s, evt.data.surface_id]); + } else if (evt.evt === "surface_closed" || evt.evt === "exit") { + // exit leaves the surface visible; surface_closed removes it. + if (evt.evt === "surface_closed") { + setSurfaces((s) => s.filter((id) => id !== evt.data.surface_id)); + } + } + }); + return () => { + void unlisten.then((f) => f()); + }; + }, []); + + async function handleNewSurface() { + let ws = workspaceId; + if (!ws) { + ws = await openWorkspace("."); + setWorkspaceId(ws); + } + const id = await newSurface(ws, 80, 24); + setActive(id); + } + + return ( +
+
+ + +
+
+ {active ? :
no surface
} +
+
+ ); +} +``` + +`app/src/main.tsx`: +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "@xterm/xterm/css/xterm.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); +``` + +- [ ] **Step 7: Manual end-to-end verification (M0)** + +Run: +```bash +cd app && npm run tauri dev +``` +Steps: click "+ surface" → a shell appears in the terminal → type `echo hi` → see output. This proves bytes fly GUI↔daemon↔PTY. +Expected: interactive shell works. + +- [ ] **Step 8: Commit** + +```bash +git add app/ +git commit -m "feat(app): UDS bridge (channel/invoke/emit) + xterm.js terminal, M0 e2e works" +``` + +--- + +## Phase 5 — spacesh-core (grid + snapshot) + +### Task 11: GridSurface — feed bytes into an alacritty Term + +**Files:** +- Create: `crates/spacesh-core/src/grid.rs` +- Modify: `crates/spacesh-core/src/lib.rs` (enable `mod grid;`) +- Test: inline `#[cfg(test)]` in `grid.rs` + +> **API pin:** `alacritty_terminal = "0.25"`. The grid/parser API is version-sensitive. The code below targets 0.25 (`Term::new(config, &dims, listener)`, `vte::ansi::Processor::advance(&mut term, bytes)`, `term.grid().display_iter()`, `Cell { c, fg, bg, flags }`, `term.grid().cursor.point`). If `cargo build` reports a signature mismatch, run `cargo doc -p alacritty_terminal --open` and adjust the three call sites (constructor, `advance`, cell iteration) — the wrapper isolates them. + +- [ ] **Step 1: Write the failing test** + +`crates/spacesh-core/src/grid.rs`: +```rust +use alacritty_terminal::event::VoidListener; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line, Point}; +use alacritty_terminal::term::{Config, Term}; +use alacritty_terminal::vte::ansi::Processor; + +/// Fixed-size terminal dimensions for the daemon-side grid. +#[derive(Clone, Copy)] +pub struct GridSize { + pub cols: usize, + pub lines: usize, +} + +impl Dimensions for GridSize { + fn total_lines(&self) -> usize { + self.lines + } + fn screen_lines(&self) -> usize { + self.lines + } + fn columns(&self) -> usize { + self.cols + } +} + +/// Owns an alacritty terminal model and feeds raw PTY bytes into it. +pub struct GridSurface { + term: Term, + parser: Processor, + size: GridSize, +} + +impl GridSurface { + pub fn new(cols: u16, rows: u16) -> Self { + let size = GridSize { cols: cols as usize, lines: rows as usize }; + let term = Term::new(Config::default(), &size, VoidListener); + Self { term, parser: Processor::new(), size } + } + + pub fn feed(&mut self, bytes: &[u8]) { + self.parser.advance(&mut self.term, bytes); + } + + pub fn resize(&mut self, cols: u16, rows: u16) { + self.size = GridSize { cols: cols as usize, lines: rows as usize }; + self.term.resize(self.size); + } + + pub fn size(&self) -> GridSize { + self.size + } + + /// Read the visible character at (line, col) — used by tests and the snapshot writer. + pub fn char_at(&self, line: usize, col: usize) -> char { + let point = Point::new(Line(line as i32), Column(col)); + self.term.grid()[point].c + } + + pub fn term(&self) -> &Term { + &self.term + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feeding_plain_text_lands_in_the_grid() { + let mut g = GridSurface::new(20, 5); + g.feed(b"hello"); + assert_eq!(g.char_at(0, 0), 'h'); + assert_eq!(g.char_at(0, 4), 'o'); + } + + #[test] + fn carriage_return_and_newline_move_the_cursor() { + let mut g = GridSurface::new(20, 5); + g.feed(b"ab\r\ncd"); + assert_eq!(g.char_at(0, 0), 'a'); + assert_eq!(g.char_at(1, 0), 'c'); + } +} +``` + +- [ ] **Step 2: Enable the module** + +`crates/spacesh-core/src/lib.rs`: +```rust +pub mod grid; +pub mod snapshot; + +pub use grid::GridSurface; +pub use snapshot::Snapshot; +``` + +(`snapshot` is added in Task 12; if building Task 11 alone, temporarily comment the `snapshot` lines and restore them in Task 12.) + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p spacesh-core grid` +Expected: PASS (2 tests). Fix the three pinned call sites if the API differs (see API pin note). + +- [ ] **Step 4: Commit** + +```bash +git add crates/spacesh-core/src/grid.rs crates/spacesh-core/src/lib.rs +git commit -m "feat(core): GridSurface feeding PTY bytes into alacritty term" +``` + +--- + +### Task 12: snapshot_ansi — serialize the grid to an ANSI dump + +**Files:** +- Create: `crates/spacesh-core/src/snapshot.rs` +- Test: inline `#[cfg(test)]` in `snapshot.rs` + +- [ ] **Step 1: Write the failing test** + +`crates/spacesh-core/src/snapshot.rs`: +```rust +use serde::Serialize; +use alacritty_terminal::index::Point; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::vte::ansi::Color; +use crate::grid::GridSurface; + +/// Serializable snapshot returned by `attach`. +#[derive(Debug, Clone, Serialize)] +pub struct Snapshot { + /// ANSI byte dump suitable for `xterm.write()`. + pub ansi: String, + pub cols: u16, + pub rows: u16, + /// 1-based cursor position. + pub cursor_row: u16, + pub cursor_col: u16, +} + +fn sgr_for_color(c: Color, foreground: bool) -> String { + let base = if foreground { 38 } else { 48 }; + match c { + Color::Named(named) => { + // Map common named colors to SGR; default fg/bg reset for the rest. + use alacritty_terminal::vte::ansi::NamedColor; + let code = match named { + NamedColor::Black => Some(if foreground { 30 } else { 40 }), + NamedColor::Red => Some(if foreground { 31 } else { 41 }), + NamedColor::Green => Some(if foreground { 32 } else { 42 }), + NamedColor::Yellow => Some(if foreground { 33 } else { 43 }), + NamedColor::Blue => Some(if foreground { 34 } else { 44 }), + NamedColor::Magenta => Some(if foreground { 35 } else { 45 }), + NamedColor::Cyan => Some(if foreground { 36 } else { 46 }), + NamedColor::White => Some(if foreground { 37 } else { 47 }), + NamedColor::BrightBlack => Some(if foreground { 90 } else { 100 }), + NamedColor::BrightRed => Some(if foreground { 91 } else { 101 }), + NamedColor::BrightGreen => Some(if foreground { 92 } else { 102 }), + NamedColor::BrightYellow => Some(if foreground { 93 } else { 103 }), + NamedColor::BrightBlue => Some(if foreground { 94 } else { 104 }), + NamedColor::BrightMagenta => Some(if foreground { 95 } else { 105 }), + NamedColor::BrightCyan => Some(if foreground { 96 } else { 106 }), + NamedColor::BrightWhite => Some(if foreground { 97 } else { 107 }), + _ => None, // Foreground/Background/Cursor etc. → use reset. + }; + match code { + Some(n) => format!("{n}"), + None => format!("{}", if foreground { 39 } else { 49 }), + } + } + Color::Indexed(i) => format!("{base};5;{i}"), + Color::Spec(rgb) => format!("{base};2;{};{};{}", rgb.r, rgb.g, rgb.b), + } +} + +/// Serialize the visible grid into an ANSI dump. +pub fn snapshot_ansi(g: &GridSurface) -> Snapshot { + let size = g.size(); + let term = g.term(); + let grid = term.grid(); + + let mut out = String::new(); + out.push_str("\x1b[2J\x1b[H"); // clear + home + + let cols = size.cols; + let lines = size.lines; + + // Track the last emitted attributes to avoid redundant SGR sequences. + let mut last: Option<(Color, Color, Flags)> = None; + + for line in 0..lines { + for col in 0..cols { + let point = Point::new(alacritty_terminal::index::Line(line as i32), alacritty_terminal::index::Column(col)); + let cell = &grid[point]; + let cur = (cell.fg, cell.bg, cell.flags); + if last != Some(cur) { + let mut codes: Vec = vec!["0".into()]; // reset, then re-apply + if cell.flags.contains(Flags::BOLD) { codes.push("1".into()); } + if cell.flags.contains(Flags::DIM) { codes.push("2".into()); } + if cell.flags.contains(Flags::ITALIC) { codes.push("3".into()); } + if cell.flags.contains(Flags::UNDERLINE) { codes.push("4".into()); } + if cell.flags.contains(Flags::INVERSE) { codes.push("7".into()); } + codes.push(sgr_for_color(cell.fg, true)); + codes.push(sgr_for_color(cell.bg, false)); + out.push_str(&format!("\x1b[{}m", codes.join(";"))); + last = Some(cur); + } + out.push(cell.c); + } + out.push_str("\r\n"); + } + out.push_str("\x1b[0m"); // reset attributes at end + + let cursor = grid.cursor.point; + let cursor_row = (cursor.line.0 as i64 + 1).clamp(1, lines as i64) as u16; + let cursor_col = (cursor.column.0 as i64 + 1).clamp(1, cols as i64) as u16; + out.push_str(&format!("\x1b[{cursor_row};{cursor_col}H")); + + Snapshot { + ansi: out, + cols: cols as u16, + rows: lines as u16, + cursor_row, + cursor_col, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_contains_fed_text_and_is_deterministic() { + let mut g = GridSurface::new(10, 3); + g.feed(b"hi"); + let a = snapshot_ansi(&g); + let b = snapshot_ansi(&g); + assert_eq!(a.ansi, b.ansi, "snapshot must be deterministic"); + assert!(a.ansi.contains("hi")); + assert!(a.ansi.starts_with("\x1b[2J\x1b[H")); + assert_eq!(a.cols, 10); + assert_eq!(a.rows, 3); + } + + #[test] + fn cursor_is_one_based_after_input() { + let mut g = GridSurface::new(10, 3); + g.feed(b"abc"); + let s = snapshot_ansi(&g); + // After 'abc' the cursor sits at column 4 (1-based) on row 1. + assert_eq!(s.cursor_row, 1); + assert_eq!(s.cursor_col, 4); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p spacesh-core` +Expected: PASS (grid + snapshot tests). If `Flags`, `Color`, or `NamedColor` paths differ in 0.25, adjust the imports per `cargo doc`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/spacesh-core/src/snapshot.rs crates/spacesh-core/src/lib.rs +git commit -m "feat(core): deterministic ANSI snapshot of the grid for reattach repaint" +``` + +--- + +## Phase 6 — spaceshd (M1: grid + attach snapshot) + +### Task 13: Integrate grid + coalescing into the surface actor; snapshot on attach + +**Files:** +- Modify: `crates/spaceshd/src/surface.rs` +- Test: inline `#[cfg(test)]` additions in `surface.rs` + +Changes: the actor now owns a `GridSurface`, feeds every output chunk into it, coalesces output before broadcasting (flush every ~6 ms or at 16 KiB), and answers a new `Snapshot` request by serializing the grid **in the same actor turn** as the subscription — guaranteeing the snapshot/stream ordering from the spec. + +- [ ] **Step 1: Extend the message enum and actor** + +Replace `crates/spaceshd/src/surface.rs` actor section with this (the test module from Task 5 stays; new tests appended in Step 2): +```rust +use spacesh_core::{snapshot::snapshot_ansi, GridSurface}; +use spacesh_core::snapshot::Snapshot; +use spacesh_proto::{SurfaceId, WorkspaceId}; +use spacesh_pty::PtyHandle; +use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio::time::{Duration, Instant}; + +const BROADCAST_CAP: usize = 1024; +const FLUSH_INTERVAL: Duration = Duration::from_millis(6); +const FLUSH_BYTES: usize = 16 * 1024; + +pub enum SurfaceMsg { + Input(Vec), + Resize { cols: u16, rows: u16 }, + Attach { reply: oneshot::Sender>> }, + /// Attach with snapshot: subscribe AND capture the grid in one actor turn. + AttachSnapshot { reply: oneshot::Sender<(Snapshot, broadcast::Receiver>)> }, + Close, +} + +pub struct SurfaceHandle { + pub id: SurfaceId, + pub workspace_id: WorkspaceId, + pub tx: mpsc::Sender, +} + +pub fn spawn_surface( + id: SurfaceId, + workspace_id: WorkspaceId, + mut pty: PtyHandle, + cols: u16, + rows: u16, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, +) -> SurfaceHandle { + let (tx, mut rx) = mpsc::channel::(64); + let (bcast, _) = broadcast::channel::>(BROADCAST_CAP); + let actor_id = id.clone(); + + tokio::spawn(async move { + let mut grid = GridSurface::new(cols, rows); + let mut pending: Vec = Vec::with_capacity(FLUSH_BYTES); + let mut flush_deadline: Option = None; + + // Helper closure can't borrow across awaits cleanly; inline the flush logic. + loop { + // Copy the deadline into an owned local so the timer future doesn't + // hold a borrow of `flush_deadline` across the select! (other arms mutate it). + let next_flush = flush_deadline; + let timer = async move { + match next_flush { + Some(d) => tokio::time::sleep_until(d).await, + None => std::future::pending::<()>().await, + } + }; + + tokio::select! { + msg = rx.recv() => { + match msg { + Some(SurfaceMsg::Input(bytes)) => { let _ = pty.write_input(&bytes); } + Some(SurfaceMsg::Resize { cols, rows }) => { + grid.resize(cols, rows); + let _ = pty.resize(cols, rows); + } + Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); } + Some(SurfaceMsg::AttachSnapshot { reply }) => { + // Flush pending into the grid first so the snapshot is current, + // but DO NOT broadcast here; subscribe before any further output. + if !pending.is_empty() { + grid.feed(&pending); + let _ = bcast.send(std::mem::take(&mut pending)); + flush_deadline = None; + } + let sub = bcast.subscribe(); + let snap = snapshot_ansi(&grid); + let _ = reply.send((snap, sub)); + } + Some(SurfaceMsg::Close) | None => { pty.kill(); break; } + } + } + chunk = pty.output.recv() => { + match chunk { + Some(bytes) => { + pending.extend_from_slice(&bytes); + if flush_deadline.is_none() { + flush_deadline = Some(Instant::now() + FLUSH_INTERVAL); + } + if pending.len() >= FLUSH_BYTES { + grid.feed(&pending); + let _ = bcast.send(std::mem::take(&mut pending)); + flush_deadline = None; + } + } + None => { + // Final flush on EOF. + if !pending.is_empty() { + grid.feed(&pending); + let _ = bcast.send(std::mem::take(&mut pending)); + } + break; + } + } + } + _ = timer => { + if !pending.is_empty() { + grid.feed(&pending); + let _ = bcast.send(std::mem::take(&mut pending)); + } + flush_deadline = None; + } + } + } + let code = pty.wait(); + let _ = exit_tx.send((actor_id, code)); + }); + + SurfaceHandle { id, workspace_id, tx } +} +``` + +**Caller update:** `spawn_surface` now takes `cols, rows`. Update `crates/spaceshd/src/server.rs` `Cmd::NewSurface` to pass them: +```rust +let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, cols, rows, exit_tx.clone()); +``` + +**Attach update (server):** replace the M0 `Cmd::Attach` arm in `server.rs` with a snapshot-backed one: +```rust +Cmd::Attach { surface_id } => { + if let Some(s) = reg.surface(&surface_id) { + let (reply_tx, reply_rx) = oneshot::channel(); + if s.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.is_ok() { + if let Ok((snap, _sub)) = reply_rx.await { + subs.entry(surface_id.clone()).or_default().push(client); + let _ = out.send(ok(id, serde_json::json!({ + "snapshot": snap.ansi, + "cols": snap.cols, + "rows": snap.rows, + "cursor_row": snap.cursor_row, + "cursor_col": snap.cursor_col, + }))).await; + return; + } + } + let _ = out.send(err(id, "INTERNAL", "attach failed")).await; + } else { + let _ = out.send(err(id, "NOT_FOUND", "surface")).await; + } +} +``` + +Note: the output bridge (Task 7's `spawn_output_bridge`) still subscribes via `SurfaceMsg::Attach` and pumps **all** broadcast traffic into the router; the router fans only to clients in `subs`. The `AttachSnapshot` subscription receiver `_sub` is dropped because the client receives output through the router/bridge path, not directly — the snapshot's role is the one-shot repaint, and the bridge guarantees subsequent output flows. This keeps a single fan-out path while preserving the ordering guarantee (snapshot taken in the same actor turn the bridge's broadcast continues from). + +- [ ] **Step 2: Update Task 5 tests for the new signature and add the snapshot test** + +In `surface.rs` tests, update both `spawn_surface(...)` calls to pass `80, 24`: +```rust +let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx); +``` +```rust +let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx); +``` + +Append a new test: +```rust +#[tokio::test] +async fn attach_snapshot_reflects_prior_output() { + let pty = PtyHandle::spawn(spec("printf SNAPME; sleep 0.5")).unwrap(); + let (exit_tx, _exit_rx) = mpsc::unbounded_channel(); + let handle = spawn_surface(SurfaceId("s_s".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx); + + // Give the child time to write and the actor time to flush into the grid. + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + + let (reply_tx, reply_rx) = oneshot::channel(); + handle.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.unwrap(); + let (snap, _sub) = reply_rx.await.unwrap(); + assert!(snap.ansi.contains("SNAPME"), "snapshot: {:?}", snap.ansi); +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p spaceshd` +Expected: PASS (surface + registry + lifecycle + server tests). + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/surface.rs crates/spaceshd/src/server.rs +git commit -m "feat(daemon): grid feed + output coalescing + snapshot-on-attach (M1)" +``` + +--- + +### Task 14: Reattach integration test (same screen after reconnect) + +**Files:** +- Modify: `crates/spaceshd/src/server.rs` (append a test) + +- [ ] **Step 1: Write the test** + +Append to the `tests` module in `server.rs`: +```rust +#[tokio::test] +async fn reattach_returns_snapshot_with_prior_output() { + let dir = tempdir_path(); + let sock = dir.join("sock"); + let sock_for_task = sock.clone(); + tokio::spawn(async move { let _ = serve(&sock_for_task).await; }); + wait_for_socket(&sock).await; + + // First client: open, new surface that prints a marker, attach, then disconnect. + let surface_id; + { + let mut s = UnixStream::connect(&sock).await.unwrap(); + let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; + let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); + let r = req(&mut s, 2, Cmd::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws), + command: Some("/bin/sh".into()), + args: vec!["-c".into(), "printf REPAINT_ME; sleep 2".into()], + cols: 80, rows: 24, + }).await; + surface_id = spacesh_proto::SurfaceId(res_data(&r)["surface_id"].as_str().unwrap().to_string()); + // Give the actor time to flush output into the grid. + tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; + // disconnect by dropping `s` + } + + // Second client: attach to the same surface, expect snapshot to contain the marker. + let mut s2 = UnixStream::connect(&sock).await.unwrap(); + let r = req(&mut s2, 1, Cmd::Attach { surface_id: surface_id.clone() }).await; + let snap = res_data(&r)["snapshot"].as_str().unwrap(); + assert!(snap.contains("REPAINT_ME"), "snapshot was: {snap:?}"); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p spaceshd server` +Expected: PASS (3 server tests including reattach). + +- [ ] **Step 3: Commit** + +```bash +git add crates/spaceshd/src/server.rs +git commit -m "test(daemon): reattach after disconnect repaints prior output from snapshot" +``` + +--- + +## Phase 7 — app (M1 reattach in the GUI) + +### Task 15: Reattach flow + reconnect handling in the bridge + +**Files:** +- Modify: `app/src-tauri/src/bridge.rs` (reconnect on EOF) +- Modify: `app/src/TerminalView.tsx` (already writes snapshot — verify cursor handling) + +The TerminalView from Task 10 already: creates a fresh xterm per surface, writes `res.snapshot`, then streams. The remaining M1 piece is making the **bridge survive a daemon restart** is out of scope (daemon outlives GUI, not vice-versa) — but the GUI must survive its own reload, which it does because attach re-runs on mount. This task hardens reattach and adds a reconnect guard so a transient socket drop doesn't wedge the bridge. + +- [ ] **Step 1: Add a reconnect note + guard (no behavior change to the happy path)** + +In `app/src-tauri/src/bridge.rs`, change `spawn_reader`'s terminal branch to emit a disconnect event so the UI can re-attach: +```rust + Ok(None) | Err(_) => { + let _ = app.emit("spacesh:disconnected", ()); + break; + } +``` + +- [ ] **Step 2: Re-attach the active surface on reconnect in the front** + +In `app/src/App.tsx`, add inside the `useEffect`: +```tsx + const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { + // Force a remount of the active TerminalView by toggling the key. + setActive((cur) => cur); + void getStatus().then((ws) => { + const flat = ws.flatMap((w) => w.surfaces); + setSurfaces(flat); + }); + }); + return () => { + void unlisten.then((f) => f()); + void reconnect.then((f) => f()); + }; +``` + +Add to `app/src/socketBridge.ts`: +```ts +export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> { + return listen(name, () => handler()); +} +``` + +- [ ] **Step 3: Manual verification (M1 killer feature)** + +Run `cd app && npm run tauri dev`. Steps: +1. Click "+ surface", run a long command like `top`. +2. Close the app window (the daemon keeps running — verify `ls ~/.spacesh/sock` still works and `pgrep spaceshd` shows it alive). +3. Relaunch `npm run tauri dev`. +4. The surface is still listed; selecting it repaints the screen from the snapshot and `top` is still updating. + +Expected: agent (process) survived the GUI; screen restored on reattach. + +- [ ] **Step 4: Commit** + +```bash +git add app/ +git commit -m "feat(app): reattach repaint + disconnect guard (M1)" +``` + +--- + +## Phase 8 — launchd + +### Task 16: launchd user-agent install + +**Files:** +- Modify: `crates/spaceshd/src/launchd.rs` (replace the Task 8 stub) +- Test: inline `#[cfg(test)]` in `launchd.rs` + +- [ ] **Step 1: Write the failing test** + +`crates/spaceshd/src/launchd.rs`: +```rust +use anyhow::{Context, Result}; +use std::path::PathBuf; + +const LABEL: &str = "xyz.spacesh.daemon"; + +fn plist_path() -> Result { + let home = dirs::home_dir().context("no home")?; + Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist"))) +} + +/// Render the launchd plist. `run_at_load` defaults to false in this slice. +pub fn render_plist(exe: &str, run_at_load: bool) -> String { + format!( + r#" + + + + Label + {LABEL} + ProgramArguments + + {exe} + + KeepAlive + + RunAtLoad + <{run_at_load}/> + + +"#, + run_at_load = if run_at_load { "true" } else { "false" } + ) +} + +pub fn install_agent() -> Result<()> { + let exe = std::env::current_exe()?; + let plist = render_plist(&exe.to_string_lossy(), false); + let path = plist_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, plist)?; + // Load it (best-effort; ignore "already loaded"). + let _ = std::process::Command::new("launchctl") + .arg("load") + .arg(&path) + .status(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plist_has_label_keepalive_and_exe() { + let p = render_plist("/usr/local/bin/spaceshd", false); + assert!(p.contains("xyz.spacesh.daemon")); + assert!(p.contains("/usr/local/bin/spaceshd")); + assert!(p.contains("KeepAlive\n ")); + assert!(p.contains("RunAtLoad\n ")); + } + + #[test] + fn run_at_load_toggles() { + assert!(render_plist("x", true).contains("RunAtLoad\n ")); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p spaceshd launchd` +Expected: PASS (2 tests). (`install_agent` itself touches the real filesystem/launchctl — exercised manually, not in tests.) + +- [ ] **Step 3: Manual verification** + +Run: +```bash +cargo build -p spaceshd +./target/debug/spaceshd install-agent +launchctl list | grep spacesh # shows the agent +``` +Expected: the agent is registered; killing `spaceshd` (`pkill spaceshd`) results in launchd respawning it. + +Cleanup: +```bash +launchctl unload ~/Library/LaunchAgents/xyz.spacesh.daemon.plist +rm ~/Library/LaunchAgents/xyz.spacesh.daemon.plist +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/launchd.rs +git commit -m "feat(daemon): launchd user-agent install with KeepAlive" +``` + +--- + +## Definition of Done (slice acceptance) + +Run the full suite and the manual checks: + +- [ ] `cargo test` — all crate tests pass. +- [ ] `cargo build` — workspace + app build clean. +- [ ] **M0:** in the app, create a surface, type into it, see live output. Bytes fly GUI↔daemon↔PTY. +- [ ] **M1:** start a long-running process in a surface, kill the GUI, confirm `spaceshd` (and the child) stay alive, relaunch the GUI, reselect the surface, confirm the screen repaints from the snapshot and the process is still live. +- [ ] **launchd:** `spaceshd install-agent` registers the KeepAlive agent; `pkill spaceshd` triggers a respawn. + +--- + +## Notes for the implementer + +- **alacritty_terminal API drift:** Tasks 11–12 are the only place the crate's internal API is touched. If 0.25's signatures differ from the pinned code, fix the three isolated call sites (constructor, `Processor::advance`, cell/cursor access) — everything else depends on the `GridSurface`/`Snapshot` interface, not the crate. +- **Single fan-out path:** all output to clients flows daemon broadcast → `spawn_output_bridge` → router → per-client socket. The `AttachSnapshot` receiver is intentionally dropped; the snapshot is a one-shot repaint and the bridge carries the live stream. Do not add a second direct path or you will double-render. +- **Coalescing budget:** `FLUSH_INTERVAL = 6ms`, `FLUSH_BYTES = 16KiB` match the spec's §6.2 budget. Tune only with a profiler against a real build log. +- **Out of this slice:** statuses/hooks (M3), split tree + disk persistence (M2), CLI (M4), notifications/zoom/diff (M5), remote (M6). Do not pull them in. +``` diff --git a/DOCS/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md b/DOCS/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md new file mode 100644 index 0000000..f72f615 --- /dev/null +++ b/DOCS/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md @@ -0,0 +1,204 @@ +# spacesh M0+M1 — design spec + +> Срез реализации: **M0 (живой терминал через socket)** + **M1 (переживаемость и reattach)**. +> Базовая спецификация: `DOCS/MAIN.md` (v0.1). Этот документ сужает её до первого реализуемого среза. +> Дата: 2026-06-09. + +--- + +## 1. Скоуп среза + +**Входит:** +- Cargo workspace, крейты `spacesh-proto`, `spacesh-core`, `spacesh-pty`, `spaceshd`. +- Tauri 2 приложение `app/` с одной панелью `TerminalView` (xterm.js) и переключением между surfaces. +- Шина команд/событий поверх UDS (`~/.spacesh/sock`), субсет протокола (см. §4). +- Реальный PTY, батчированный поток вывода, ввод с клавиатуры. +- Грид в демоне (`alacritty_terminal`), снапшот→ANSI, `attach`/`detach`, репейнт после reattach. +- launchd user-agent с `KeepAlive`; ленивый старт; single-instance. + +**Зафиксированные решения (из брейншторма):** +- `new_surface` запускает **произвольную команду; дефолт = login shell** (`$SHELL`). `$SPACESH_SURFACE_ID` инжектируется в env панели. +- Переживаемость = **только live-reattach** (демон переживает GUI). Диск-персист раскладки и дерево сплитов — отложены в M2. +- GUI = **одна панель + переключение между surfaces** (несколько surfaces живут в демоне, GUI делает `attach` к одной). LayoutEngine/сплиты — M2. +- Мост GUI↔демон = **схема B**: Tauri `Channel` для `output`, `invoke` для команд, `emit` для редких событий. + +**Не входит (явно):** +- Статусы / `set_state` / хуки агентов / OSC 133 / fallback-паттерны — **M3**. +- Дерево сплитов, `move_surface`, `apply_preset`, `close_workspace`, диск-персист раскладки (`state.json`) — **M2**. +- `spacesh-cli` — **M4**. Нотификации Telegram/MAX, зум, поиск, diff-view — **M5**. Remote — **M6**. +- Поле `split?` у `new_surface`, скроллбэк в снапшоте. + +--- + +## 2. Архитектура и крейты + +``` +spacesh/ +├── Cargo.toml # workspace +├── crates/ +│ ├── spacesh-proto/ # типы Envelope/Cmd/Evt, serde, length-prefix framing (u32 BE + payload) +│ ├── spacesh-core/ # SurfaceId/WorkspaceId, грид (alacritty_terminal), снапшот→ANSI. NO I/O. +│ ├── spacesh-pty/ # spawn PTY (portable-pty), reader-loop, батчинг, resize, backpressure +│ └── spaceshd/ # демон: socket-сервер, реестр сессий, fan-out, attach/snapshot, launchd +└── app/ + ├── src-tauri/ # UDS-клиент + мост (Channel/invoke/emit), bridge.rs + └── src/ # React/TS: TerminalView, SurfaceList, socketBridge, App +``` + +| Крейт | Ответственность | Ключевые зависимости | Тестируемость | +|---|---|---|---| +| `spacesh-proto` | Протокол: типы + кадрирование. Чистый, шарится всеми. | serde, serde_json, tokio (codec), bytes | юнит: round-trip encode/decode, кадрирование | +| `spacesh-core` | Доменные id, грид-обёртка, снапшот→ANSI-дамп. Без I/O. | alacritty_terminal | юнит: feed bytes → детерминированный snapshot | +| `spacesh-pty` | PTY io, reader-loop, коалесцирование (4–8мс/16КБ), resize, backpressure | portable-pty, tokio, bytes | интеграц: spawn `echo`, читаем вывод | +| `spaceshd` | Реестр Surface, socket accept, корреляция req/res, fan-out evt, attach=snapshot+stream, launchd | tokio, spacesh-* | интеграц: connect→new_surface→input→output | +| `app/src-tauri` | Мост UDS↔webview по схеме B | tauri 2, spacesh-proto, tokio | ручная проверка | + +`spacesh-core` намеренно без I/O — грид и снапшот юнит-тестируемы фидом байтов. + +--- + +## 3. Мост GUI↔демон (схема B) + +`src-tauri` держит UDS-коннект к демону и разводит трафик по частоте: + +- **`output` (высокочастотный)** → `tauri::ipc::Channel` на surface. Типизированный стрим, дешевле общей IPC-шины, естественный backpressure на канал. JS получает чанки и пишет в `xterm.write()`. +- **Команды** (`open`/`new_surface`/`input`/`resize`/`attach`/`detach`/`focus`/`close`/`status`/`shutdown`) → `#[tauri::command]` + `invoke` из JS. Rust кадрирует req, ждёт res по `id`, возвращает в JS. +- **Редкие события** (`exit`/`surface_created`/`surface_closed`) → `app.emit(...)`, JS слушает. + +Прямой WebSocket JS→демон отклонён: ломает «один socket/UDS», добавляет транспорт и кадрирование в JS. + +--- + +## 4. Субсет протокола (M0+M1) + +Конверт из `DOCS/MAIN.md` §5.2 без изменений: `req`/`res` (корреляция по `id`) + `evt` (push, без id). Кадрирование: `u32` BE длина + JSON payload. + +### Команды + +| Команда | Args | Res | Назначение | +|---|---|---|---| +| `open` | `{ path }` | `{ workspace_id }` | открыть папку воркспейсом; повтор не плодит дубль | +| `new_surface` | `{ workspace_id, cmd?, args?, cols, rows }` | `{ surface_id }` | поднять PTY; пусто→`$SHELL`; cwd=папка воркспейса; `$SPACESH_SURFACE_ID` в env | +| `input` | `{ surface_id, bytes }` | `{}` | ввод (base64 в JSON) → PTY master | +| `resize` | `{ surface_id, cols, rows }` | `{}` | ресайз PTY → SIGWINCH | +| `attach` | `{ surface_id }` | `{ snapshot, cols, rows, cursor }` | подписка на поток + ANSI-снапшот для репейнта | +| `detach` | `{ surface_id }` | `{}` | отписка от потока | +| `focus` | `{ surface_id }` | `{}` | пометить активной (паритет с CLI позже) | +| `close` | `{ surface_id }` | `{}` | убить PTY, закрыть панель | +| `status` | `{}` | `{ workspaces[] }` | структура при старте GUI | +| `shutdown` | `{}` | `{}` | остановить демон | + +### События (push) + +| Событие | Data | Назначение | +|---|---|---| +| `output` | `{ surface_id, bytes }` | живой батчированный поток вывода PTY (едет каналом, схема B) | +| `exit` | `{ surface_id, code }` | процесс панели завершился | +| `surface_created` | `{ surface_id, workspace_id }` | появилась панель | +| `surface_closed` | `{ surface_id }` | панель закрыта | + +`attach` возвращает снапшот в `res`; дальше живой `output` приходит потоком. Порядок гарантирован актором (см. §5). + +--- + +## 5. Модель владения и потоки данных + +**Actor-per-surface.** Каждый Surface — отдельная tokio-задача, единолично владеющая PTY master, дочерним процессом, alacritty-гридом, набором подписчиков. Команды к surface — через `mpsc`; вывод — через `broadcast`. Single-task = единственный писатель в грид: гонок нет by design, и это же гарантирует корректный порядок attach. Общий `HashMap` под `RwLock` отклонён (конкуренция за грид, гонки на attach). + +``` +spaceshd +├── accept-loop # на коннект → client-task +├── client-task (на клиента) # читает кадры, корреляция id, маршрут команд +├── registry # SurfaceId → mpsc::Sender, WorkspaceId → meta +└── surface-actor (на surface) # select! { команды-mpsc | PTY-reader-rx | flush-таймер } + ├── owns: PtyMaster, Child, Term(grid), broadcast::Sender + └── PTY-reader-task → сырьё в актор +``` + +**Input:** client-task → `input` → registry → surface mpsc → write в PTY master. + +**Output:** PTY-reader читает → актор коалесцирует (флаш по таймеру 4–8мс **или** при накоплении 16КБ) → на флаше: (1) `term.feed(bytes)` — авторитетный грид; (2) `broadcast.send(bytes)` подписчикам → каждый подписчик в своей client-task кадрирует `output` evt → socket → src-tauri reader → Tauri Channel → `xterm.write()`. + +**Backpressure:** `broadcast` ограниченной ёмкости; отстающий подписчик получает `Lagged` и теряет промежуточные кадры, но грид всегда целый — следующий кадр сводит экран. Демон не растёт в памяти неограниченно. + +**Attach / reattach (ядро M1):** +``` +client → attach{surface_id} + surface-actor обрабатывает СИНХРОННО в своём loop: + 1. rx = broadcast.subscribe() // подписка ПЕРВОЙ + 2. snap = term.snapshot_ansi() // снимок грида в этой же точке + 3. res { snapshot: snap, cols, rows, cursor } + → rx далее получает ТОЛЬКО output, эмитнутый после snapshot +``` +Актор обрабатывает одно сообщение за раз → между subscribe и snapshot не вклинивается флаш вывода → нет двойной отрисовки и нет дыры. GUI: новый `xterm`-инстанс → пишет `snapshot` → дальше живой поток из канала. + +**detach / разрыв коннекта:** client-task падает → её `broadcast::Receiver` дропается → подписка чистится сама. PTY и грид живут в акторе дальше — демон переживает GUI. + +--- + +## 6. Снапшот → ANSI (`spacesh-core`) + +Из alacritty `Term` обходим видимую сетку построчно: +- Старт дампа: `ESC[2J ESC[H` (очистка + home) — свежий xterm-инстанс встаёт чисто. +- Для каждой ячейки эмитим SGR-атрибуты (цвет fg/bg, жирный, курсив, подчёркивание) **только при изменении** от предыдущей ячейки, затем символ. +- Конец строки → `\r\n`. +- В конце — позиционирование курсора `ESC[{row};{col}H`. +- Скроллбэк не дампим (только видимая область — бюджет репейнта <100мс). + +Детерминированно → юнит-тест: feed известных байтов → фиксированный снапшот; проверка курсора и SGR. + +--- + +## 7. Lifecycle демона + +- **Ленивый старт:** первый клиент (GUI), не найдя socket, форкает `spaceshd` и ждёт готовности (поллинг connect с таймаутом). +- **Single-instance:** лок-файл `~/.spacesh/daemon.lock` (flock) + проверка живости socket. Кейс «socket есть, демон мёртв»: connect фейлится → удаляем stale socket → стартуем. +- **launchd:** user-agent `~/Library/LaunchAgents/xyz.spacesh.daemon.plist` с `KeepAlive` (автоперезапуск при краше). `RunAtLoad` (автостарт при логине) — опционально, по умолчанию **выключен** в этом срезе. +- **shutdown:** закрыть все surface-акторы (kill child), снять socket + lock, выйти. + +Транспорт изолирован за трейтом (задел на remote, `DOCS/MAIN.md` §4.3), но в срезе только UDS. + +--- + +## 8. Обработка ошибок + +- **Протокол:** невалидный кадр/JSON → `res ok:false {code:"BAD_REQUEST"}`, коннект живёт. Неизвестный `surface_id` → `{code:"NOT_FOUND"}`. +- **PTY:** спавн упал → `res ok:false {code:"SPAWN_FAILED", msg}`. Процесс умер → `exit{code}` evt; surface остаётся видимым (закрытие — отдельной `close`). +- **Изоляция:** сбой одной client-task не роняет демон и другие surfaces (best-effort на каждого клиента). + +--- + +## 9. Тесты + +| Уровень | Что проверяем | +|---|---| +| `spacesh-proto` (юнит) | round-trip encode/decode каждого Cmd/Evt; кадрирование (частичный/склеенный кадр) | +| `spacesh-core` (юнит) | feed байтов → детерминированный снапшот; корректность курсора и SGR | +| `spacesh-pty` (интеграц) | spawn `echo hello` → читаем вывод; `resize` не падает | +| `spaceshd` (интеграц) | temp-socket: `open`→`new_surface`(`printf`)→`attach`→снапшот с выводом; reattach после дропа коннекта возвращает тот же экран; `close`→`exit` evt | +| `app` (ручной) | байты летают GUI↔демон↔PTY; kill GUI → reopen → экран восстановлен из снапшота | + +--- + +## 10. Бюджеты производительности (из `DOCS/MAIN.md` §2) + +- Keypress → echo: < 16 мс при нормальной нагрузке. +- Батчинг `output`: коалесцировать ~4–8 мс **или** до ~16 КБ, что раньше; не эмитить по байту. +- Backpressure: ограниченный канал на подписчика; грид авторитетный, не растём в памяти. +- Репейнт при reattach: снапшот < 100 мс на типовом гриде. + +--- + +## 11. Порядок реализации (внутри среза) + +Снизу вверх, вертикальный срез сначала: +1. `spacesh-proto` — типы + кадрирование + тесты. +2. `spacesh-pty` — spawn/reader/батчинг/resize + тест. +3. `spaceshd` (M0-ядро) — socket-сервер, registry, surface-actor, `open`/`new_surface`/`input`/`output`/`close`/`status`/`shutdown`. +4. `app` минимальный — src-tauri мост (схема B) + один `TerminalView` + `SurfaceList`. Байты летают (конец M0). +5. `spacesh-core` — грид (alacritty) + снапшот→ANSI + тесты. +6. `spaceshd` (M1) — `term.feed` в акторе, `attach`/`detach` со снапшотом, `broadcast`-fan-out. +7. `app` (M1) — репейнт из снапшота на attach, reattach-флоу. +8. launchd-обвязка + ленивый старт + single-instance. + +Конец среза: убил GUI — агент жив в демоне; открыл — экран восстановился.