Chapter 7 System Design

Immutable State Management

A Zustand-inspired reactive store for the terminal UI

src/state/AppStateStore.tsLines 180
1
import type { Notification } from 'src/context/notifications.js'
2
import type { TodoList } from 'src/utils/todo/types.js'
3
import type { BridgePermissionCallbacks } from '../bridge/bridgePermissionCallbacks.js'
4
import type { Command } from '../commands.js'
5
import type { ChannelPermissionCallbacks } from '../services/mcp/channelPermissions.js'
6
import type { ElicitationRequestEvent } from '../services/mcp/elicitationHandler.js'
7
import type {
8
  MCPServerConnection,
9
  ServerResource,
10
} from '../services/mcp/types.js'
11
import { shouldEnablePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js'
12
import {
13
  getEmptyToolPermissionContext,
14
  type Tool,
15
  type ToolPermissionContext,
16
} from '../Tool.js'
17
import type { TaskState } from '../tasks/types.js'
18
import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'
19
import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'
20
import type { AllowedPrompt } from '../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
21
import type { AgentId } from '../types/ids.js'
22
import type { Message, UserMessage } from '../types/message.js'
23
import type { LoadedPlugin, PluginError } from '../types/plugin.js'
24
import type { DeepImmutable } from '../types/utils.js'
25
import {
26
  type AttributionState,
27
  createEmptyAttributionState,
28
} from '../utils/commitAttribution.js'
29
import type { EffortValue } from '../utils/effort.js'
30
import type { FileHistoryState } from '../utils/fileHistory.js'
31
import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js'
32
import type { SessionHooksState } from '../utils/hooks/sessionHooks.js'
33
import type { ModelSetting } from '../utils/model/model.js'
34
import type { DenialTrackingState } from '../utils/permissions/denialTracking.js'
35
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
36
import { getInitialSettings } from '../utils/settings/settings.js'
37
import type { SettingsJson } from '../utils/settings/types.js'
38
import { shouldEnableThinkingByDefault } from '../utils/thinking.js'
39
import type { Store } from './store.js'
40
 
41
export type CompletionBoundary =
42
  | { type: 'complete'; completedAt: number; outputTokens: number }
43
  | { type: 'bash'; command: string; completedAt: number }
44
  | { type: 'edit'; toolName: string; filePath: string; completedAt: number }
45
  | {
46
      type: 'denied_tool'
47
      toolName: string
48
      detail: string
49
      completedAt: number
50
    }
51
 
52
export type SpeculationResult = {
53
  messages: Message[]
54
  boundary: CompletionBoundary | null
55
  timeSavedMs: number
56
}
57
 
58
export type SpeculationState =
59
  | { status: 'idle' }
60
  | {
61
      status: 'active'
62
      id: string
63
      abort: () => void
64
      startTime: number
65
      messagesRef: { current: Message[] } // Mutable ref - avoids array spreading per message
66
      writtenPathsRef: { current: Set<string> } // Mutable ref - relative paths written to overlay
67
      boundary: CompletionBoundary | null
68
      suggestionLength: number
69
      toolUseCount: number
70
      isPipelined: boolean
71
      contextRef: { current: REPLHookContext }
72
      pipelinedSuggestion?: {
73
        text: string
74
        promptId: 'user_intent' | 'stated_intent'
75
        generationRequestId: string | null
76
      } | null
77
    }
78
 
79
export const IDLE_SPECULATION_STATE: SpeculationState = { status: 'idle' }
80
 
Annotations (click the dots)

AppStateStore is Claude Code's single source of truth. Unlike browser apps that can use React context freely, a terminal app using Ink.js needs a careful state model — unnecessary re-renders mean visible flicker.

🔑Key Insight

State is DeepImmutable — TypeScript's type system prevents direct mutations. The only way to change state is through the store's dispatch function, creating an audit trail of every update.

The message log is append-only. When a message is "deleted" (e.g., on /clear), a TombstoneMessage is appended with the IDs of deleted messages. This makes the history easy to replay and avoids index mutation bugs.

💡Tip

Memoized selectors in `src/state/selectors.ts` compute derived values once and cache them. Components subscribe to selectors, not raw state — they only re-render when their specific slice changes.

KEY TAKEAWAYS
  • AppState is a DeepImmutable type — no direct mutation allowed anywhere
  • The single store dispatches updates; all components subscribe to relevant slices
  • Memoized selectors prevent unnecessary React re-renders in the terminal UI
  • Messages are append-only — deletion uses TombstoneMessage markers, not splicing
  • Speculation state enables pipelined suggestions without blocking the main loop
AI Assistant

Ask anything about Immutable State Management

Powered by Groq · Enter to send, Shift+Enter for newline