A native markdown viewer CLI built with Rust (wry/tao) and Svelte 5.
Strategic design context lives in PRODUCT.md at the repo root — read it before design/UI work. In short: register is product (the reviewer tool is the thing; design serves the task), platform web (Svelte in a wry webview + a hosted browser build). Positioning: the reviewer for agent-authored docs — human comments and AI suggestions in one end-to-end-encrypted thread over files that never leave your machine. The guiding principle is warm surface, sharp behavior: an editorial paper/ink/rust identity you can see, power-tool (Linear/Raycast-grade) precision you can feel. The visual system is captured in DESIGN.md.
Use the Taskfile dev command for frontend/native UI development:
task devBy default, task dev auto-selects a free port (equivalent to DEV_PORT=auto).
This command starts Vite in web/, waits for it to be ready, then runs Rust with ATTN_DEV_SERVER_URL so the wry webview uses the Vite dev server with HMR.
Useful overrides:
task dev ATTN_PATH=tests/fixtures/basic.md
task dev DEV_PORT=5174
task dev DEV_PORT=auto
task dev DEV_HOST=0.0.0.0
ATTN_HOME=/tmp/attn-owner task dev # honor ATTN_HOME for multi-instance dev (optional)The fastest way to bring up the full local stack (Miniflare relay + owner + reviewer) in one terminal:
task dev:collab
# or directly:
scripts/dev-collab.shThis boots three processes:
- Miniflare relay —
wrangler dev --local --port 8787(waits for/health). - Owner daemon —
ATTN_HOME=/tmp/attn-collab-owneropeningtests/fixtures/basic.md. - Reviewer daemon —
ATTN_HOME=/tmp/attn-collab-reviewer, idle until the user provides an invite.
Interactive flow: click [Share] in the owner window, copy the invite URL,
paste it at the script's prompt — it runs attn review join <invite> routed
to the reviewer daemon (deliberately NOT --as-agent, which forks a separate
headless agent process and leaves the reviewer window idle; the daemon-routed
join flips the reviewer's own window onto the shared document). Ctrl+C cleans
up all three processes.
Env overrides:
ATTN_RELAY_URL=http://localhost:8787 # default
FIXTURE_PATH=tests/fixtures/basic.md # default
ATTN_BIN=target/debug/attn # default; built on demand
REVIEWER_AGENT=reviewer # agent name passed to `review join`
ATTN_COLLAB_NONINTERACTIVE=1 # skip the invite prompt (boot-only CI)For manually running two daemons without the relay/join harness:
ATTN_HOME=/tmp/attn-owner task dev ATTN_PATH=plan.md
ATTN_HOME=/tmp/attn-reviewer attn ...Each instance gets its own socket, identity, log, and (future) review store.
Default behavior is unchanged when ATTN_HOME is unset.
For automated test scripts, source the dual-instance library:
source scripts/lib/dual-instance.sh
trap stop_dual EXIT
start_dual
wait_for_dual 'h1'
attn_owner --click 'text=Suggest'
attn_reviewer --fill '.composer textarea' 'fix the typo'
count=$(attn_owner --query '.review-thread' | jq '.count')Each helper prefixes the right ATTN_HOME automatically — instances stay isolated.
Smoke-test via task test:dual.
Ordinary npm run dev:browser sessions proxy share traffic to staging and mint
https://staging.attn.sh/s/... links by default. This keeps localhost away from
production while still producing a public link that another browser can open.
The full browser share flow can also run entirely on localhost — dev builds allow the app's own origin as a share host (production bundles keep the strict attn.sh/staging.attn.sh allowlist):
# Terminal 1 — local relay with dev vars (same flags as `npm run dev` in relay/)
cd relay && npm run dev # port 8787
# Terminal 2 — hosted app with same-origin relay proxy
cd web && npm run dev:browser:shares # port 5173, proxies /v3 → 8787Open http://localhost:5173/app#new, write, click Share for review — the
sheet mints http://localhost:5173/s/<id>#key=… links; open one in another
tab/profile for the joiner UX. ATTN_DEV_RELAY_TARGET and
VITE_ATTN_SHARE_ORIGIN remain available when testing a different explicit
relay/public-origin pairing.
scripts/build.sh # debug (default) — automation + devtools enabled
scripts/build.sh release # release — automation + devtools stripped
scripts/build.sh prod # alias for releaseOr directly with cargo:
cargo build # debug (automation, devtools, screenshots, dev server)
cargo build --release # release (stripped — clean for distribution)Debug builds (debug_assertions on) include automation CLI flags (--screenshot, --eval, --click, --wait-for, --query, --fill), devtools, and dev server support. Release builds strip all of these automatically — no feature flags needed.
The release binary must stay under 40 MiB (locked by planning/collab/amendments.md §Decision #1 — the WebRTC transport is owned by Rust via webrtc-rs, which is the main risk to this budget; raised 25 → 30 MiB once webrtc-rs landed, 30 → 32 MiB once the tracing/time daemon-logging stack landed, then 32 → 40 MiB when snapshot compression (flate2) left only 3 KB of slack and further collab features were expected).
task check:size # builds release + runs the gate
scripts/check-binary-size.sh # gate only (assumes target/release/attn exists)
scripts/check-binary-size.sh 30 # override budget (positional MAX_MIB)Run the gate locally before merging any PR that touches Cargo.toml, src/**, or adds a transitively-heavy dep (tokio features, rustls, etc.). The script also reports the .app bundle size for context, and warns if it sees a regression > 2 MiB above an optional planning/collab/binary-size-baseline.md.
Emergency bypass: BINARY_SIZE_WAIVER=1 scripts/check-binary-size.sh (or the CI alias ATTN_SIZE_BUDGET_WAIVER=1). Bypasses must include a follow-up issue filed against attn-nnj.11.3 linked in the PR — never commit the waiver as a default.
# Generate temporary placeholder icon (replace before final release)
scripts/generate-placeholder-icon.sh
# Build app bundle
scripts/macos-build-bundle.sh prod aarch64-apple-darwin
# Sign app bundle (requires APPLE_SIGNING_IDENTITY)
scripts/macos-sign-app.sh target/aarch64-apple-darwin/release/bundle/osx/attn.app
# Create signed DMG (if APPLE_SIGNING_IDENTITY is set)
scripts/macos-create-dmg.sh target/aarch64-apple-darwin/release/bundle/osx/attn.app
# Notarize + staple (requires APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID)
scripts/macos-notarize-dmg.sh target/aarch64-apple-darwin/release/bundle/osx/attn.dmgGitHub Action setup is documented in .github/RELEASE_SETUP.md.
To bump the version (e.g., to 0.3.6):
- Update
versioninCargo.tomlandpackage.json(root) - Run
cargo checkto updateCargo.lock - Commit:
git commit -m "Bump version to 0.3.6" - Tag:
git tag v0.3.6 - Push:
git push && git push origin v0.3.6
src/main.rs— CLI entry, daemon event loop, webview setupsrc/daemon.rs— Unix socket IPC, fork, single-instance protocolsrc/watcher.rs— File change detection via notifysrc/markdown.rs— Markdown rendering (comrak + syntect)src/ipc.rs— Webview IPC message handlingsrc/screenshot.rs— Native WKWebView screenshot (debug builds, macOS)web/— Svelte 5 frontend, built by Vite intoweb/dist/index.html(embedded at compile time). In dev,task devserves Vite directly for HMR.build.rs— Runs Vite build, recursively watchesweb/src/andweb/styles/for changesscripts/build.sh— Unified build script (web + Rust)scripts/test-e2e.sh— Automated E2E test runner
attn runs as a single-instance daemon. The first invocation forks to background and opens a window. Subsequent invocations connect via unix socket at ~/.attn/attn.sock.
Use task dev during development to keep the daemon in the foreground with HMR enabled:
task dev ATTN_PATH=path/to/file.mdIf you only need Rust-side iteration (no frontend HMR), you can still run:
cargo run -- --no-fork path/to/file.md# Structured interaction commands (preferred for E2E tests)
attn --click 'text=Submit' # click by text content
attn --click '.my-button' # click by CSS selector
attn --wait-for 'h1' # wait for element to appear (default 5s)
attn --wait-for 'h1' --timeout 10000 # custom timeout in ms
attn --query 'h1' # JSON: {status, count, elements[{tag, text, visible, attributes}]}
attn --query '[data-sidebar]' | jq '.count'
attn --fill 'input.search' 'hello' # fill a form field
# Evaluate JavaScript in the webview and print the result (escape hatch)
attn --eval "document.title"
attn --eval "document.querySelector('h1')?.textContent"
attn --eval "window.__attn__" # access the Svelte app bridge
# Get daemon info (binary path, PID, window ID)
attn --info
# Take a screenshot (macOS, debug builds only)
attn --screenshotSelectors support CSS selectors and a text= prefix for matching by element text content (like Playwright locators). Exit code 0 on success, 1 on not_found/timeout.
Run the automated E2E test suite:
scripts/test-e2e.shThis builds attn, launches it with test fixtures, asserts DOM state via --eval, and captures screenshots to /tmp/attn-e2e-screenshots/.
Test fixtures are in tests/fixtures/:
basic.md— headings, checkboxes, code block, table, blockquotetypography.md— all heading levels, nested lists, text formattingnested/child.md— subdirectory file for tree/breadcrumb testingreview/scenario-comment-survives-edit.{md,json}— canvas + scripted mock-IPC scenario consumed by the review E2E suite
Run the review-surface E2E suite (shape-only assertions today; isolated ATTN_HOME):
task test:review
# or directly:
scripts/test-review-e2e.shThis runs under ATTN_HOME=/tmp/attn-review-e2e so it does not touch the user's normal daemon state, loads a scripted scenario from tests/fixtures/review/, and asserts the shape of the review surfaces (right-rail slot, window.__attn__ review callbacks). Assertions that depend on not-yet-merged Phase 0c work print PEND instead of FAIL and flip to hard asserts as those issues land.
Run the apply-flow E2E suite (attn-nnj.8.6 — Rust cargo tests + optional daemon-layer probes):
task test:apply
# or directly:
scripts/test-apply-e2e.shDrives the full owner-side accept/reject pipeline end-to-end: snapshot + UserEdit drift forces the suggestion to REMAP, apply_ready_verdict writes the file, the LocalRevision journal lands UserEdit + AcceptedSuggestion in order, and a SuggestionAccepted (or SuggestionRejected) envelope round-trips through the outbox with resulting_hash matching the on-disk hash. The Rust E2E cases live in src/review/apply.rs as e2e_* tests; the bash wrapper also probes the running daemon for the same end-state via the --eval bridge (daemon-layer assertions print PEND until attn-nnj.8.5 wires the AcceptSuggestion command — flip via ATTN_APPLY_E2E_REQUIRE_DAEMON=1).
Two surfaces:
task test:webrtc # runs both the Rust test and the bash harness
# or directly:
cargo test --test webrtc_e2e -- --nocapture
scripts/test-webrtc-e2e.sh- Rust:
tests/webrtc_e2e.rsstands up twoWebRtcTransportinstances inside the same process, drives them through SDP offer/answer + trickle ICE via an in-process signaling relay, sends a comment envelope over the DataChannel, and verifies the owner'sInboundPipelinepersisted it toevents.jsonl. Requires loopback UDP and (optionally) STUN reachability tostun.l.google.com. - Bash:
scripts/test-webrtc-e2e.shboots two daemons viascripts/lib/dual-instance.shand exercises the WebRTC handshake from the outside. Today it's primarily a daemon-shape scaffold — hard assertions on "the owner saw the reviewer's comment" PEND until attn-nnj.7.8 wires the ReviewManager IPC intowindow.__attn__.review.
Both surfaces honor ATTN_SKIP_WEBRTC_E2E=1 as a CI escape hatch: WebRTC bring-up needs real UDP sockets and is flaky on some infrastructure (especially macOS GH Actions runners). Skip-on-CI is a clean exit, not a failure.
- Start the daemon with HMR:
task dev ATTN_PATH=some/file.md - In another terminal, use
--evalto inspect/interact with the webview:- Query DOM state:
cargo run -- --eval "document.querySelector('.task-list').children.length" - Trigger actions:
cargo run -- --eval "document.querySelector('input[type=checkbox]').click()" - Read app state:
cargo run -- --eval "JSON.stringify(window.__attn_init__)"
- Query DOM state:
- Use
--infoto get PID/window ID for external tooling - Use
--screenshotto capture visual state for comparison