Event System
Complete guide to Iris Studio's event-driven architecture.
Philosophy
All state changes are events. Every interaction, every LLM response, every git operation — all represented as data flowing through a single event system.
Why Events?
Traditional imperative code scatters logic everywhere:
// BAD: Logic in multiple places
fn handle_key(&mut self, key: Key) {
if key == 'g' {
self.state.generating = true;
self.spawn_agent();
self.ui.show_spinner();
}
}
fn on_agent_complete(&mut self, result: Result) {
self.state.generating = false;
self.ui.hide_spinner();
self.state.message = result;
}Problems:
- State mutations in many places
- Hard to trace "what happened?"
- Difficult to test
- No audit trail
With events, everything flows through one system:
// GOOD: Central event flow
pub enum Event {
GenerateCommit,
AgentComplete { result },
}
fn reduce(state: &mut State, event: Event) -> Vec<Effect> {
match event {
Event::GenerateCommit => {
state.generating = true;
vec![Effect::SpawnAgent]
}
Event::AgentComplete { result } => {
state.generating = false;
state.message = result;
vec![]
}
}
}Benefits:
- Single source of truth
- Complete audit trail
- Easy testing
- Replay-able
Event Types
Located in src/studio/events.rs:
#[derive(Debug, Clone)]
pub enum StudioEvent {
// User Input
KeyPressed(KeyEvent),
Mouse(MouseEvent),
// Navigation
SwitchMode(Mode),
FocusPanel(PanelId),
FocusNext,
FocusPrev,
// Content Generation
GenerateCommit { instructions, preset, use_gitmoji, amend },
ToggleAmendMode,
GenerateReview { from_ref, to_ref },
GeneratePR { base_branch, to_ref },
GenerateChangelog { from_ref, to_ref },
GenerateReleaseNotes { from_ref, to_ref },
ChatMessage(String),
// Agent Responses
AgentStarted { task_type },
AgentProgress { task_type, tool_name, message },
AgentComplete { task_type, result },
AgentError { task_type, error },
StreamingChunk { task_type, chunk, aggregated },
StreamingComplete { task_type },
StatusMessage(StatusMessage), // Dynamic status from fast model
// Tool-Triggered (agent controls UI)
UpdateContent { content_type, content },
LoadData { data_type, from_ref, to_ref },
StageFile(PathBuf),
UnstageFile(PathBuf),
// File & Git + history logs
FileStaged(PathBuf), FileUnstaged(PathBuf),
RefreshGitStatus, GitStatusRefreshed,
SelectFile(PathBuf),
FileLogLoaded { file, entries },
FileLogLoading(PathBuf),
GlobalLogLoaded { entries },
GlobalLogLoading,
ToggleGlobalLog,
// Modals
OpenModal(ModalType),
CloseModal,
ModalConfirmed { modal_type, data },
// UI Updates
Notify { level, message },
Scroll { direction, amount },
ToggleEditMode,
NextMessageVariant,
PrevMessageVariant,
CopyToClipboard(String),
// Settings
SetPreset(String),
ToggleGitmoji,
SetEmoji(String),
// Lifecycle
Quit,
Tick,
// Companion (ambient awareness)
CompanionFileCreated(PathBuf),
CompanionFileModified(PathBuf),
CompanionFileDeleted(PathBuf),
CompanionGitRefChanged,
CompanionWatcherError(String),
CompanionBranchSwitch { branch, welcome_message },
}48 variants total, covering every state transition. The derive is #[derive(Debug, Clone)] only — StudioEvent is not Serialize/Deserialize, and there is no History::save_to_file / load_from_file today. Persistence is planned future work (see history.rs:9).
Event Categories
User Input Events
Origin: Keyboard, mouse
KeyPressed(KeyEvent) // Filtered for Press kind only
Mouse(MouseEvent) // Click, scroll, dragFlow:
- Crossterm captures raw input
- Event loop filters/normalizes
- Dispatches as
StudioEvent - Handler maps to semantic events
Example:
User presses 'g' in Commit mode
↓
KeyPressed(KeyEvent { code: Char('g'), ... })
↓
Handler: handle_commit_key()
↓
GenerateCommit { instructions: None, preset: "default", use_gitmoji: true, amend: false }Navigation Events
Origin: User input, mode switching
SwitchMode(Mode) // Change to different mode
FocusPanel(PanelId) // Focus specific panel
FocusNext // Tab to next panel
FocusPrev // Shift-Tab to previousEffects:
- Update
active_modeorfocused_panel - May trigger
LoadDatafor new mode - Updates UI rendering
Content Generation Events
Origin: User requests Iris to generate content
GenerateCommit { instructions, preset, use_gitmoji, amend }
ToggleAmendMode // Swap in/out of amend mode (loads HEAD message)
GenerateReview { from_ref, to_ref }
GeneratePR { base_branch, to_ref }
GenerateChangelog { from_ref, to_ref }
GenerateReleaseNotes { from_ref, to_ref }
ChatMessage(String)Flow:
User triggers generation
↓
GenerateCommit event (with amend: bool)
↓
Reducer sets generating=true, marks Iris thinking
↓
Returns SpawnAgent { task: AgentTask::Commit { ... } } effect
↓
Executor spawns async task via spawn_commit_generation
↓
Task runs LLM API call
↓
Task sends AgentComplete event back through the iris_result channel
↓
Reducer extends state.modes.commit.messages and updates the editorAgent Response Events
Origin: Async agent tasks
AgentStarted { task_type }
AgentProgress { task_type, tool_name, message }
AgentComplete { task_type, result }
AgentError { task_type, error }
StreamingChunk { task_type, chunk, aggregated }
StreamingComplete { task_type }Lifecycle:
AgentStarted
↓
AgentProgress (tool: git_diff)
↓
AgentProgress (tool: git_log)
↓
StreamingChunk ("feat")
↓
StreamingChunk ("feat: add")
↓
StreamingChunk ("feat: add user auth")
↓
StreamingComplete
↓
AgentComplete { result }Why separate events?
- UI can show progress ("Using tool: git_diff")
- Streaming updates provide real-time feedback
- Errors can be handled gracefully
Tool-Triggered Events
Origin: Agent tool calls (Iris controls UI)
UpdateContent { content_type, content }
LoadData { data_type, from_ref, to_ref }
StageFile(PathBuf)
UnstageFile(PathBuf)Example: Iris can update commit message via chat:
User: "Make it more concise"
↓
ChatMessage("Make it more concise")
↓
Agent uses update_commit_message tool
↓
Tool emits UpdateContent { content_type: CommitMessage, content: ContentPayload::Commit(msg) }
↓
Reducer writes state.modes.commit.messages[current_index] and refreshes message_editor
↓
UI shows updated messageWhy? Agent can manipulate UI state through tools. Powerful for chat interactions.
Git & File Events
Origin: Git operations, file selection
StageFile(PathBuf) // Request to stage
FileStaged(PathBuf) // Successfully staged
UnstageFile(PathBuf) // Request to unstage
FileUnstaged(PathBuf) // Successfully unstaged
RefreshGitStatus // Request refresh
GitStatusRefreshed // Refresh complete
SelectFile(PathBuf) // User selects file in treePattern: Request/Response
User clicks file in tree
↓
SelectFile(path)
↓
Reducer updates selected_file
↓
Returns LoadData { data_type: FileContent }
↓
Executor loads file async
↓
Sends DataLoaded event
↓
Reducer updates code_view stateModal Events
Origin: User opens/closes dialogs
OpenModal(ModalType)
CloseModal
ModalConfirmed { modal_type, data }Modal Types:
pub enum ModalType {
Help,
Chat,
Settings,
PresetSelector,
EmojiSelector,
RefSelector { field: RefField },
ConfirmCommit,
ConfirmAmend,
ConfirmQuit,
}Flow:
User presses '?'
↓
OpenModal(Help)
↓
state.modal = Some(Modal::Help)
↓
Render shows help overlay
↓
User presses Esc
↓
CloseModal
↓
state.modal = NoneUI Update Events
Origin: User interactions, state changes
Notify { level, message } // Show notification
Scroll { direction, amount } // Scroll content
ToggleEditMode // View ↔ Edit
NextMessageVariant // Cycle messages
PrevMessageVariant
CopyToClipboard(String)Lifecycle Events
Origin: Timer, shutdown
Tick // Animation frame (~250ms)
Quit // User requests exitTick events:
- Advance spinner animation
- Clean up expired notifications
- Update timestamps
- Poll async channels
Event Flow
Synchronous Flow
flowchart TB
input[User Input]
handler[Handler]
queue[Event Queue]
reducer[Reducer]
state[State Updated]
effects[Effects]
executor[Executor]
input --> handler
handler --> queue
queue --> reducer
reducer --> state
reducer --> effects
effects --> executorFlow: User input → Handler maps to event → Queue → Reducer processes → State updates + Effects returned → Executor runs effects
Asynchronous Flow
flowchart TB
reducer[Reducer]
effect[Effect::SpawnAgent]
executor[Executor]
spawn[tokio::spawn]
task[Async Task]
channel[Send via Channel]
queue[Event Queue]
back[Back to Reducer]
reducer --> effect
effect --> executor
executor --> spawn
spawn --> task
task --> channel
channel --> queue
queue --> backFlow: Reducer emits effect → Executor spawns async task → Task runs (LLM call, git op) → Result sent via channel → Event queue → Back to reducer
Key insight: Async work happens outside reducer, results come back as events.
Event Sources
Events come from four sources:
pub enum EventSource {
User, // Keyboard, mouse
Agent, // LLM responses
Tool, // Agent tool calls
System, // Tick, refresh, async task results
}For convenience, TimestampedEvent pairs a StudioEvent with its source and Instant:
pub struct TimestampedEvent {
pub timestamp: Instant,
pub source: EventSource,
pub event: StudioEvent,
}This is the type the rest of the system passes around when it cares about provenance; the unified History stores a denormalized HistoryEntry per event with the same source plus a HistoryChange payload.
Why track source?
- Debugging ("was this user or agent?")
- Analytics (how often do users vs agents trigger actions?)
- Filtering replays/inspections by source
Supporting Types
TaskType
pub enum TaskType {
Commit,
Review,
PR,
Changelog,
ReleaseNotes,
Chat,
SemanticBlame,
}Identifies which agent task is running. Used in:
AgentStarted { task_type }AgentComplete { task_type, result }- Progress tracking
AgentResult
pub enum AgentResult {
CommitMessages(Vec<GeneratedMessage>),
ReviewContent(Review), // structured Review type
PRContent(String),
ChangelogContent(String),
ReleaseNotesContent(String),
ChatResponse(String),
SemanticBlame(SemanticBlameResult),
}Typed result from agent completion. Reducer matches on this to update the correct state field. Note that ReviewContent wraps the structured Review type (with findings), not a raw markdown string.
ContentType
pub enum ContentType {
CommitMessage,
PRDescription,
CodeReview,
Changelog,
ReleaseNotes,
}Used in UpdateContent events when agent tools update UI state.
ContentPayload
pub enum ContentPayload {
Commit(GeneratedMessage),
Markdown(String),
}Data for content updates. Structured (commit) vs unstructured (markdown).
DataType
pub enum DataType {
GitStatus,
CommitDiff,
ReviewDiff,
PRDiff,
ChangelogCommits,
ReleaseNotesCommits,
ExploreFiles, // Lazy-load file tree on Explore mode switch
}Identifies what data to load asynchronously.
NotificationLevel
pub enum NotificationLevel {
Info,
Success,
Warning,
Error,
}Determines notification color/icon.
ScrollDirection
pub enum ScrollDirection {
Up,
Down,
PageUp,
PageDown,
Top,
Bottom,
}Semantic scroll commands (not raw line offsets).
Side Effects
Effects are data describing I/O operations to execute after state update:
#[derive(Debug, Clone)]
pub enum SideEffect {
SpawnAgent { task: AgentTask },
LoadData { data_type, from_ref, to_ref },
GitStage(PathBuf), GitUnstage(PathBuf),
GitStageAll, GitUnstageAll,
SaveSettings,
RefreshGitStatus,
CopyToClipboard(String),
ExecuteCommit { message },
ExecuteAmend { message },
ShowNotification { level, message, duration_ms },
Redraw,
Quit,
GatherBlameAndSpawnAgent { file, start_line, end_line },
LoadFileLog(PathBuf),
LoadGlobalLog,
}Why separate from events?
- Events describe what happened
- Effects describe what to do
Reducer returns effects, executor performs I/O. Some effects — Quit, ExecuteCommit, ExecuteAmend — also short-circuit the event loop by returning a typed ExitResult.
AgentTask
pub enum AgentTask {
Commit { instructions, preset, use_gitmoji, amend },
Review { from_ref, to_ref },
PR { base_branch, to_ref },
Changelog { from_ref, to_ref },
ReleaseNotes { from_ref, to_ref },
Chat { message, context: ChatContext },
SemanticBlame { blame_info: BlameInfo },
}Describes agent work to spawn. BlameInfo carries the file/line range, commit hash, author, date, message, and the actual code content for the semantic-blame agent to explain.
ChatContext
pub struct ChatContext {
pub mode: Mode,
pub current_content: Option<String>,
pub diff_summary: Option<String>,
}Provides context to chat agent about current mode and content.
Event Lifecycle Example
Let's trace a complete flow: User generates commit message
1. User presses 'g' in Commit mode
↓
2. Event loop captures KeyEvent { code: Char('g') }
↓
3. Dispatcher calls handle_commit_key(state, key)
↓
4. Handler checks: no modal, not editing, key is 'g'
Returns: [] (effects handled by reducer directly)
Actually pushes event to queue:
GenerateCommit { instructions: None, preset: "default", use_gitmoji: true, amend: false }
↓
5. Event loop processes queue
↓
6. Calls reduce(state, event, history)
↓
7. Reducer matches GenerateCommit:
- state.modes.commit.generating = true
- state.set_iris_thinking("Generating commit message...")
- history.record_agent_start(TaskType::Commit)
Returns: [SpawnAgent { task: Commit { ... } }]
↓
8. Executor receives effects
↓
9. Matches SpawnAgent, spawns async task:
tokio::spawn(async {
let result = agent.generate_commit(...).await;
tx.send(AgentComplete { task_type: Commit, result });
})
↓
10. UI renders with generating=true, shows spinner
↓
11. Agent task runs (calls LLM API)
↓
12. Agent sends progress events:
AgentProgress { task_type: Commit, tool_name: "git_diff", message: "..." }
↓
13. Reducer updates status: "Using git_diff..."
↓
14. Agent completes, sends event:
AgentComplete { task_type: Commit, result: CommitMessages([...]) }
↓
15. Event loop receives from channel
↓
16. Calls reduce(state, event, history)
↓
17. Reducer matches AgentComplete:
- state.modes.commit.messages.extend(result.clone())
- state.modes.commit.current_index = first_new_index
- state.modes.commit.generating = false
- state.modes.commit.message_editor.add_messages(result)
- state.iris_status = Complete { message: "Ready." }
- history.record_agent_complete(Commit, true)
- history.record_content(Mode::Commit, ContentType::CommitMessage, ..., "generation_complete")
Returns: []
↓
18. UI renders with new messages, no spinnerNotice:
- Clear request/response pattern
- State updates are synchronous
- Async work happens outside reducer
- All changes recorded to history
Event Patterns
Pattern 1: User Action
// User triggers action
KeyPressed(key)
↓ (handler)
GenerateCommit { ... }
↓ (reducer)
State updated, SpawnAgent effect returned
↓ (executor)
Async task spawnedPattern 2: Request/Response
// User requests data
RefreshGitStatus
↓ (reducer)
State: loading=true, Effect: RefreshGitStatus
↓ (executor)
Async git status load
↓
GitStatusRefreshed event sent
↓ (reducer)
State updated with new git statusPattern 3: Agent Tool Call
// Agent uses tool to update UI
Chat: "Make commit message shorter"
↓
ChatMessage event
↓ (reducer)
SpawnAgent { task: Chat }
↓
Agent calls update_commit_message tool
↓
Tool emits UpdateContent event
↓ (reducer)
State.modes.commit.message updatedPattern 4: Confirm + Execute (modal flow)
// User triggers commit
KeyPressed('c') → OpenModal(ConfirmCommit)
↓ (reducer)
state.modal = Modal::Confirm { message, action: "commit" }
↓ (user presses Enter)
KeyPressed(Enter) → ModalConfirmed { modal_type: ConfirmCommit, data: None }
↓ (reducer)
state.modal = None
Effect: ExecuteCommit { message: format_commit_message(...) }
↓ (executor)
self.perform_commit(&message) → returns ExitResult::Committed(output)
↓
Main loop exits with ExitResult, prints outputExecuteCommit (and its sibling ExecuteAmend) are the only effects besides Quit that short-circuit the event loop.
History and Replay
Every significant event is recorded to history:
#[derive(Debug, Clone)]
pub struct History {
pub metadata: SessionMetadata, // session_id, timestamps, repo_path, branch
events: VecDeque<HistoryEntry>, // unified event log (bounded)
max_events: usize,
chat_messages: Vec<ChatMessage>, // chat across modes (bounded)
content_versions: HashMap<ContentKey, Vec<ContentVersion>>, // per (mode, content_type)
next_id: u64,
}
pub struct HistoryEntry {
pub id: u64,
pub timestamp: Instant,
pub source: EventSource,
pub change: HistoryChange, // Event, ContentUpdated, ChatMessage, ModeSwitch, AgentTaskStarted, AgentTaskCompleted
}History is the single source of truth for "what happened" across the session. SessionMetadata carries the bits that would be needed by a future thread-resume UI (session id, repo path, branch, timestamps, optional title) — today they're populated and updated, but nothing on disk reads them yet.
Use cases:
Debugging
// Print all entries leading to current state
for entry in history.events() {
println!("{:?} from {:?} at {:?}",
entry.change, entry.source, entry.timestamp);
}Session Persistence (planned)
History does not currently implement save/load. The data model carries the metadata required for thread resume; the persistence layer itself is future work. Today, history lives entirely in memory for the lifetime of the StudioApp.
Analytics
// How many content updates happened?
let updates = history.events()
.filter(|entry| matches!(entry.change, HistoryChange::ContentUpdated { .. }))
.count();
// Mode-switch trail
let switches: Vec<_> = history.events()
.filter(|entry| matches!(entry.change, HistoryChange::ModeSwitch { .. }))
.collect();Replay for Testing
#[test]
fn test_session_replay() {
let mut state = StudioState::new(config, None);
let mut history = History::new();
let events = vec![
StudioEvent::SwitchMode(Mode::Commit),
StudioEvent::GenerateCommit {
instructions: None,
preset: "default".into(),
use_gitmoji: true,
amend: false,
},
// AgentComplete event with AgentResult::CommitMessages(...) injected here
];
for event in events {
reduce(&mut state, event, &mut history);
}
assert_eq!(state.active_mode, Mode::Commit);
}Testing Events
Events are plain data, easy to construct and to assert against:
#[test]
fn test_event_construction() {
let event = StudioEvent::GenerateCommit {
instructions: Some("Be concise".into()),
preset: "default".into(),
use_gitmoji: true,
amend: false,
};
// StudioEvent is Debug + Clone, but not Eq / Hash / Serialize today —
// assert by matching on the variant rather than equality.
assert!(matches!(event, StudioEvent::GenerateCommit { .. }));
}
#[test]
fn test_history_records_source() {
let mut history = History::new();
history.record_mode_switch(Mode::Explore, Mode::Commit);
history.record_agent_start(TaskType::Commit);
history.record_agent_complete(TaskType::Commit, true);
assert_eq!(history.event_count(), 3);
let sources: Vec<_> = history.events().map(|e| e.source).collect();
assert!(matches!(sources[0], EventSource::User));
assert!(matches!(sources[2], EventSource::Agent));
}Common Pitfalls
❌ Handling Events Outside Reducer
// WRONG - bypasses reducer
fn on_agent_complete(&mut self, result: Result) {
self.state.message = result; // Direct mutation!
}// RIGHT - emit event
fn on_agent_complete(&mut self, result: Result) {
self.push_event(StudioEvent::AgentComplete {
task_type: TaskType::Commit,
result: AgentResult::CommitMessages(result),
});
}❌ Forgetting to Record to History
// WRONG - event not recorded
StudioEvent::SwitchMode(mode) => {
state.active_mode = mode; // No history!
}// RIGHT - always record
StudioEvent::SwitchMode(mode) => {
history.record_mode_switch(state.active_mode, mode);
state.active_mode = mode;
}❌ Complex Logic in Event Construction
// WRONG - logic in handler
fn handle_key(key: Key) -> StudioEvent {
let instructions = if complex_condition() {
Some(compute_instructions()) // Complex logic!
} else {
None
};
StudioEvent::GenerateCommit { instructions, ... }
}// RIGHT - simple event, logic in reducer
fn handle_key(key: Key) -> StudioEvent {
StudioEvent::GenerateCommit {
instructions: None, // Simple, reducer decides
preset: "default".into(),
use_gitmoji: true,
amend: false,
}
}Performance Considerations
Event queue batches rapid events:
// Many scroll events in quick succession
Scroll { direction: Down, amount: 1 }
Scroll { direction: Down, amount: 1 }
Scroll { direction: Down, amount: 1 }
// Can be coalesced to:
Scroll { direction: Down, amount: 3 }Event processing is fast: Simple enum matching, no I/O.
Heavy operations go in effects: Spawn async tasks for expensive work.
Debugging Events
Log all events
pub fn reduce(state: &mut State, event: Event, history: &mut History) -> Vec<Effect> {
eprintln!("[EVENT] {:?}", event);
// ...
}Filter entries by source
for entry in history.events() {
if matches!(entry.source, EventSource::Agent) {
println!("{:?}", entry);
}
}Entry statistics
use std::collections::HashMap;
let mut counts: HashMap<String, usize> = HashMap::new();
for entry in history.events() {
let key = format!("{:?}", entry.change).split('{').next().unwrap_or("").to_string();
*counts.entry(key).or_default() += 1;
}Summary
Events are the backbone of Studio architecture:
- All state changes flow through events
- Clear request/response patterns
- Complete audit trail in history
- Easy to test, trace, and debug
- Async work happens outside, results come back as events
When in doubt: If something changes state, it should be an event.
