Skip to content

Epic: Native macOS rewrite (drop Electron, Mac-only) #33

@willwashburn

Description

@willwashburn

Summary

Rebuild Pear as a native macOS app (Swift + SwiftUI + AppKit interop, libghostty for the terminal, existing Rust broker), drop Electron, and explicitly drop Windows/Linux. Goal: close the structural typing-latency gap with native terminals like Ghostty / iTerm, plus the secondary wins (memory, battery, bundle size, native UX).

This issue is the parent for the rewrite. Sub-issues will track each phase.

Assumed dependency: the agent-relay team will publish an official Swift SDK on a timeline compatible with Phase 1. If that slips, see the "Fallback if Swift SDK slips" section below.

Why

We've spent the last few PRs (#29, #30, #31, #32) systematically removing every avoidable cost on the typing hot path:

Typing is meaningfully better but still distinguishable from a native terminal. The remaining gap is architectural:

Hop Pear (Electron) Native (Ghostty)
Renderer → main (Electron IPC) 2–5ms 0 — same process
Main → broker process 2–5ms (same as Pear; broker stays)
Browser-stack render (xterm parse + WebGL + Chromium compositor) ~16ms frame ~16ms but no Chromium scheduling step
Round-trip floor ~30–40ms ~15–20ms

The Electron IPC and the browser compositor frame are structural — they exist as long as we ship Electron. Predictive/local echo (mosh-style) is the only way to hide them without restructuring, and it has its own correctness gotchas (raw mode, TUIs, password prompts).

Going native eliminates both costs and reaches the same floor as Ghostty (which can be ~5–10ms perceived for a shell prompt). That's the goal of this epic.

Goals

  • Typing latency: p50 keystroke-to-paint ≤ 20ms for a shell prompt; within 5ms of Ghostty on the same hardware.
  • Memory: ≤ 100MB at idle with one agent (vs. ~400MB today).
  • Bundle size: ≤ 50MB (vs. ~150MB today).
  • Battery: ≤ 0.5%/hr idle on Apple Silicon (current Electron is closer to 2–4%/hr).
  • Feature parity with current Electron app within ~6 months.

Non-goals

  • Cross-platform. Windows and Linux are dropped, explicitly. (If we wanted to keep them, we'd be doing predictive echo + sidecar work instead of this rewrite.)
  • Reuse of React UI code. Plan for a clean rewrite.
  • Migrating the broker. The existing Rust broker stays unchanged.
  • Mac App Store. Sandbox restrictions break broker spawning and project-directory writes. Direct distribution (notarized DMG, Sparkle auto-update) from day one.

Tech stack

Area Choice Notes
Language Swift 6 Strict concurrency, async/await
UI SwiftUI + AppKit interop SwiftUI for most views; NSViewRepresentable for terminal + complex split panes
Terminal renderer libghostty (embed via C bindings) MIT-licensed, GPU-accelerated. SwiftTerm is the fallback if embedding turns out to be impractical.
Broker comms Official Swift SDK from agent-relay team Assumed available. Fallback: hybrid Swift + Node-sidecar (see below)
State management @Observable (Swift 5.9+) Don't over-engineer. Reach for TCA only if explicit need.
Persistence JSON files, compat with Electron app's user-data dir Easy import path
Diff viewer swift-diff + Splash/Sourceful (or port shiki via a JS bridge for syntax highlighting parity) Replaces react-diff-view + shiki
Graph view Custom NSView + Core Animation, or SpriteKit if layout gets gnarly Replaces @xyflow/react
Split panes NSSplitViewController Replaces allotment
Auto-update Sparkle 2 Standard for non-MAS Mac apps
Distribution Notarized DMG via notarytool Universal binary (arm64 + x86_64)
CI GitHub Actions on macOS runners Build + notarize on tag push

Architecture

┌──────────────────────────────────────────────┐
│ Pear.app (Swift / SwiftUI)                   │
│                                              │
│  ┌────────────────────────────────────────┐  │
│  │ UI: SwiftUI views                      │  │
│  │  - Sidebar, chat, graph, diff, dialogs │  │
│  └────────────────────────────────────────┘  │
│                                              │
│  ┌────────────────────────────────────────┐  │
│  │ Terminal: NSView + libghostty          │  │
│  │  - PTY input out                       │  │
│  │  - PTY chunks in (direct from broker)  │  │
│  └────────────────────────────────────────┘  │
│                                              │
│  ┌────────────────────────────────────────┐  │
│  │ AgentRelay Swift SDK                   │  │
│  │  - Client + protocol types             │  │
│  │  - CLI registry / persona resolution   │  │
│  │  - Broker log handling                 │  │
│  │  - Spawn hooks (burn etc.)             │  │
│  └────────────────────────────────────────┘  │
└──────────────────────┬───────────────────────┘
                       │
                       ▼
            ┌──────────────────────┐
            │ agent-relay broker   │  (existing Rust binary, unchanged)
            └──────────────────────┘

Key point: the broker doesn't change. Only the UI process is rewritten.

Phased plan

Each phase becomes its own sub-issue (links to be added as they're filed).

Phase 0 — Foundations & spikes (2 weeks)

Goal: prove the assumptions and pick the renderer.

  • Xcode project scaffold, SPM dependency setup, CI building a stub Pear.app on a Mac runner with notarization wired
  • Spike 1 (gating): Embed libghostty in an NSView. Get one PTY's output painting. Measure keystroke-to-paint latency vs Ghostty on the same hardware. Decision: ship with libghostty or fall back to SwiftTerm?
  • Spike 2 (gating): Confirm the agent-relay Swift SDK timeline with that team. Get the SDK building against our existing broker. If timeline slips → activate the Node-sidecar fallback plan
  • Spike 3: State pattern with @Observable, mirroring the current zustand split (agent / project / ui / typing / pty-buffer stores)
  • Spike 4: Migration path from Electron user-data dir — read existing projects.json etc. from ~/Library/Application Support/pear-by-agent-relay/
  • Decision doc: terminal renderer choice (libghostty vs SwiftTerm vs custom), state pattern, persistence layer, broker comms (direct SDK vs sidecar)

Exit criteria:

  • A spike build types into a real broker-spawned agent end-to-end
  • Latency measurement in hand: ≤ 20ms p50, within 5ms of Ghostty
  • All Phase 0 decisions made and documented

Phase 1 — Vertical slice MVP (4 weeks)

Goal: one usable terminal pane that proves the architecture; team dogfood.

  • Single window, single project, single terminal pane
  • Spawn agent dialog (Claude Code / Codex / shell)
  • Broker connection lifecycle (start, reconnect, status)
  • Keystroke → PTY → render loop end-to-end
  • PTY resize on window resize
  • Basic dark theme
  • Native menu bar with at least File / Edit / View / Window / Help
  • OAuth / API key auth flow ported (re-uses broker login surface)
  • Crash reporting wired (Sentry or similar)
  • Internal dogfood: at least one team member uses MVP as their primary Pear for a week

Exit criteria:

  • One full daily flow works: launch app → log in → open project → spawn agent → type → see output
  • Latency target hit (≤ 20ms p50, within 5ms of Ghostty)
  • No crashes during 1-week dogfood

Phase 2 — Core UX parity (8 weeks)

Goal: usable for ~80% of daily flows; beta gate.

Terminals:

  • Multiple agent tabs in a single window
  • Split-pane terminals (1 / 2 / 3 / 4-up grid layouts, page navigation when > 4)
  • Activity indicators (typing dots, blocked-on-send pause icon, idle)
  • Three modes: view / drive / passthrough
  • Terminal mode toggle + pending-messages menu
  • Selection, copy/paste, URL detection (OSC8 hyperlinks via libghostty)
  • Find-in-terminal (⌘F)
  • Scrollback navigation
  • Per-pane focus management, click-to-focus, ⌘1–9 to jump tabs

Sidebar:

  • Project list, add/remove/switch
  • Per-project agent list with status indicators
  • Channel list (per-project)
  • DM rooms (derived from relay messages)
  • Sidebar collapse/expand
  • Search

Chat / relay messages:

  • Per-channel and per-DM views
  • Compose bar with @mention autocomplete
  • Message reactions (emoji)
  • Threads (reply on a message)
  • System notice rendering (channel-join etc.)
  • Human echo dedupe (parity with current implementation)
  • Pending message queue UI for drive mode (manual flush)

Dialogs:

  • Add project (with directory picker)
  • Add channel
  • Spawn agent (CLI picker, model picker)
  • Add cloud agent
  • Add integration (Slack etc., catalog-driven)
  • Command menu (⌘K) with project / agent / channel / action search

Settings:

  • Project settings pane (channels, roots, integrations)
  • Account settings (auth, theme, model defaults)
  • Keyboard shortcut customization

Theme:

  • Dark and light variants, follow system, manual override
  • Theme variables piped to libghostty for terminal colors

Exit criteria:

  • Internal team uses native build full-time
  • All P0 daily flows work without regression vs Electron
  • Beta tag

Phase 3 — Secondary surfaces (5 weeks)

  • Graph view of agent topology (NSView + Core Animation, or SpriteKit)
    • Node rendering with live terminal preview tile (subscribe to pty-buffer per agent)
    • Edge rendering for parent/child relationships
    • Pan / zoom / fit-to-view
  • Diff viewer (review pane)
    • Side-by-side and unified modes
    • Syntax highlighting via Splash or shiki-over-JS-bridge
    • Hunk navigation, comment threads (if applicable)
    • Spawn-review-agent flow (the current DiffPane:2686 path)
  • AI conversation history pane (ai-hist integration; may need a Swift port or sidecar)
  • Broker details / debug page
    • Live event stream (non-worker_stream)
    • Broker health, agent list with details
    • Error log
  • Pending messages pane (full view, not just menu)
  • In-app help, keyboard shortcuts cheatsheet

Exit criteria:

  • Feature parity for the long tail
  • No "I have to switch back to Electron to do X" cases

Phase 4 — Cloud agents + integrations (4 weeks)

  • Cloud agents UI
    • List / create / delete cloud agents
    • Attach / detach to project
    • Status indicators (sandbox health)
    • Cloud agent dialog
  • Proactive agents
    • List / create / edit personas
    • Deploy / pause / event log
  • Integrations catalog
    • Slack and any others currently shipped
    • OAuth flows for each
    • Per-project enable/disable
  • Burn / commit-draft flow
    • Spawn hook integration
    • Generated commit draft UI

Exit criteria:

  • Full functional parity with Electron
  • Cloud + proactive flows tested with real backends

Phase 5 — Polish, migration, ship (3 weeks)

  • One-time migration from Electron ~/Library/Application Support/pear-by-agent-relay/ to native app's data dir, with verbose logging and rollback
  • Sparkle auto-update channel (separate appcast from Electron build during transition)
  • Accessibility pass (VoiceOver, dynamic type, keyboard-only navigation)
  • Performance pass on Intel Macs (Universal binary)
  • Marketing site update
  • Deprecation notice on Electron build with 6-month timeline
  • Migration guide for users
  • Beta → GA

Exit criteria:

  • GA build shipped to all users
  • Electron build marked deprecated, EOL date published

Total estimate

Team size Estimated duration
1 senior Swift-comfortable dev 9–12 months
2 devs ~6 months
3 devs ~4 months

Recommend 2 devs + 1 designer (the surface area is large enough that design parity matters).

Risks

Ranked by severity:

  1. agent-relay Swift SDK slips. If the SDK isn't ready by the end of Phase 0, the whole rewrite blocks. Fallback: hybrid Swift app + Node sidecar running the existing TS SDK over a Unix-domain JSON-RPC socket. Adds an extra hop for control-plane RPCs (fine — only PTY chunks need to be fast, and those go direct to broker WebSocket). See the architecture diagram in Dedicated IPC channel for PTY chunks + opt-in typing trace #32's discussion for the hybrid layout.
  2. libghostty embedding doesn't pan out. Either the C bindings are insufficient or the API stability isn't there yet. Fallback: SwiftTerm. Risk: we don't close the typing-latency gap as much as planned.
  3. Team velocity in Swift. If the current team is React-shaped, expect a ramp. Mitigate by hiring one experienced Swift dev for foundation work and pairing.
  4. User data migration bugs. Years of project config, channels, integrations. Mitigate with verbose-logging importer, keep Electron app installable for one release after GA.
  5. Cross-platform user churn. Drop is permanent. Mitigate (or accept): 6-month deprecation notice, optionally open-source the Electron build as a community fork.
  6. Cloud agents UX drift. That surface is in active flux; rebuilding it as it changes is wasted work. Mitigate by feature-freezing it during the rewrite, or scheduling it last (Phase 4) when it's stabilized.
  7. Apple Silicon vs Intel. Easy in principle (Universal binary for both broker and app), but needs verification before GA.

Fallback if Swift SDK slips

If by the end of Phase 0 the agent-relay team doesn't have a credible timeline for a Swift SDK that lands before Phase 1 completes:

Activate the Node-sidecar hybrid:

  • Swift app talks directly to the broker's WebSocket for PTY chunks (the latency-critical path). Define and document the worker_stream message format as a stable contract.
  • A bundled Node sidecar runs @agent-relay/sdk and exposes a local JSON-RPC server over a Unix-domain socket. Swift calls it for spawnAgent, listAgents, attachTerminal, persona resolution, broker logs, burn hooks.
  • Sidecar managed as a child process of the app (start on launch, shutdown on quit, restart on crash).
  • Adds ~30MB to the bundle (single-executable Node) and the operational overhead of one extra process. Worth it to unblock.
  • When/if a Swift SDK lands, retire the sidecar in a single PR.

Open questions (need answers by end of Phase 0)

  • libghostty: which Ghostty version do we target, and how do we handle their breaking changes? Do we vendor or pin?
  • Broker process lifecycle: launchd agent (survives app close) or app child process?
  • Universal binary or Apple Silicon only on day one?
  • Migration UX: prompt on first launch, or pure auto-discovery?
  • AI conversation history (ai-hist): port to Swift, run via the Node sidecar, or skip in Phase 1?
  • Mac App Store: confirm it's permanently off the table, or revisit later with a sandboxed variant?
  • macOS minimum version: 13 (Ventura), 14 (Sonoma), or 15 (Sequoia)? SwiftUI features and @Observable argue for 14+.

Success criteria (GA)

  • p50 keystroke-to-paint ≤ 20ms for shell prompt, within 5ms of Ghostty
  • Idle memory ≤ 100MB with one agent attached
  • App bundle ≤ 50MB
  • Idle battery ≤ 0.5%/hr on M-series
  • No daily-flow regressions in 2-week pre-GA dogfood
  • All P0 features from Phases 1–4 shipped
  • Migration from Electron app works for 100% of internal dogfood data
  • Auto-update path verified end-to-end on a real user laptop

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions