AI components
Everything a model-facing app needs on screen: a transcript, a prompt box, streaming feedback, tool calls, citations, and the controls and meters around a request.
None of it opens a socket. guise is gpui and std, and a component library is
the wrong place to keep someone's API key — so the host owns the request and
these own what the user sees while it happens. That split is also what makes
them portable: the same AIChatView drives a local model, a hosted API, or a
replayed transcript, because all it ever receives is text.
use guise::ai::*;
The short version
let chat = cx.new(|cx| AIChatView::new(cx).max_width(760.0));
let composer = cx.new(|cx| AIComposer::new(cx).hint("Shift+Enter for a new line"));
cx.subscribe(&composer, move |this, _composer, event: &AIComposerEvent, cx| {
if let AIComposerEvent::Submit(text) = event {
this.chat.update(cx, |chat, cx| {
chat.push(AITurn::user(text.clone()), cx);
chat.begin_reply(cx);
});
this.send(text.clone(), cx); // your transport
}
})
.detach();
Then, as tokens arrive:
chat.update(cx, |chat, cx| chat.push_delta(&token, cx));
and when the reply ends, chat.end_reply(cx) — or chat.fail_reply(error, cx)
if it didn't, which keeps whatever text had already arrived and shows the error
under it.
AIChatView (entity)
The transcript. It owns the conversation so a host doesn't have to re-derive one every frame, and it owns the scroll position and the per-turn disclosure state along with it.
| Method | Notes |
|---|---|
new(cx) |
|
turns(iter) |
seed it — restoring a saved conversation |
max_width(f32) |
cap the reading width and center it |
empty_message(impl Into<SharedString>) |
shown before anything is said |
size(Size) |
|
push(AITurn, cx) -> usize |
append a turn, returning its index |
begin_reply(cx) -> usize |
open an empty assistant turn to stream into |
push_delta(&str, cx) |
append to the open turn |
push_reasoning(&str, cx) |
append to the open turn's reasoning |
end_reply(cx) / fail_reply(error, cx) |
close it |
set_pending(Some("Searching…"), cx) |
a "working on it" line under the transcript |
update_turn(i, edit, cx) |
edit in place — attaching a tool result |
clear(cx) / scroll_to_bottom(cx) |
|
all() / turn(i) / turn_count() |
read the transcript |
A delta that arrives with no turn open is dropped rather than resurrecting a finished one, so a cancelled request whose last chunk was already in flight can't reopen it.
Stick-to-bottom. A transcript that auto-scrolls unconditionally rips the page away from someone reading back through it; one that never scrolls leaves the newest text off screen. So it follows the tail only while the view is already at the tail. Scrolling up detaches it; scrolling back within a couple of lines of the end re-attaches, and so does sending — you just acted, so you want to see the result.
It emits AIChatViewEvent::OpenSource(turn, source) when a citation or a
source row is clicked.
AITurn
AITurn::user("What changed in 1.5?")
AITurn::assistant(reply).name("claude-opus-5").meta("2.1s · 412 tokens")
AITurn::system("You are a careful engineer.")
Fields: role, body, reasoning / reasoning_open, tools, sources,
streaming, error, name, meta.
AIMessage
One turn, if you'd rather lay the list out yourself. The user's turn is a contained bubble, the assistant's runs full width, system and tool turns are quiet marginalia. The body is markdown; anything else — a tool card, a diff, a rating widget — arrives as a child.
AIMessage::new(AIRole::Assistant, reply)
.streaming(true)
.meta("1.4s")
.child(AIToolCall::new(("tool", 0), "read_file").status(AIToolStatus::Ok))
AIRole is User, Assistant, System, or Tool.
AIComposer (entity)
The prompt box. Enter sends and Shift+Enter breaks the line, the box grows with what's in it up to a ceiling, and the send button becomes a stop button while a reply is in flight — being able to interrupt a long generation is the control people reach for most.
let composer = cx.new(|cx| {
AIComposer::new(cx)
.attachments(true)
.hint("Claude can make mistakes. Check important info.")
});
composer.update(cx, |c, cx| c.set_busy(true, cx)); // send → stop
Emits AIComposerEvent::{Submit(String), Stop, Attach, Change(String)}. It
clears itself on submit and refuses a blank draft — sending whitespace to a
model is never what was meant.
AIStreamingText and AIThinking
AIStreamingText renders exactly what Markdown renders and puts a blinking
block on the end, the way a terminal shows a process still writing. It takes
the whole text every frame rather than a delta, because that is what a render
pass has.
AIThinking covers the gap before the first token, which can run to several
seconds and reads as a hang if nothing moves. Give it a specific label —
"Searching the web" is worth far more than "Thinking".
AIThinking::new().label("Running tests")
AIReasoning
Extended thinking, folded away — it is usually longer than the answer and not what the reader came for. While it is still streaming the header says so, which is the one case where someone wants to know before they open it.
Open state belongs to whatever owns the transcript, so it is controlled:
AIReasoning::new(("reasoning", i), text)
.open(turn.reasoning_open)
.streaming(turn.streaming)
.on_toggle(cx.listener(|this, _, _, cx| this.toggle_reasoning(i, cx)))
AIToolCall
What the model did and whether it worked. Name and status always visible, arguments and result folded away until asked. Status is the load-bearing part: a stalled tool is the most common way an assistant appears broken.
AIToolCall::new(("tool", i), "read_file")
.status(AIToolStatus::Running)
.arguments(r#"{"path": "src/main.rs"}"#)
.result(preview)
.meta("120ms")
.open(expanded)
.on_toggle(...)
AIToolStatus is Pending, Running, Ok, or Error. An errored card is
outlined in the danger color and labels its result "Error".
The fold affordance appears when arguments or result is given. Pass
expandable(true) to offer it anyway — AIChatView does, so it can withhold a
tens-of-kilobytes result until the card is actually open.
AICitation and AISources
A citation is only useful if it is reachable, so AICitation is a click
target, not a decoration, and AISources is the numbered list it points at.
AICitation::new(("cite", i), 1).label("docs.rs").on_click(...)
AISources::new(sources)
.excerpts(true)
.on_open(|index, _window, cx| { /* open it */ })
AISource::new(title, location).excerpt(passage) — the excerpt matters:
"it says so on this page" and "it says so in this sentence" are different
claims.
AIModelPicker (entity)
A model is more than a name — which one is selected changes what a request
costs and how much context it has, and both matter at the moment of choosing.
So each row carries its description and context size, and the picker hands back
the whole AIModel.
let picker = cx.new(|cx| {
AIModelPicker::new(cx)
.models([
AIModel::new("claude-opus-5", "Opus 5")
.description("Deepest reasoning")
.context(200_000)
.pricing(AIPricing::new(15.0, 75.0)),
AIModel::new("claude-sonnet-5", "Sonnet 5")
.description("Balanced")
.context(200_000)
.pricing(AIPricing::new(3.0, 15.0)),
])
.selected_id("claude-sonnet-5")
});
Emits AIModelPickerEvent(AIModel). set_models keeps the selection on the
same id when it survives the swap.
AITokenMeter, AICost, AISettings
AITokenMeter::new(used, limit) turns context exhaustion — the failure people
hit without warning — into something visible a few turns early: amber at 75%,
red at 90%.
AICost::new(usage, pricing) keeps a running total. The arithmetic lives here
because getting it wrong by a factor of a thousand is easy — prices are quoted
per million tokens:
let usage = AIUsage::new(1_200, 800).cache_read(40_000);
let pricing = AIPricing::new(3.0, 15.0).cache_read(0.30);
AICost::new(usage, pricing).breakdown(true)
AIUsage adds with + and sums with .sum(), saturating rather than wrapping.
AISettings is the two knobs every provider takes: temperature as a slider,
because the useful range is narrow and the exact value rarely matters, and max
tokens as a number field, because it does. Both clamp before they emit, so the
event can go straight into a request. ceiling(tokens, cx) follows the
selected model and pulls the value down with it, so the pair can never describe
an impossible request.
Markdown
Message bodies are rendered by Markdown, a read-only RenderOnce builder
that works anywhere:
div().child(Markdown::new("# Notes\n\n- **bold** and `code`"))
It shares the three pure passes MarkdownEditor uses — headings, lists, task
boxes, quotes, fenced code, rules, links, emphasis — without the caret, scroll
model, or hand-rolled glyph layout an editor needs.