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().
export interface RuntimeState {
  version: number;                          // Schema version for migrations
  sessions: Record<string, WorktreeSession>;   // All open worktree/root sessions
  openProjects: StoredProject[];            // Open projects in sidebar
  recents: RecentWorkspace[];               // Recently opened projects
  settings: Partial<AppSettings>;           // Terminal and app settings
  keybindings: Partial<Record<ActionId, Shortcut | null>>;  // Custom key bindings
  attribution: boolean;                     // Co-author hook enabled flag
  migrations: Record<string, boolean>;      // Migration tracking
  tabs: PersistedTab[];                     // Non-terminal tabs (diff, preview, editor)
  sessionOrder: string[];                   // instanceIds in tab-bar order
  activeInstanceId: string | null;          // Last focused session's instanceId
  prompts: Array<...>;                      // Custom and builtin prompts
  atlasProjects: Record<string, boolean>;   // Project codebase indexing decisions
}
Key functions:
FunctionPurpose
loadRuntimeState()Reads runtime-state.json from disk, falls back to localStorage for migration
getRuntimeState()Returns current in-memory state
setRuntimeState(patch)Merges patch into state and persists atomically to disk
persist()Calls invoke("write_runtime_state", ...) to write to disk
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:
FunctionPurpose
getWorkState(sessionId)Returns current state (defaults to “idle”)
setWorkState(sessionId, state)Updates state and notifies all listeners
clearWorkState(sessionId)Deletes session from tracking
useWorkState(sessionId)React hook, subscribes to one session
useWorkStateVersion()React hook, incrementing version for cross-session sorting
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:
export interface WorktreeSession {
  name: string;                 // User-facing session name
  agent?: string;               // CLI command hint ("claude", "gemini", etc)
  conversationId?: string;      // Claude API conversation UUID for --resume
  instanceId?: string;          // Permanent PTY session ID (minted once)
  projectId: string;            // Project this session belongs to
  closed?: boolean;             // Soft-closed (tab closed but can re-open)
  isRootSession?: boolean;      // true = opened in project root (no worktree)
  noGit?: boolean;              // true = user chose to proceed without git
}
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:
FunctionPurpose
getWorktreeSession(worktreePath)Fetch session by path
rootSessionKey(projectPath, sessionId)Build a root-session store key
isRootSessionKey(key)Check if a key is a root session
rootSessionIdFromKey(key)Extract sessionId from a root-session key
getRootSessionsForProject(projectPath)Get all root sessions for a project
saveWorktreeSession(path, session)Persist a session
removeWorktreeSession(path)Delete a session
markWorktreeSessionClosed(path)Soft-close (set closed=true)
markWorktreeSessionOpen(path)Re-open (set closed=false)
dedupeRootSessions(projectPath)Merge duplicate root sessions from the pre-fix restore bug
pruneOrphanedSessions(validPaths)Remove sessions for deleted projects

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

Terminal appearance and behavior settings, merged with hardcoded defaults.
export interface AppSettings {
  terminalFontSize: number;            // Default: 13
  terminalFontFamily: string;          // Default: "Geist Mono"
  terminalCursorStyle: "block" | "bar" | "underline";  // Default: "block"
  terminalCursorBlink: boolean;        // Default: true
  terminalScrollback: number;          // Default: 1000
  sidebarFontSize: number;             // Default: 14
  branchPrefix: string;                // Auto-prefix for new branches
  commitMessageTemplate: string;       // Default: "Agent work"
  atlasEnabled: boolean;               // Enable codebase indexing
  atlasAutoIndex: boolean;             // Auto-index new projects
}
Public API:
FunctionPurpose
getSettings()Fetch merged settings (defaults + runtime override)
updateSetting(key, value)Update a single setting
useSettings()React hook, subscribes to changes

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

Customizable keyboard shortcuts, merged with ACTION_DEFS defaults.
export type ActionId =
  | "toggleTheme" | "openSettings" | "toggleLeftSidebar"
  | "toggleRightSidebar" | "openProject" | "newWorkspace"
  | "closeTab" | "nextTab" | "prevTab" | "broadcast"
  | "splitPaneV" | "splitPaneH" | "openQueue";

export interface Shortcut {
  key: string;      // e.g., "T", "\\", "ArrowUp"
  ctrl: boolean;
  shift: boolean;
  alt: boolean;
}
Defaults are defined in DEFAULTS record and merged at access time. Custom bindings override defaults. Public API:
FunctionPurpose
getBindings()Return merged bindings (defaults + custom)
setBinding(action, shortcut)Override a keybinding
resetBinding(action)Revert to default
resetAllBindings()Clear all customizations
useKeybindings()React hook, subscribes to all bindings
matchesEvent(shortcut, KeyboardEvent)Check if an event matches a shortcut
formatShortcut(shortcut)Format for display (e.g., “Ctrl+Shift+T”)
shortcutFromEvent(e)Extract shortcut from KeyboardEvent

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

Simple flag for whether co-author attribution hook is enabled. Co-author line:
Co-authored-by: Tempest <tempestai.dev@gmail.com>
Public API:
FunctionPurpose
getAttribution()Boolean flag
setAttribution(value)Enable/disable
useAttribution()React hook

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

List of projects shown in the sidebar.
export interface StoredProject {
  id: string;         // UUID
  name: string;       // Display name
  path: string;       // File system path
  expanded: boolean;  // Sidebar tree expansion state
}
Public API:
FunctionPurpose
getOpenProjects()Fetch list
saveOpenProjects(projects)Replace entire list

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

Ordered list of recently opened projects.
export interface RecentWorkspace {
  id: string;
  name: string;
  path: string;
  lastOpened: string;  // ISO 8601 timestamp
}
Public API:
FunctionPurpose
getRecents()Fetch recent list (max 50)
addRecent(ws)Add or move to top
removeRecent(path)Remove a recent

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

Ephemeral queue for messages displayed in a UI popover, never persisted.
export interface QueueItem {
  id: string;
  text: string;
}
Per-session queues: each session can have its own queue independently. Public API:
FunctionPurpose
enqueue(sessionId, text)Add message
dequeue(sessionId)Remove and return first message
removeFromQueue(sessionId, itemId)Remove specific item
clearQueue(sessionId)Empty queue
getQueue(sessionId)Fetch messages
useQueue(sessionId)React hook

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

Builtin and user-defined prompts for common tasks (Review, Tests, Security, Explain, Commit).
export interface PromptEntry {
  id: string;
  title: string;
  body: string;
  enabled: boolean;
  isBuiltin: boolean;
}
Builtin prompts are hardcoded and auto-inserted if missing (supports new builtins in app updates). Custom prompts have id: "custom-<uuid>". Public API:
FunctionPurpose
getPrompts()Fetch all prompts (loaded from runtimeState)
usePrompts()React hook
addPrompt(title, body)Create custom prompt
updatePrompt(id, patch)Modify title/body/enabled
deletePrompt(id)Delete custom prompt or disable builtin
resetPrompt(id)Restore builtin to defaults
clonePrompt(id)Create copy of a prompt
reorderPrompts(fromId, toId, side)Drag-and-drop reordering
isBuiltinModified(prompt)Check if builtin differs from default

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:
SignalMeaning
OSC 9 (iTerm notification)Agent turn complete
OSC 9;4;<state> (Windows Terminal progress)State 0 = done, 1-4 = working
OSC 133 B,D (FinalTerm shell-integration)Prompt ready, command finished
OSC 777 (rxvt-unicode)Attention/turn complete
DEC mode 2004 (bracketed paste)Enabled = prompt ready, disabled = busy
DEC mode 1049 (alt screen buffer)Leaving alt screen = app exited
Title regex (agent-specific)Claude: ”✳” idle, spinner busy; Gemini: ”◇” idle, ”✦✋” busy
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:
class SessionManager {
  register(sessionId, channel, isAgent, onDone?, onChunk?, agentHint?)
  unregister(sessionId)
  attach(sessionId, onData)        // Called by TerminalPane on mount
  detach(sessionId, onData)        // Called by TerminalPane on unmount
  markUserInput(sessionId)         // Called when user presses Enter
  updateTitle(sessionId, title)    // Called by term.onTitleChange()
  setOnChunk(sessionId, onChunk)   // Update per-chunk callback
}

export const sessionManager = new SessionManager();

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

Central reader/writer for all persisted state. Handles localStorage migration on first load. Initialization:
export async function loadRuntimeState(): Promise<void>
On first app launch, tries to read from write_runtime_state (Rust), then falls back to localStorage keys:
localStorage keyRuntimeState field
tempest-worktree-sessionssessions
tempest-open-projectsopenProjects
tempest-recentsrecents
tempest-app-settingssettings
tempest-keybindingskeybindings
tempest-attributionattribution
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:
StructurePurpose
PtyState(Arc<DashMap<String, Arc<PtySession>>>)Active PTY sessions (shell processes)
ZenState(Mutex<HashMap<String, (String, String)>>)Zen window (secondary window) metadata
No other state is kept in memory; all config flows through Frontend -> Tauri command -> Rust subprocess.

PtyState (Concurrent PTY Registry)

pub struct PtyState(pub(crate) Arc<DashMap<String, Arc<PtySession>>>);

struct PtySession {
  writer: Mutex<Box<dyn Write + Send>>,           // stdin to PTY
  master: Mutex<Box<dyn portable_pty::MasterPty + Send>>,  // master side of PTY
  _slave: Mutex<Box<dyn portable_pty::SlavePty + Send>>,   // slave side (slave end of PTY)
  child:  Mutex<Box<dyn portable_pty::Child + Send + Sync>>, // shell process
  cwd: String,                                    // working directory
}
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)

pub struct ZenState(pub Mutex<std::collections::HashMap<String, (String, String)>>);
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:
FrontendRust BackendState Modified
createWorktree(path, name)create_terminal_worktreeWorktree dir created
createPtySession(...)create_pty_sessionPTY added to DashMap
writeToTerminal(sessionId, data)write_to_ptyData sent to PTY stdin
closePtySession(sessionId)close_pty_sessionPTY removed from DashMap

Backend to Frontend

Pattern 1: Poll-based (Tauri commands) Frontend calls Rust command, waits for response:
const branch = await invoke<string>("get_git_branch", { path });
Pattern 2: Push-based (Event channels) PTY output flows via a dedicated Channel<PtyOutputPayload> passed at PTY creation:
const channel = new Channel<{ session_id: string; data: string }>();
channel.onmessage = (event) => {
  const { session_id, data } = event.payload;
  // Update terminal state
};
await invoke("create_pty_session", {
  session_id,
  cwd,
  rows, cols,
  command, args,
  on_event: channel,
});
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):
KeyTypePurpose
tempest-worktree-sessionsJSON RecordAll worktree sessions
tempest-open-projectsJSON arraySidebar projects
tempest-recentsJSON arrayRecent workspaces
tempest-app-settingsJSON objectTerminal settings
tempest-keybindingsJSON objectCustom key bindings
tempest-attribution”true” | “false”Co-author enabled
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:
    const session: WorktreeSession = {
      name: "feature-x",
      projectId: project.id,
      isRootSession: false,
      // No conversationId or agent yet
    };
    saveWorktreeSession(worktreePath, session);
    
  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.