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’suseSyncExternalStore 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 viauseSyncExternalStore - Optional localStorage fallback for persistence
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().
| Function | Purpose |
|---|---|
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 |
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)
| Function | Purpose |
|---|---|
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 |
2. Worktree Sessions (src/store/sessions.ts)
Stores metadata for all terminal and agent sessions running in worktrees or project roots.
Session schema:
- 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
| Function | Purpose |
|---|---|
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.
| Function | Purpose |
|---|---|
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.
DEFAULTS record and merged at access time. Custom bindings override defaults.
Public API:
| Function | Purpose |
|---|---|
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:
| Function | Purpose |
|---|---|
getAttribution() | Boolean flag |
setAttribution(value) | Enable/disable |
useAttribution() | React hook |
6. Open Projects (src/store/openProjects.ts)
List of projects shown in the sidebar.
| Function | Purpose |
|---|---|
getOpenProjects() | Fetch list |
saveOpenProjects(projects) | Replace entire list |
7. Recent Workspaces (src/store/recents.ts)
Ordered list of recently opened projects.
| Function | Purpose |
|---|---|
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.
| Function | Purpose |
|---|---|
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).
id: "custom-<uuid>".
Public API:
| Function | Purpose |
|---|---|
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:
| Signal | Meaning |
|---|---|
| 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 |
- 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)
11. Runtime State (Lib) (src/lib/runtimeState.ts)
Central reader/writer for all persisted state. Handles localStorage migration on first load.
Initialization:
write_runtime_state (Rust), then falls back to localStorage keys:
| localStorage key | RuntimeState field |
|---|---|
tempest-worktree-sessions | sessions |
tempest-open-projects | openProjects |
tempest-recents | recents |
tempest-app-settings | settings |
tempest-keybindings | keybindings |
tempest-attribution | attribution |
setRuntimeState(patch) immediately:
- Merges patch into in-memory
_state - Calls
persist()which invokeswrite_runtime_stateover Tauri IPC - Waits for Rust to write atomically (temp file, then rename)
Backend State (Rust)
Architecture
The Rust backend (src-tauri/src/lib.rs) uses Tauri’s state management system with two main persistent structures:
| Structure | Purpose |
|---|---|
PtyState(Arc<DashMap<String, Arc<PtySession>>>) | Active PTY sessions (shell processes) |
ZenState(Mutex<HashMap<String, (String, String)>>) | Zen window (secondary window) metadata |
PtyState (Concurrent PTY Registry)
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:
- Frontend calls
create_pty_session(...)with a uniquesession_id - Rust spawns PTY, starts background reader thread
- PTY is inserted into DashMap under
session_id - Frontend can then call
write_to_pty(session_id, data)orresize_pty(session_id, ...) - When tab closes, frontend calls
close_pty_session(session_id)orclose_and_remove_worktree(session_id, ...) - Rust removes entry from DashMap, kills child process, waits for exit, cleans up
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)
"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:
| Frontend | Rust Backend | State Modified |
|---|---|---|
createWorktree(path, name) | create_terminal_worktree | Worktree dir created |
createPtySession(...) | create_pty_session | PTY added to DashMap |
writeToTerminal(sessionId, data) | write_to_pty | Data sent to PTY stdin |
closePtySession(sessionId) | close_pty_session | PTY removed from DashMap |
Backend to Frontend
Pattern 1: Poll-based (Tauri commands) Frontend calls Rust command, waits for response:Channel<PtyOutputPayload> passed at PTY creation:
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.jsonatomically viawrite_runtime_state - Loaded on app startup via
loadRuntimeState - Falls back to localStorage for migration from older app versions
- 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-pidsidecar files inside worktrees for crash recovery
localStorage Keys (Deprecated)
For reference, these are no longer used (migrated to RuntimeState):| Key | Type | Purpose |
|---|---|---|
tempest-worktree-sessions | JSON Record | All worktree sessions |
tempest-open-projects | JSON array | Sidebar projects |
tempest-recents | JSON array | Recent workspaces |
tempest-app-settings | JSON object | Terminal settings |
tempest-keybindings | JSON object | Custom key bindings |
tempest-attribution | ”true” | “false” | Co-author enabled |
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
-
Creation: User clicks “New Workspace” or opens a project, frontend calls
createWorktree(projectPath, name)- Rust creates
.tempest/<name>git worktree - Returns worktree path
- Rust creates
-
Storage: Frontend saves session to RuntimeState:
-
PTY Spawn: Frontend calls
createPtySession(sessionId, cwd, ...)- Rust opens PTY, saves PID to
.tempest-pid - Starts background reader thread streaming output
- Rust opens PTY, saves PID to
-
Agent Launch: If user runs
claudein shell, sessionManager detects OSC title changes- Sets
workStateto “working” - Stores conversationId (extracted from agent output)
- Sets
-
Completion: Agent finishes, signals OSC/DEC sequence
- sessionManager detects signal or timeout
- Sets
workStateto “done” - Optional: frontend auto-saves session with timestamp
-
Closure: User closes tab
- Frontend calls
closeWorktreeSession(worktreePath) - Sets
closed: truein RuntimeState (soft close) - PTY is still alive (can re-open)
- Frontend calls
-
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
- Frontend calls
Restore on App Restart
At app startup:loadRuntimeState()reads disk, populating sessions- For each open (non-closed) worktree session:
- Check if
.tempest-pidexists - If yes, process was orphaned; kill it via
taskkill /F /T(Windows) orkill -9(Unix)
- Check if
- 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
- Call
Project State Tracking
Current project path: Stored implicitly through:activeInstanceIdin RuntimeState (which session tab is focused)- That session’s
WorktreeSession.projectId - That project’s
StoredProject.path
- Sidebar lists all
StoredProjectentries - Frontend tracks open projects via
getOpenProjects()/saveOpenProjects(...) - Projects are added via folder picker in welcome screen, persisted to RuntimeState
- On app startup,
pruneOrphanedSessions(validPaths)removes sessions for deleted projects - Prevents stale entries accumulating
Key Design Decisions
- No Redux/Zustand: Custom stores are simpler, smaller, and need no external dependencies for this use case.
- useSyncExternalStore: Ensures React re-renders only when subscribed state changes, avoiding O(N²) updates across hundreds of sessions.
- RuntimeState centralization: Single source of truth makes state debugging and persistence easy.
- Atomic disk writes: Every state change persists atomically (temp file + rename), preventing corruption on crash.
- Stateless Rust backend: No memory-resident app state reduces crashes and restart costs.
- DashMap for PTY sessions: Lock-free concurrent HashMap allows many PTY read/write operations to proceed in parallel without global locks.
- Lazy initialization of merged defaults: Keybindings and settings merge defaults on access, so app updates can add new defaults without migration code.
- Ephemeral vs persistent: Work state, queue, WebGL pool are ephemeral (lost on restart). Everything else lives in RuntimeState and survives.