Skip to content

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:

rust
// 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:

rust
// 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:

rust
#[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

rust
KeyPressed(KeyEvent)    // Filtered for Press kind only
Mouse(MouseEvent)       // Click, scroll, drag

Flow:

  1. Crossterm captures raw input
  2. Event loop filters/normalizes
  3. Dispatches as StudioEvent
  4. 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 }

Origin: User input, mode switching

rust
SwitchMode(Mode)        // Change to different mode
FocusPanel(PanelId)     // Focus specific panel
FocusNext               // Tab to next panel
FocusPrev               // Shift-Tab to previous

Effects:

  • Update active_mode or focused_panel
  • May trigger LoadData for new mode
  • Updates UI rendering

Content Generation Events

Origin: User requests Iris to generate content

rust
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 editor

Agent Response Events

Origin: Async agent tasks

rust
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)

rust
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 message

Why? Agent can manipulate UI state through tools. Powerful for chat interactions.

Git & File Events

Origin: Git operations, file selection

rust
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 tree

Pattern: 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 state

Origin: User opens/closes dialogs

rust
OpenModal(ModalType)
CloseModal
ModalConfirmed { modal_type, data }

Modal Types:

rust
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 = None

UI Update Events

Origin: User interactions, state changes

rust
Notify { level, message }        // Show notification
Scroll { direction, amount }     // Scroll content
ToggleEditMode                   // View ↔ Edit
NextMessageVariant               // Cycle messages
PrevMessageVariant
CopyToClipboard(String)

Lifecycle Events

Origin: Timer, shutdown

rust
Tick    // Animation frame (~250ms)
Quit    // User requests exit

Tick events:

  • Advance spinner animation
  • Clean up expired notifications
  • Update timestamps
  • Poll async channels

Event Flow

Synchronous Flow

mermaid
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 --> executor

Flow: User input → Handler maps to event → Queue → Reducer processes → State updates + Effects returned → Executor runs effects

Asynchronous Flow

mermaid
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 --> back

Flow: 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:

rust
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:

rust
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

rust
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

rust
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

rust
pub enum ContentType {
    CommitMessage,
    PRDescription,
    CodeReview,
    Changelog,
    ReleaseNotes,
}

Used in UpdateContent events when agent tools update UI state.

ContentPayload

rust
pub enum ContentPayload {
    Commit(GeneratedMessage),
    Markdown(String),
}

Data for content updates. Structured (commit) vs unstructured (markdown).

DataType

rust
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

rust
pub enum NotificationLevel {
    Info,
    Success,
    Warning,
    Error,
}

Determines notification color/icon.

ScrollDirection

rust
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:

rust
#[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

rust
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

rust
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 spinner

Notice:

  • Clear request/response pattern
  • State updates are synchronous
  • Async work happens outside reducer
  • All changes recorded to history

Event Patterns

Pattern 1: User Action

rust
// User triggers action
KeyPressed(key)
  ↓ (handler)
GenerateCommit { ... }
  ↓ (reducer)
State updated, SpawnAgent effect returned
  ↓ (executor)
Async task spawned

Pattern 2: Request/Response

rust
// User requests data
RefreshGitStatus
  ↓ (reducer)
State: loading=true, Effect: RefreshGitStatus
  ↓ (executor)
Async git status load

GitStatusRefreshed event sent
  ↓ (reducer)
State updated with new git status

Pattern 3: Agent Tool Call

rust
// 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 updated

Pattern 4: Confirm + Execute (modal flow)

rust
// 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 output

ExecuteCommit (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:

rust
#[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

rust
// 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

rust
// 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

rust
#[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:

rust
#[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

rust
// WRONG - bypasses reducer
fn on_agent_complete(&mut self, result: Result) {
    self.state.message = result;  // Direct mutation!
}
rust
// 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

rust
// WRONG - event not recorded
StudioEvent::SwitchMode(mode) => {
    state.active_mode = mode;  // No history!
}
rust
// RIGHT - always record
StudioEvent::SwitchMode(mode) => {
    history.record_mode_switch(state.active_mode, mode);
    state.active_mode = mode;
}

❌ Complex Logic in Event Construction

rust
// 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, ... }
}
rust
// 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:

rust
// 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

rust
pub fn reduce(state: &mut State, event: Event, history: &mut History) -> Vec<Effect> {
    eprintln!("[EVENT] {:?}", event);
    // ...
}

Filter entries by source

rust
for entry in history.events() {
    if matches!(entry.source, EventSource::Agent) {
        println!("{:?}", entry);
    }
}

Entry statistics

rust
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.

Released under the Apache 2.0 License.