Skip to main content

Overview

Tempest uses a hybrid state management system: the React frontend manages UI state through custom hooks and stores, while the Rust backend manages PTY sessions and git operations. The two layers synchronize through Tauri IPC commands and persist state to disk for recovery across app restarts.

Frontend State Management

Architecture

Tempest does not use Redux, Zustand, or Jotai. Instead, it uses a custom store pattern built on React’s useSyncExternalStore hook combined with Node.js event emitter patterns. Each store is a self-contained module with:
  • An internal data structure (Map, object, or class)
  • Direct read/write functions (get*, set*)
  • React hooks (use*) that subscribe to changes via useSyncExternalStore
  • Optional localStorage fallback for persistence
This approach keeps store code simple and dependency-free while ensuring components re-render only when their subscribed state changes.

Runtime State (Central Persistence)

File: src/lib/runtimeState.ts The single source of truth for all persisted state. Stored in <app-data-dir>/runtime-state.json and loaded on app startup via loadRuntimeState().
Key functions: All stores read from getRuntimeState() on access, so changes are immediately visible across the app.

Store Modules

1. Work State (src/store/workState.ts)

Ephemeral per-session work status, never persisted. Detects when an agent finishes executing. State held:
  • Map of sessionId -> "idle" | "working" | "done"
  • Separate listener sets per session (prevents O(N²) re-renders)
Public API: Used by: TerminalPane to update work badges, Sidebar to sort sessions by activity.

2. Worktree Sessions (src/store/sessions.ts)

Stores metadata for all terminal and agent sessions running in worktrees or project roots. Session schema:
Storage key structure:
  • Worktree sessions: keyed by worktree path (e.g., /path/to/project/.tempest/my-workspace)
  • Root sessions: keyed by <projectPath>::root::<sessionId> (ROOT_SESSION_PREFIX) to avoid collisions
  • Multiple root sessions can coexist in the same project
Public API:

3. App Settings (src/store/appSettings.ts)

Terminal appearance and behavior settings, merged with hardcoded defaults.
Public API:

4. Keybindings (src/store/keybindings.ts)

Customizable keyboard shortcuts, merged with ACTION_DEFS defaults.
Defaults are defined in DEFAULTS record and merged at access time. Custom bindings override defaults. Public API:

5. Attribution (src/store/attribution.ts)

Simple flag for whether co-author attribution hook is enabled. Co-author line:
Public API:

6. Open Projects (src/store/openProjects.ts)

List of projects shown in the sidebar.
Public API:

7. Recent Workspaces (src/store/recents.ts)

Ordered list of recently opened projects.
Public API:

8. Message Queue (src/store/messageQueue.ts)

Ephemeral queue for messages displayed in a UI popover, never persisted.
Per-session queues: each session can have its own queue independently. Public API:

9. Prompts (src/store/prompts.ts)

Builtin and user-defined prompts for common tasks (Review, Tests, Security, Explain, Commit).
Builtin prompts are hardcoded and auto-inserted if missing (supports new builtins in app updates). Custom prompts have id: "custom-<uuid>". Public API:

10. Session Manager (src/store/sessionManager.ts)

Singleton that detects when agent sessions finish by monitoring PTY output, escape sequences, and terminal title changes. Ephemeral, never persisted. Detection signals monitored: Heuristic timers:
  • QUIET_MS (5000ms): If no output received for 5s, mark done (unless title says busy or insufficient bytes received)
  • TURN_STARTED_BYTES (200): Minimum bytes required before heuristic timers fire (gates false positives during thinking window)
  • DEAD_ZONE_MS (300ms): After user presses Enter, suppress done signals to avoid echo false-positives
  • CEIL_MS (12000ms): Hard ceiling; if still working after 12s, force done (safety net for keepalive loops)
Architecture:

11. Runtime State (Lib) (src/lib/runtimeState.ts)

Central reader/writer for all persisted state. Handles localStorage migration on first load. Initialization:
On first app launch, tries to read from write_runtime_state (Rust), then falls back to localStorage keys: After migration, all state moves to disk and localStorage is no longer used. Persistence: Every call to setRuntimeState(patch) immediately:
  1. Merges patch into in-memory _state
  2. Calls persist() which invokes write_runtime_state over Tauri IPC
  3. Waits for Rust to write atomically (temp file, then rename)
This ensures state survives app crashes.

Backend State (Rust)

Architecture

The Rust backend (src-tauri/src/lib.rs) uses Tauri’s state management system with two main persistent structures: No other state is kept in memory; all config flows through Frontend -> Tauri command -> Rust subprocess.

PtyState (Concurrent PTY Registry)

DashMap: Lock-free concurrent HashMap from the dashmap crate. Allows multiple threads to read/write simultaneously without global locks. Each PTY session is wrapped in Arc for cheap cloning across threads. Lifecycle:
  1. Frontend calls create_pty_session(...) with a unique session_id
  2. Rust spawns PTY, starts background reader thread
  3. PTY is inserted into DashMap under session_id
  4. Frontend can then call write_to_pty(session_id, data) or resize_pty(session_id, ...)
  5. When tab closes, frontend calls close_pty_session(session_id) or close_and_remove_worktree(session_id, ...)
  6. Rust removes entry from DashMap, kills child process, waits for exit, cleans up
Process tree termination: On Windows, portable-pty creates a job object that includes the shell and all descendants (agent CLI process, npm, git, etc). Killing the job kills everything atomically. On Unix, killing the shell process sends SIGTERM to its children. Any orphaned agent processes are force-killed via kill -9 if .tempest-pid persists across app restart.

ZenState (Secondary Windows)

Maps window label (e.g., "zen-1234567890") to (project_path, project_name). Used to initialize the React component in a secondary zen window. Populated by open_zen_window(), read by get_zen_config(), cleared when the window closes.

State Synchronization

Frontend to Backend

Pattern: Frontend -> invoke(command, params) -> Rust -> returns result All backend operations are stateless: Rust doesn’t cache anything. The frontend sends complete information (paths, settings) on each command. Examples:

Backend to Frontend

Pattern 1: Poll-based (Tauri commands) Frontend calls Rust command, waits for response:
Pattern 2: Push-based (Event channels) PTY output flows via a dedicated Channel<PtyOutputPayload> passed at PTY creation:
Rust thread reads from PTY master and sends each chunk as PtyOutputPayload, O(1) directly to this session’s channel (no broadcast, no filtering).

Storage Layer

Frontend persistence:
  • All frontend state lives in RuntimeState (typed, centralized)
  • Persisted to <app-data-dir>/runtime-state.json atomically via write_runtime_state
  • Loaded on app startup via loadRuntimeState
  • Falls back to localStorage for migration from older app versions
Backend persistence:
  • No persistent backend state (stateless design)
  • Git state is stored in .git/ directories (managed by git CLI)
  • PTY session state exists only while the process runs
  • Process IDs persisted to .tempest-pid sidecar files inside worktrees for crash recovery

localStorage Keys (Deprecated)

For reference, these are no longer used (migrated to RuntimeState): On first app load with the new version, loadRuntimeState attempts to read from disk. If that fails, it reads from localStorage, merges everything into RuntimeState, and persists atomically to disk. Subsequently, localStorage is never read again.

Session State Deep Dive

Complete Session Lifecycle

  1. Creation: User clicks “New Workspace” or opens a project, frontend calls createWorktree(projectPath, name)
    • Rust creates .tempest/<name> git worktree
    • Returns worktree path
  2. Storage: Frontend saves session to RuntimeState:
  3. PTY Spawn: Frontend calls createPtySession(sessionId, cwd, ...)
    • Rust opens PTY, saves PID to .tempest-pid
    • Starts background reader thread streaming output
  4. Agent Launch: If user runs claude in shell, sessionManager detects OSC title changes
    • Sets workState to “working”
    • Stores conversationId (extracted from agent output)
  5. Completion: Agent finishes, signals OSC/DEC sequence
    • sessionManager detects signal or timeout
    • Sets workState to “done”
    • Optional: frontend auto-saves session with timestamp
  6. Closure: User closes tab
    • Frontend calls closeWorktreeSession(worktreePath)
    • Sets closed: true in RuntimeState (soft close)
    • PTY is still alive (can re-open)
  7. Deletion: User clicks “Delete” in sidebar
    • Frontend calls closeAndRemoveWorktree(sessionId, repoPath, worktreePath)
    • Rust kills PTY, waits for process exit, removes worktree dir
    • Frontend removes session from RuntimeState

Restore on App Restart

At app startup:
  1. loadRuntimeState() reads disk, populating sessions
  2. For each open (non-closed) worktree session:
    • Check if .tempest-pid exists
    • If yes, process was orphaned; kill it via taskkill /F /T (Windows) or kill -9 (Unix)
  3. For each root session:
    • Call dedupeRootSessions(projectPath) to merge duplicates from old restore bug
    • Restore ghost tabs for root sessions that were closed but are being restored

Project State Tracking

Current project path: Stored implicitly through:
  • activeInstanceId in RuntimeState (which session tab is focused)
  • That session’s WorktreeSession.projectId
  • That project’s StoredProject.path
Project detection:
  • Sidebar lists all StoredProject entries
  • Frontend tracks open projects via getOpenProjects() / saveOpenProjects(...)
  • Projects are added via folder picker in welcome screen, persisted to RuntimeState
Project cleanup:
  • On app startup, pruneOrphanedSessions(validPaths) removes sessions for deleted projects
  • Prevents stale entries accumulating

Key Design Decisions

  1. No Redux/Zustand: Custom stores are simpler, smaller, and need no external dependencies for this use case.
  2. useSyncExternalStore: Ensures React re-renders only when subscribed state changes, avoiding O(N²) updates across hundreds of sessions.
  3. RuntimeState centralization: Single source of truth makes state debugging and persistence easy.
  4. Atomic disk writes: Every state change persists atomically (temp file + rename), preventing corruption on crash.
  5. Stateless Rust backend: No memory-resident app state reduces crashes and restart costs.
  6. DashMap for PTY sessions: Lock-free concurrent HashMap allows many PTY read/write operations to proceed in parallel without global locks.
  7. Lazy initialization of merged defaults: Keybindings and settings merge defaults on access, so app updates can add new defaults without migration code.
  8. Ephemeral vs persistent: Work state, queue, WebGL pool are ephemeral (lost on restart). Everything else lives in RuntimeState and survives.