gpui-ai

Guide

Ownership and events

Your application owns the state and the clock; components render what you give them.

Applications own state; components render snapshots

No component in this library fetches anything, keeps fixture data, or runs a clock of its own. Your application owns the content, the lifecycle, and the timing of the work, and hands a component the current state each frame. A component may still time something it is entirely responsible for — the “Copied” confirmation on a chat message clears itself after a couple of seconds, and dropping the chat drops it — but nothing that a snapshot describes is ever advanced from inside. That is what makes every component testable without a network, and what makes a streamed reply resumable, cancellable, and replayable by you rather than by us.

src/reply.rs
use gpui_ai::prelude::*;

// The application owns the text and the lifecycle. It advances them from
// whatever its model client hands back; the component renders the snapshot and
// never asks for more.
struct Reply {
    answer: StreamedContent,
}

impl Reply {
    fn started() -> Self {
        Self { answer: StreamedContent::new() }
    }

    fn token(&mut self, chunk: &str) {
        self.answer.append(chunk);
    }

    fn finished(&mut self) {
        self.answer.finish();
    }

    fn refused(&mut self, why: &str) {
        self.answer.fail(why);
    }
}

Progressive<T> pairs content with a lifecycle — Pending, Running, Complete, or Failed with a reason — and a revision that changes only when one of them actually does. Every component that shows progressive work consumes the same type, so there is one lifecycle to learn rather than one per component.

Events are keyed by your identifiers

Components emit typed events carrying the identifier you gave the thing, never its position in a collection. A list that reorders, filters, or loses a row while a decision is in flight still resolves to the thing the decision was about — an index would not.

src/approvals.rs
use gpui_ai::prelude::*;

// Every event carries the application's own identifier rather than a row
// number. A list that reorders, filters, or loses a row while a decision is in
// flight still resolves to the thing it was about.
fn gate(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
    ApprovalCard::new("deploy-2f9c", "Deploy build 2f9c to production")
        .decision(self.decisions.get("deploy-2f9c").copied().unwrap_or_default())
        .on_event(cx.listener(|this, event: &ApprovalEvent, _, cx| {
            let (id, decision) = match event {
                ApprovalEvent::Approved { id } | ApprovalEvent::ApprovedAlways { id } => {
                    (id.clone(), ApprovalDecision::Approved)
                }
                ApprovalEvent::Rejected { id } => (id.clone(), ApprovalDecision::Rejected),
            };
            this.decisions.insert(id, decision);
            cx.notify();
        }))
}

Cues, for the things that are not state

Some moments are worth a sound or a haptic: a reply arriving, a response settling, text copied, a prompt submitted, work cancelled, a gate decided. The library never plays audio — it says the moment happened and leaves the decision to you, in one place rather than at every call site.

src/feedback.rs
use gpui_ai::cues::{self, Cue};

// One place to hang sound, haptics, or a notification. The library never plays
// anything itself: it says what happened and leaves the decision here.
let _cues = cues::observe(cx, |cue, _cx| match cue {
    Cue::ResponseSettled { succeeded: true, .. } => play("chime"),
    Cue::Copied => play("tick"),
    _ => {}
});
// Dropping the subscription stops the cues.

Cues are hints, never state. Every cue corresponds to a typed event or a snapshot transition your application already receives, so ignoring cues entirely loses nothing but the sound.