Agent skill
gpui-patterns
GPUI framework patterns for Script Kit. Use when writing UI code, handling keyboard events, managing state, or working with layouts. Covers layout chains, lists, themes, events, focus, and window management.
Install this agent skill to your Project
npx add-skill https://github.com/johnlindquist/script-kit-next/tree/main/.claude/skills/gpui-patterns
SKILL.md
For always-loaded rules see CLAUDE.md. This skill provides detailed patterns and examples.
GPUI Patterns
Essential patterns for building UI with GPUI in Script Kit.
Quick Reference (Things That Break Most Often)
- Layout chain order: Layout (
flex*) → Sizing (w/h) → Spacing (px/gap) → Visual (bg/border) - Lists:
uniform_list(fixed height 52px) +UniformListScrollHandle - Theme colors: use
theme.colors.*(neverrgb(0x...)) - Focus colors: use
theme.get_colors(is_focused); re-render on focus change - State updates: after render-affecting changes, must
cx.notify() - Keyboard: primary pattern is
.on_key_down(handler)+crate::ui_foundation::is_key_*helpers - Printable chars:
printable_char(event.keystroke.key_char.as_deref()) - Focus events: keyboard handlers need the focus trio:
.track_focus(...) + .on_key_down(...) + .child(...)
Keyboard Handling (CRITICAL)
Import and use key helpers from crate::ui_foundation:
use crate::ui_foundation::{
is_key_backspace, is_key_delete, is_key_down, is_key_enter, is_key_escape, is_key_left,
is_key_right, is_key_space, is_key_tab, is_key_up, printable_char,
};
Register via cx.listener() and extract the key string with event.keystroke.key.as_str():
// In render():
div()
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
let key = event.keystroke.key.as_str();
if is_key_up(key) {
this.move_up(cx);
return;
}
if is_key_down(key) {
this.move_down(cx);
return;
}
if is_key_left(key) {
this.move_left(cx);
return;
}
if is_key_right(key) {
this.move_right(cx);
return;
}
if is_key_enter(key) {
this.confirm(cx);
return;
}
if is_key_escape(key) {
this.cancel(cx);
return;
}
if is_key_tab(key) || is_key_space(key) {
this.toggle(cx);
return;
}
if is_key_backspace(key) || is_key_delete(key) {
this.delete(cx);
return;
}
if let Some(ch) = printable_char(event.keystroke.key_char.as_deref()) {
this.insert_char(ch, cx);
}
}))
.child(self.render_content(window, cx))
Layout System
Chain in order: layout → sizing → spacing → visual → children.
div().flex().flex_row().items_center().gap_2();
div().flex().flex_col().w_full();
div().flex().items_center().justify_center();
div().flex_1(); // fill remaining space
Conditional rendering:
div().when(is_selected, |d| d.bg(selected)).when_some(desc, |d, s| d.child(s));
List Virtualization
Use uniform_list with fixed-height rows (~52px):
uniform_list(
"script-list",
filtered.len(),
move |visible_range, _window, _cx| {
visible_range.map(|ix| render_list_item(ix)).collect()
},
)
.h_full()
.track_scroll(&self.list_scroll_handle);
Scroll to item:
self.list_scroll_handle.scroll_to_item(selected_index, ScrollStrategy::Nearest);
Theme System
let colors = &self.theme.colors;
div().bg(rgb(colors.background.main)).border_color(rgb(colors.ui.border));
Focus-aware:
- compute
is_focused = self.focus_handle.is_focused(window) - if changed: update state +
cx.notify() - use
let colors = self.theme.get_colors(is_focused);
For closures: extract copyable structs like colors.list_item_colors().
Focus + Events
let focus_handle = cx.focus_handle();
focus_handle.focus(window);
div()
.track_focus(&self.focus_handle)
.on_key_down(Self::on_key_down)
.child(self.render_content(cx));
Without .track_focus(&self.focus_handle), key events never arrive at .on_key_down(...).
Key Propagation
After handling a key, call cx.stop_propagation() to prevent parent handlers from also firing. In the fallthrough/default arm, call cx.propagate() so unhandled keys bubble up to parent views.
// In a cx.listener key handler:
if is_key_enter(key) {
this.confirm(window, cx);
cx.stop_propagation(); // consumed — don't let parent also handle Enter
return;
}
// ... other keys ...
cx.propagate(); // unhandled — let parent try
State Management
After any state mutation affecting rendering: cx.notify()
Shared state: Arc<Mutex<T>> or channels; for async, use mpsc sender → UI receiver.
Entity Lifecycle + Async Work
Never create entities inside render(). Entity creation (cx.new()) in render() allocates a new entity every frame, leaking subscriptions and losing state. Create entities in constructors and store on the struct.
Store subscriptions on the view struct (Vec<Subscription> is a common pattern), otherwise they are dropped and stop receiving events.
pub struct PromptView {
subscriptions: Vec<Subscription>,
poll_task: Option<Task<()>>,
load_generation: u64,
}
fn wire_model(&mut self, cx: &mut Context<Self>) {
let sub = cx.subscribe(&self.model, |this, _model, event, cx| this.on_model_event(event, cx));
self.subscriptions.push(sub);
}
Use .detach() for fire-and-forget background work:
cx.spawn(async move |_this, _cx| {
send_telemetry().await;
}).detach();
For UI-updating async work, cx.spawn() gives this: WeakEntity<_> and cx: AsyncApp. Re-enter UI state with this.update(cx, |this, cx| { ... }).ok():
fn reload(&mut self, cx: &mut Context<Self>) {
self.load_generation += 1;
let generation = self.load_generation;
self.poll_task = Some(cx.spawn(async move |this, cx| {
let items = fetch_items().await;
this.update(cx, |this, cx| {
if generation != this.load_generation {
return; // stale async result
}
this.items = items;
cx.notify();
}).ok();
}));
}
Dropping a stored Task cancels it. Store tasks intentionally (Option<Task<_>> / Vec<Task<_>>) when they must stay alive.
References
- Anti-Patterns - Common mistakes that cause bugs
- Smart Pointers - Arc, Rc, Mutex patterns
- Window Management - Multi-monitor, floating panels
- Scroll Performance - Rapid-key coalescing
- Testing Patterns - GPUI test organization
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
Generate Component Documentation
Based on existing docs styles and specific API implementations, and referencing same name stories, generate comprehensive documentation for the new component.
Generate Component Story
Generate a comprehensive story for a new component for as example.
new-component
How to write a new component of GPUI Component.
troubleshooting
Diagnose and fix common Script Kit issues. Use when the user reports bugs, crashes, missing features, or unexpected behavior in Script Kit GPUI.
script-authoring
Create and manage TypeScript scripts for Script Kit. Use when the user wants to write a new script, edit an existing script, or understand Script Kit's SDK and metadata system.
agents
Create mdflow-backed agent files for Script Kit. Use when the user wants to create AI agents, configure agent backends (Claude, Gemini, Codex), or manage agent metadata.
Didn't find tool you were looking for?