Skip to content

Latest commit

 

History

History
148 lines (115 loc) · 9.37 KB

File metadata and controls

148 lines (115 loc) · 9.37 KB

Wisp

A native iOS app for managing and interacting with Fly.io Sprites — stateful sandbox VMs with persistent filesystems, checkpoint/restore, and HTTP access. Wisp provides a chat-based interface to run Claude Code on remote Sprites from your phone.

Tech Stack

  • Language: Swift 6
  • UI: SwiftUI, iOS 17+ minimum
  • State: @Observable view models (Observation framework)
  • Navigation: NavigationStack with NavigationPath
  • Persistence: SwiftData for local state (sessions, linked repos, preferences)
  • Secrets: iOS Keychain for tokens (Sprites API, Claude Code OAuth, GitHub)
  • Networking: URLSession for REST, URLSessionWebSocketTask for WebSocket — no third-party networking dependencies
  • Markdown: MarkdownUI (SPM) for rendering Claude responses with syntax highlighting
  • JSON: Custom JSONValue enum for arbitrary JSON decoding (no AnyCodable dependency)
  • No third-party dependencies except MarkdownUI (and SwiftTerm in Phase 3)

Architecture

Wisp/
├── App/                    # App entry point, root navigation
├── Models/                 # SwiftData models + Codable API types
├── Services/               # API clients, Keychain, networking
│   ├── SpritesAPIClient    # REST + WebSocket for Sprites API
│   ├── GitHubClient        # GitHub OAuth device flow + API
│   ├── KeychainService     # Token storage wrapper
│   └── ClaudeStreamParser  # NDJSON binary stream parser
├── ViewModels/             # @Observable view models per feature
├── Views/                  # SwiftUI views organised by feature
│   ├── Auth/
│   ├── Dashboard/
│   ├── SpriteDetail/
│   │   ├── Overview/
│   │   ├── Chat/
│   │   └── Checkpoints/
│   └── Settings/
└── Utilities/              # Extensions, helpers, JSONValue

Patterns

  • One @Observable view model per feature screen
  • Shared SpritesAPIClient singleton injected via SwiftUI environment
  • View models own async tasks; views are purely declarative
  • Use Swift concurrency (async/await, AsyncThrowingStream) throughout — no Combine
  • Errors surfaced as user-facing alerts via a shared error handling pattern

Sprites API

  • Base URL: https://api.sprites.dev
  • Auth: Authorization: Bearer {sprites_token} header on all requests
  • REST: JSON request/response via URLSession
  • WebSocket exec: wss://api.sprites.dev/v1/sprites/{name}/exec?cmd=...&env=...
    • cmd and env are repeatable query params
    • Non-TTY mode uses binary protocol: frames prefixed with stream ID byte (0=stdin, 1=stdout, 2=stderr, 3=exit, 4=stdin_eof)
    • Claude Code NDJSON arrives as raw bytes on stdout (stream ID 1); parse line-by-line

Claude Code exec pattern

# First message — cd into project dir first
cd /home/sprite/project && claude -p --verbose --output-format stream-json --dangerously-skip-permissions "prompt"

# Follow-up messages — resume session
cd /home/sprite/project && claude -p --verbose --output-format stream-json --dangerously-skip-permissions --resume SESSION_ID "prompt"

Environment variables passed via ?env=CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...

NDJSON stream events

Each stdout line is a JSON object with a type field:

  • system — contains session_id, model, tools, cwd. Store session_id for --resume.
  • assistant — contains message.content[] which is either text blocks or tool_use blocks
  • user — tool results (stdout/stderr for Bash, file content for Read/Write, etc.)
  • result — final event with session_id, duration_ms, num_turns, success/error

Current Phase: Phase 1 (MVP)

Phase 1 scope — build only these features:

  • Token auth (Sprites + Claude Code) with Keychain storage
  • Sprites list (dashboard) with create/delete
  • Sprite detail overview (metadata, status, URL)
  • Chat interface (core feature):
    • claude -p via WebSocket exec with streaming NDJSON
    • Render assistant text as chat bubbles with markdown
    • Render tool use as collapsible action cards (Bash, Write, Read, Edit, Glob, Grep)
    • Session continuity via --resume
    • Interrupt (kill exec session)
  • Checkpoint list, create, restore

Not in Phase 1: GitHub integration, file browser, web view, terminal, services, network policy, settings screen, background notifications.

Conventions

  • Always add a #Preview macro at the bottom of every SwiftUI view file, wrapping in NavigationStack where needed and injecting any required environment objects
  • Always use idiomatic iOS UI/UX patterns — follow Apple's Human Interface Guidelines and standard platform conventions (e.g. tap-to-copy instead of copy buttons, swipe actions, pull-to-refresh, confirmation sheets for destructive actions)
  • Use SF Symbols for icons throughout
  • System colors and standard iOS chrome — no custom design system
  • Sprite status colors: running = green, warm = amber/orange, cold = blue
  • Chat bubbles: user messages right-aligned, assistant messages left-aligned
  • Tool use cards are collapsible/expandable inline elements between chat bubbles
  • Show loading states for Sprite wake-up ("Waking Sprite..." for cold starts, ~1s)
  • Destructive actions (delete Sprite, restore checkpoint) always require confirmation dialogs

Multi-platform layout (iPhone / iPad / Mac)

The app runs on iPhone, iPad, and Mac (Designed for iPad). Keep all three in mind:

  • Navigation: DashboardView uses NavigationSplitView; List(selection:) + .tag() drives sidebar selection and push navigation on iPhone. Do not remove the selection binding — iPhone relies on it for implicit navigation links.
  • Size class: Use @Environment(\.horizontalSizeClass) to branch between compact (iPhone) and regular (iPad/Mac) layouts where needed.
  • Mac detection: The app may run on Mac as a Catalyst build or as a "Designed for iPad" app — handle both. #if targetEnvironment(macCatalyst) is true for Catalyst; ProcessInfo.processInfo.isiOSAppOnMac is true for "Designed for iPad" on Mac. A safe isRunningOnMac helper should return true in either case by combining the compile-time and runtime checks.
  • Content width: Wide screens benefit from a max-width cap on content-heavy views (Overview, Checkpoints, Auth). Use HStack spacers + .frame(maxWidth:) — do not use containerRelativeFrame inside NavigationSplitView detail columns, as it measures the wrong container and compresses the sidebar.
  • Swipe actions and context menus: Always implement both. Swipe actions are the primary interaction on iPhone/iPad; context menus (long-press on iOS, right-click on Mac) must cover the same set of actions so nothing is inaccessible on Mac. The two should be kept in sync whenever actions are added or removed. Do not use .disabled() on swipe action buttons — it has no visual effect on iOS. Instead, conditionally show/hide the button.
  • View model properties: Use private(set) for properties that views only need to read, not set directly.
  • Tokens are org-scoped; org slug is embedded in the token string (e.g. my-org/1290577/...)
  • Working directory convention: /home/sprite/project for new Sprites, /home/sprite/{repo} for cloned repos
  • Run mkdir -p /home/sprite/project on first chat message if no project dir exists
  • Settings captions: when a setting needs an explanation, wrap the control and caption text together in a VStack(alignment: .leading, spacing: 8) inside the Section, with the caption styled .font(.subheadline).foregroundStyle(.secondary).

Sheet design

Two sheet patterns in use — match the right one:

  • Form sheets (data entry, e.g. New Sprite, New Checkpoint): NavigationStack > Form > .navigationTitle + .navigationBarTitleDisplayMode(.inline) + .toolbar with text Cancel / action-name buttons (e.g. "Cancel" / "Create"). Keep text labels — they're more informative than icons for actions with consequences.
  • Utility/panel sheets (e.g. Quick Actions): NavigationStack > custom content > .navigationBarTitleDisplayMode(.inline) + Image(systemName: "xmark") in .cancellationAction. For tabs, use a segmented Picker in ToolbarItem(placement: .principal) + Group { switch selectedTab } for content — do not use TabView. TabView consumes bottom bar space, breaks transparent backgrounds, and its swipe gesture conflicts with any horizontal scroll content inside tabs.

Both sheet types use .background(.clear) on inner content views so the system sheet material shows through. Do not force Color(.systemBackground) on sheet content — let the sheet's natural material handle the background.

Avoid sandwiching a fixed element (e.g. input bar) between two Dividers. A divider above the input bar separating scrollable content from a fixed action area is standard; a second one below it is redundant.

Testing

  • Run unit tests after making changes to verify nothing is broken
  • Add new unit tests when adding or modifying logic (models, parsers, utilities, view models)
  • Tests live in WispTests/ and use Swift Testing (import Testing, @Suite, @Test, #expect)
  • Test target uses @testable import Wisp

Common Commands

# Build the project
xcodebuild -scheme Wisp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build

# Run tests
xcodebuild -scheme Wisp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' test