feat: add Dioxus and GPUI UI prototypes for framework comparison

Dioxus (ui-dioxus/): 2,169 lines, WebView mode (same wry as Tauri),
  Catppuccin theme, 12 components, agor-core integration, compiles clean.
  Evolution path — keeps xterm.js, gradual migration from Tauri.

GPUI (ui-gpui/): 2,490 lines, GPU-accelerated rendering, alacritty_terminal
  for native terminal, 17 files, Catppuccin palette, demo data.
  Revolution path — pure Rust UI, 120fps target, no WebView.

Both are standalone (not in workspace), share agor-core backend.
Created for side-by-side comparison to inform framework decision.
This commit is contained in:
Hibryda 2026-03-19 06:05:58 +01:00
parent 90c7315336
commit f3d2ca78ba
34 changed files with 17467 additions and 0 deletions

7452
ui-gpui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

20
ui-gpui/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "agor-gpui"
version = "0.1.0"
edition = "2021"
description = "GPU-accelerated Agent Orchestrator UI prototype using Zed's GPUI framework"
license = "MIT"
# Standalone — NOT part of the workspace Cargo.toml
# Build with: cd ui-gpui && cargo build
[workspace]
[dependencies]
gpui = "0.2"
agor-core = { path = "../agor-core" }
alacritty_terminal = "0.25"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
dirs = "5"

129
ui-gpui/src/backend.rs Normal file
View file

@ -0,0 +1,129 @@
//! Bridge to agor-core: PtyManager, SidecarManager, EventSink.
//!
//! Implements the `EventSink` trait from agor-core so that PTY and sidecar
//! events flow into GPUI's entity system via channel-based async dispatch.
use agor_core::event::EventSink;
use agor_core::pty::{PtyManager, PtyOptions};
use agor_core::sandbox::SandboxConfig;
use agor_core::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{mpsc, Arc};
// ── GPUI EventSink ──────────────────────────────────────────────────
/// Event payload sent from backend threads to the GPUI main thread.
#[derive(Debug, Clone)]
pub struct BackendEvent {
pub event_name: String,
pub payload: serde_json::Value,
}
/// An `EventSink` that queues events into an `mpsc` channel.
/// The GPUI main loop drains this channel each frame to update entity state.
pub struct GpuiEventSink {
sender: mpsc::Sender<BackendEvent>,
}
impl GpuiEventSink {
pub fn new(sender: mpsc::Sender<BackendEvent>) -> Self {
Self { sender }
}
}
impl EventSink for GpuiEventSink {
fn emit(&self, event: &str, payload: serde_json::Value) {
let _ = self.sender.send(BackendEvent {
event_name: event.to_string(),
payload,
});
}
}
// ── Backend Manager ─────────────────────────────────────────────────
/// Owns the agor-core managers and the event channel.
/// Created once at app startup; shared via `Arc` or `Entity<Backend>`.
pub struct Backend {
pub pty_manager: PtyManager,
pub sidecar_manager: SidecarManager,
pub event_rx: mpsc::Receiver<BackendEvent>,
}
impl Backend {
/// Create backend with default sidecar search paths.
pub fn new() -> Self {
let (tx, rx) = mpsc::channel();
let sink: Arc<dyn EventSink> = Arc::new(GpuiEventSink::new(tx));
let pty_manager = PtyManager::new(Arc::clone(&sink));
// Sidecar search paths: look next to the binary, then common install locations
let mut search_paths = vec![
PathBuf::from("./sidecar/dist"),
PathBuf::from("../sidecar/dist"),
];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
search_paths.push(dir.join("sidecar"));
}
}
let sidecar_config = SidecarConfig {
search_paths,
env_overrides: HashMap::new(),
sandbox: SandboxConfig::default(),
};
let sidecar_manager = SidecarManager::new(Arc::clone(&sink), sidecar_config);
Self {
pty_manager,
sidecar_manager,
event_rx: rx,
}
}
/// Spawn a PTY for a project terminal.
pub fn spawn_pty(&self, cwd: &str) -> Result<String, String> {
self.pty_manager.spawn(PtyOptions {
shell: None,
cwd: Some(cwd.to_string()),
args: None,
cols: Some(120),
rows: Some(30),
})
}
/// Start an agent query (sends to sidecar, non-blocking).
pub fn start_agent(&self, session_id: &str, prompt: &str, cwd: &str) {
let options = AgentQueryOptions {
provider: "claude".to_string(),
session_id: session_id.to_string(),
prompt: prompt.to_string(),
cwd: Some(cwd.to_string()),
max_turns: None,
max_budget_usd: None,
resume_session_id: None,
permission_mode: Some("bypassPermissions".to_string()),
setting_sources: Some(vec!["user".to_string(), "project".to_string()]),
system_prompt: None,
model: None,
claude_config_dir: None,
additional_directories: None,
worktree_name: None,
provider_config: serde_json::Value::Null,
extra_env: HashMap::new(),
};
let _ = self.sidecar_manager.query(&options);
}
/// Drain all pending backend events (call once per frame / tick).
pub fn drain_events(&self) -> Vec<BackendEvent> {
let mut events = Vec::new();
while let Ok(ev) = self.event_rx.try_recv() {
events.push(ev);
}
events
}
}

View file

@ -0,0 +1,346 @@
//! Agent Pane: scrollable message list + prompt input.
//!
//! Shows user/assistant messages with different styling, tool call blocks,
//! status indicator, and a prompt input field at the bottom.
use gpui::*;
use crate::state::{AgentMessage, AgentSession, AgentStatus, MessageRole};
use crate::theme;
// ── Status Dot ──────────────────────────────────────────────────────
fn status_dot(status: AgentStatus) -> Div {
let color = match status {
AgentStatus::Idle => theme::OVERLAY0,
AgentStatus::Running => theme::GREEN,
AgentStatus::Done => theme::BLUE,
AgentStatus::Error => theme::RED,
};
div()
.w(px(8.0))
.h(px(8.0))
.rounded(px(4.0))
.bg(color)
}
fn status_label(status: AgentStatus) -> &'static str {
match status {
AgentStatus::Idle => "Idle",
AgentStatus::Running => "Running",
AgentStatus::Done => "Done",
AgentStatus::Error => "Error",
}
}
// ── Message Bubble ──────────────────────────────────────────────────
fn render_message(msg: &AgentMessage) -> Div {
let (bg, text_col) = match msg.role {
MessageRole::User => (theme::SURFACE0, theme::TEXT),
MessageRole::Assistant => (theme::with_alpha(theme::BLUE, 0.08), theme::TEXT),
MessageRole::System => (theme::with_alpha(theme::MAUVE, 0.08), theme::SUBTEXT0),
};
let role_label = match msg.role {
MessageRole::User => "You",
MessageRole::Assistant => "Claude",
MessageRole::System => "System",
};
let mut bubble = div()
.max_w(rems(40.0))
.rounded(px(8.0))
.bg(bg)
.px(px(12.0))
.py(px(8.0))
.flex()
.flex_col()
.gap(px(4.0));
// Role label
bubble = bubble.child(
div()
.text_size(px(11.0))
.text_color(theme::OVERLAY1)
.child(role_label.to_string()),
);
// Tool call indicator
if let Some(ref tool_name) = msg.tool_name {
bubble = bubble.child(
div()
.flex()
.flex_row()
.items_center()
.gap(px(4.0))
.px(px(6.0))
.py(px(2.0))
.rounded(px(4.0))
.bg(theme::with_alpha(theme::PEACH, 0.12))
.text_size(px(11.0))
.text_color(theme::PEACH)
.child(format!("\u{2699} {tool_name}")),
);
}
// Content
bubble = bubble.child(
div()
.text_size(px(13.0))
.text_color(text_col)
.child(msg.content.clone()),
);
// Tool result
if let Some(ref result) = msg.tool_result {
let truncated = if result.len() > 300 {
format!("{}...", &result[..300])
} else {
result.clone()
};
bubble = bubble.child(
div()
.mt(px(4.0))
.px(px(8.0))
.py(px(6.0))
.rounded(px(4.0))
.bg(theme::MANTLE)
.text_size(px(11.0))
.text_color(theme::SUBTEXT0)
.font_family("JetBrains Mono")
.child(truncated),
);
}
// Wrap in a row for alignment
let mut row = div().w_full().flex();
match msg.role {
MessageRole::User => {
// Push bubble to the right
row = row
.child(div().flex_1())
.child(bubble);
}
_ => {
// Push bubble to the left
row = row
.child(bubble)
.child(div().flex_1());
}
}
row
}
// ── Agent Pane View ─────────────────────────────────────────────────
pub struct AgentPane {
pub session: AgentSession,
pub prompt_text: String,
}
impl AgentPane {
pub fn new(session: AgentSession) -> Self {
Self {
session,
prompt_text: String::new(),
}
}
/// Create a pane pre-populated with demo messages for visual testing.
pub fn with_demo_messages() -> Self {
let messages = vec![
AgentMessage {
id: "1".into(),
role: MessageRole::User,
content: "Add error handling to the PTY spawn function. It should log failures and return a Result.".into(),
timestamp: 1710000000,
tool_name: None,
tool_result: None,
collapsed: false,
},
AgentMessage {
id: "2".into(),
role: MessageRole::Assistant,
content: "I'll add proper error handling to the PTY spawn function. Let me first read the current implementation.".into(),
timestamp: 1710000001,
tool_name: None,
tool_result: None,
collapsed: false,
},
AgentMessage {
id: "3".into(),
role: MessageRole::Assistant,
content: "Reading the PTY module...".into(),
timestamp: 1710000002,
tool_name: Some("Read".into()),
tool_result: Some("pub fn spawn(&self, options: PtyOptions) -> Result<String, String> {\n let pty_system = native_pty_system();\n // ...\n}".into()),
collapsed: false,
},
AgentMessage {
id: "4".into(),
role: MessageRole::Assistant,
content: "The function already returns `Result<String, String>`. I'll improve the error types and add logging.".into(),
timestamp: 1710000003,
tool_name: None,
tool_result: None,
collapsed: false,
},
AgentMessage {
id: "5".into(),
role: MessageRole::Assistant,
content: "Applying changes to agor-core/src/pty.rs".into(),
timestamp: 1710000004,
tool_name: Some("Edit".into()),
tool_result: Some("Applied 3 edits to agor-core/src/pty.rs".into()),
collapsed: false,
},
AgentMessage {
id: "6".into(),
role: MessageRole::Assistant,
content: "Done. I've added:\n1. Structured PtyError enum replacing raw String errors\n2. log::error! calls on spawn failure with context\n3. Graceful fallback when SHELL env var is missing".into(),
timestamp: 1710000005,
tool_name: None,
tool_result: None,
collapsed: false,
},
];
let mut session = AgentSession::new();
session.messages = messages;
session.status = AgentStatus::Done;
session.cost_usd = 0.0142;
session.tokens_used = 3847;
Self {
session,
prompt_text: String::new(),
}
}
}
impl Render for AgentPane {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let status = self.session.status;
// Build message list
let mut message_list = div()
.id("message-list")
.flex_1()
.w_full()
.flex()
.flex_col()
.gap(px(8.0))
.p(px(12.0))
.overflow_y_scroll();
if self.session.messages.is_empty() {
message_list = message_list.child(
div()
.flex_1()
.flex()
.items_center()
.justify_center()
.text_size(px(14.0))
.text_color(theme::OVERLAY0)
.child("Start a conversation with your agent..."),
);
} else {
for msg in &self.session.messages {
message_list = message_list.child(render_message(msg));
}
}
div()
.id("agent-pane")
.w_full()
.flex_1()
.flex()
.flex_col()
.bg(theme::BASE)
// ── Status strip ────────────────────────────────────
.child(
div()
.w_full()
.h(px(28.0))
.flex()
.flex_row()
.items_center()
.px(px(10.0))
.gap(px(6.0))
.bg(theme::MANTLE)
.border_b_1()
.border_color(theme::SURFACE0)
.child(status_dot(status))
.child(
div()
.text_size(px(11.0))
.text_color(theme::SUBTEXT0)
.child(status_label(status).to_string()),
)
.child(div().flex_1())
// Cost
.child(
div()
.text_size(px(11.0))
.text_color(theme::OVERLAY0)
.child(format!("${:.4}", self.session.cost_usd)),
)
// Tokens
.child(
div()
.text_size(px(11.0))
.text_color(theme::OVERLAY0)
.child(format!("{}tok", self.session.tokens_used)),
)
// Model
.child(
div()
.text_size(px(10.0))
.text_color(theme::OVERLAY0)
.px(px(6.0))
.py(px(1.0))
.rounded(px(3.0))
.bg(theme::SURFACE0)
.child(self.session.model.clone()),
),
)
// ── Message list (scrollable) ───────────────────────
.child(message_list)
// ── Prompt input ────────────────────────────────────
.child(
div()
.w_full()
.px(px(12.0))
.py(px(8.0))
.border_t_1()
.border_color(theme::SURFACE0)
.child(
div()
.id("prompt-input")
.w_full()
.min_h(px(36.0))
.px(px(12.0))
.py(px(8.0))
.rounded(px(8.0))
.bg(theme::SURFACE0)
.border_1()
.border_color(theme::SURFACE1)
.text_size(px(13.0))
.text_color(theme::TEXT)
.child(
if self.prompt_text.is_empty() {
div()
.text_color(theme::OVERLAY0)
.child("Ask Claude anything... (Enter to send)")
} else {
div().child(self.prompt_text.clone())
},
),
),
)
}
}

View file

@ -0,0 +1,163 @@
//! Command Palette: Ctrl+K modal overlay with filtered command list.
//!
//! Spotlight-style floating panel centered in the window.
use gpui::*;
use crate::state::AppState;
use crate::theme;
// ── Command Row ─────────────────────────────────────────────────────
fn command_row(label: &str, shortcut: Option<&str>, index: usize) -> Stateful<Div> {
div()
.id(SharedString::from(format!("cmd-{index}")))
.w_full()
.flex()
.flex_row()
.items_center()
.justify_between()
.px(px(12.0))
.py(px(6.0))
.rounded(px(4.0))
.cursor_pointer()
.hover(|s| s.bg(theme::blue_tint()))
.child(
div()
.text_size(px(13.0))
.text_color(theme::TEXT)
.child(label.to_string()),
)
.child(
if let Some(sc) = shortcut {
div()
.px(px(6.0))
.py(px(2.0))
.rounded(px(3.0))
.bg(theme::SURFACE0)
.text_size(px(10.0))
.text_color(theme::SUBTEXT0)
.child(sc.to_string())
} else {
div()
},
)
}
// ── CommandPalette View ─────────────────────────────────────────────
pub struct CommandPalette {
app_state: Entity<AppState>,
}
impl CommandPalette {
pub fn new(app_state: Entity<AppState>) -> Self {
Self { app_state }
}
}
impl Render for CommandPalette {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.app_state.read(cx);
let commands = state.filtered_commands();
let query = state.palette_query.clone();
// Full-screen overlay with semi-transparent backdrop
div()
.id("palette-backdrop")
.absolute()
.top(px(0.0))
.left(px(0.0))
.size_full()
.flex()
.items_start()
.justify_center()
.pt(px(80.0))
.bg(theme::with_alpha(theme::CRUST, 0.60))
.on_mouse_down(MouseButton::Left, {
let app_state = self.app_state.clone();
move |_ev: &MouseDownEvent, _win: &mut Window, cx: &mut App| {
app_state.update(cx, |s, cx| {
s.palette_open = false;
cx.notify();
});
}
})
// Palette card
.child(
div()
.id("palette-card")
.w(px(480.0))
.max_h(px(360.0))
.flex()
.flex_col()
.bg(theme::MANTLE)
.rounded(px(12.0))
.border_1()
.border_color(theme::SURFACE1)
.shadow_lg()
.overflow_hidden()
// Search input
.child(
div()
.w_full()
.px(px(14.0))
.py(px(10.0))
.border_b_1()
.border_color(theme::SURFACE0)
.child(
div()
.w_full()
.h(px(32.0))
.px(px(10.0))
.flex()
.items_center()
.rounded(px(6.0))
.bg(theme::SURFACE0)
.text_size(px(13.0))
.text_color(if query.is_empty() {
theme::OVERLAY0
} else {
theme::TEXT
})
.child(if query.is_empty() {
"Type a command...".to_string()
} else {
query
}),
),
)
// Command list
.child({
let mut list = div()
.id("palette-list")
.flex_1()
.w_full()
.flex()
.flex_col()
.p(px(6.0))
.gap(px(2.0))
.overflow_y_scroll();
for (i, cmd) in commands.iter().enumerate() {
list = list.child(command_row(cmd.label, cmd.shortcut, i));
}
if commands.is_empty() {
list = list.child(
div()
.w_full()
.py(px(20.0))
.flex()
.justify_center()
.text_size(px(13.0))
.text_color(theme::OVERLAY0)
.child("No matching commands"),
);
}
list
}),
)
}
}

View file

@ -0,0 +1,7 @@
pub mod agent_pane;
pub mod command_palette;
pub mod project_box;
pub mod project_grid;
pub mod settings;
pub mod sidebar;
pub mod status_bar;

View file

@ -0,0 +1,278 @@
//! ProjectBox: individual project card with header, tab bar, content area.
//!
//! Each project gets a card in the grid with:
//! - Header: name, status dot, CWD
//! - Tab bar: Model / Docs / Files
//! - Content area: AgentPane (Model tab), placeholder (Docs/Files)
//! - Terminal section in Model tab
use gpui::*;
use crate::state::{AgentStatus, Project, ProjectTab};
use crate::theme;
// ── Accent Colors by Index ──────────────────────────────────────────
fn accent_color(index: usize) -> Rgba {
const ACCENTS: [Rgba; 8] = [
theme::BLUE,
theme::MAUVE,
theme::GREEN,
theme::PEACH,
theme::PINK,
theme::TEAL,
theme::SAPPHIRE,
theme::LAVENDER,
];
ACCENTS[index % ACCENTS.len()]
}
// ── Status Indicator ────────────────────────────────────────────────
fn project_status_dot(status: AgentStatus) -> Div {
let color = match status {
AgentStatus::Idle => theme::OVERLAY0,
AgentStatus::Running => theme::GREEN,
AgentStatus::Done => theme::BLUE,
AgentStatus::Error => theme::RED,
};
div()
.w(px(8.0))
.h(px(8.0))
.rounded(px(4.0))
.bg(color)
}
// ── Tab Button ──────────────────────────────────────────────────────
fn tab_button(label: &str, active: bool, accent: Rgba) -> Div {
let fg = if active { accent } else { theme::SUBTEXT0 };
let mut tab = div()
.px(px(10.0))
.py(px(4.0))
.text_size(px(11.0))
.text_color(fg)
.cursor_pointer()
.hover(|s| s.text_color(theme::TEXT));
if active {
tab = tab.border_b_2().border_color(accent);
}
tab.child(label.to_string())
}
// ── ProjectBox View ─────────────────────────────────────────────────
pub struct ProjectBox {
pub project: Project,
/// Entity handle for the embedded AgentPane (Model tab)
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
/// Entity handle for the embedded terminal (Model tab)
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
}
impl ProjectBox {
pub fn new(project: Project) -> Self {
Self {
project,
agent_pane: None,
terminal_view: None,
}
}
/// Initialize sub-views. Must be called after the ProjectBox entity is created.
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
// Create agent pane with demo messages
let agent_pane = cx.new(|_cx| {
crate::components::agent_pane::AgentPane::with_demo_messages()
});
self.agent_pane = Some(agent_pane);
// Create terminal view with demo content
let terminal_view = cx.new(|_cx| {
let mut tv = crate::terminal::renderer::TerminalView::new(120, 10);
tv.feed_demo();
tv
});
self.terminal_view = Some(terminal_view);
}
}
impl Render for ProjectBox {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let accent = accent_color(self.project.accent_index);
let name = self.project.name.clone();
let cwd = self.project.cwd.clone();
let status = self.project.agent.status;
let active_tab = self.project.active_tab;
// Build content area based on active tab
let content = match active_tab {
ProjectTab::Model => {
let mut model_content = div()
.id(SharedString::from(format!("model-content-{}", self.project.id)))
.flex_1()
.w_full()
.flex()
.flex_col();
// Agent pane (upper portion)
if let Some(ref pane) = self.agent_pane {
model_content = model_content.child(
div()
.flex_1()
.w_full()
.child(pane.clone()),
);
}
// Resize handle
model_content = model_content.child(
div()
.id(SharedString::from(format!("resize-{}", self.project.id)))
.w_full()
.h(px(4.0))
.bg(theme::SURFACE0)
.cursor_pointer()
.hover(|s| s.bg(theme::SURFACE1)),
);
// Terminal view
if let Some(ref term) = self.terminal_view {
model_content = model_content.child(
div()
.w_full()
.h(px(180.0))
.child(term.clone()),
);
}
model_content
}
ProjectTab::Docs => {
div()
.id(SharedString::from(format!("docs-content-{}", self.project.id)))
.flex_1()
.w_full()
.flex()
.items_center()
.justify_center()
.text_size(px(14.0))
.text_color(theme::OVERLAY0)
.child("Documentation viewer — renders project markdown files")
}
ProjectTab::Files => {
div()
.id(SharedString::from(format!("files-content-{}", self.project.id)))
.flex_1()
.w_full()
.flex()
.flex_col()
.p(px(12.0))
.gap(px(4.0))
.child(
div()
.text_size(px(12.0))
.text_color(theme::SUBTEXT0)
.child("src/"),
)
.child(
div()
.pl(px(16.0))
.text_size(px(12.0))
.text_color(theme::TEXT)
.child("main.rs"),
)
.child(
div()
.pl(px(16.0))
.text_size(px(12.0))
.text_color(theme::TEXT)
.child("lib.rs"),
)
.child(
div()
.text_size(px(12.0))
.text_color(theme::SUBTEXT0)
.child("Cargo.toml"),
)
}
};
div()
.id(SharedString::from(format!("project-{}", self.project.id)))
.flex_1()
.min_w(px(400.0))
.min_h(px(300.0))
.flex()
.flex_col()
.bg(theme::BASE)
.rounded(px(8.0))
.border_1()
.border_color(theme::SURFACE0)
.overflow_hidden()
// ── Header ──────────────────────────────────────────
.child(
div()
.w_full()
.h(px(36.0))
.flex()
.flex_row()
.items_center()
.px(px(12.0))
.gap(px(8.0))
.bg(theme::MANTLE)
.border_b_1()
.border_color(theme::SURFACE0)
// Accent stripe on left
.child(
div()
.w(px(3.0))
.h(px(20.0))
.rounded(px(2.0))
.bg(accent),
)
// Status dot
.child(project_status_dot(status))
// Project name
.child(
div()
.text_size(px(13.0))
.text_color(theme::TEXT)
.child(name),
)
.child(div().flex_1())
// CWD (ellipsized)
.child(
div()
.text_size(px(10.0))
.text_color(theme::OVERLAY0)
.max_w(px(200.0))
.overflow_hidden()
.whitespace_nowrap()
.child(cwd),
),
)
// ── Tab Bar ─────────────────────────────────────────
.child(
div()
.w_full()
.h(px(32.0))
.flex()
.flex_row()
.items_center()
.px(px(8.0))
.gap(px(2.0))
.bg(theme::MANTLE)
.border_b_1()
.border_color(theme::SURFACE0)
.child(tab_button("Model", active_tab == ProjectTab::Model, accent))
.child(tab_button("Docs", active_tab == ProjectTab::Docs, accent))
.child(tab_button("Files", active_tab == ProjectTab::Files, accent)),
)
// ── Content Area ────────────────────────────────────
.child(content)
}
}

View file

@ -0,0 +1,80 @@
//! ProjectGrid: flex-wrap grid of ProjectBox cards.
//!
//! Lays out projects in a responsive grid that wraps when the window is wide
//! enough for multiple columns.
use gpui::*;
use crate::components::project_box::ProjectBox;
use crate::state::AppState;
use crate::theme;
// ── ProjectGrid View ────────────────────────────────────────────────
pub struct ProjectGrid {
app_state: Entity<AppState>,
/// One ProjectBox entity per project.
project_boxes: Vec<Entity<ProjectBox>>,
}
impl ProjectGrid {
pub fn new(app_state: Entity<AppState>, cx: &mut Context<Self>) -> Self {
// Clone projects out of state to avoid borrowing cx through app_state
let projects: Vec<_> = {
let state = app_state.read(cx);
state.projects.clone()
};
let project_boxes: Vec<Entity<ProjectBox>> = projects
.into_iter()
.map(|proj| {
cx.new(|cx| {
let mut pb = ProjectBox::new(proj);
pb.init_subviews(cx);
pb
})
})
.collect();
Self {
app_state,
project_boxes,
}
}
}
impl Render for ProjectGrid {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let mut grid = div()
.id("project-grid")
.flex_1()
.w_full()
.h_full()
.flex()
.flex_row()
.flex_wrap()
.gap(px(8.0))
.p(px(8.0))
.bg(theme::CRUST)
.overflow_y_scroll();
for pb in &self.project_boxes {
grid = grid.child(pb.clone());
}
if self.project_boxes.is_empty() {
grid = grid.child(
div()
.flex_1()
.flex()
.items_center()
.justify_center()
.text_size(px(16.0))
.text_color(theme::OVERLAY0)
.child("No projects. Press Ctrl+N to add one."),
);
}
grid
}
}

View file

@ -0,0 +1,187 @@
//! Settings panel: overlay drawer for theme selection and basic config.
//!
//! Slides in from the left when the sidebar settings button is clicked.
use gpui::*;
use crate::state::AppState;
use crate::theme;
// ── Section Header ──────────────────────────────────────────────────
fn section_header(label: &str) -> Div {
div()
.w_full()
.py(px(8.0))
.text_size(px(11.0))
.text_color(theme::OVERLAY1)
.border_b_1()
.border_color(theme::SURFACE0)
.child(label.to_string())
}
// ── Setting Row ─────────────────────────────────────────────────────
fn setting_row(label: &str, value: &str) -> Div {
div()
.w_full()
.flex()
.flex_row()
.items_center()
.justify_between()
.py(px(6.0))
.child(
div()
.text_size(px(12.0))
.text_color(theme::TEXT)
.child(label.to_string()),
)
.child(
div()
.px(px(8.0))
.py(px(3.0))
.rounded(px(4.0))
.bg(theme::SURFACE0)
.text_size(px(11.0))
.text_color(theme::SUBTEXT0)
.child(value.to_string()),
)
}
// ── Theme Option ────────────────────────────────────────────────────
fn theme_option(name: &str, selected: bool) -> Stateful<Div> {
let bg = if selected {
theme::blue_wash()
} else {
theme::SURFACE0
};
let fg = if selected { theme::BLUE } else { theme::TEXT };
div()
.id(SharedString::from(format!("theme-{name}")))
.w_full()
.px(px(10.0))
.py(px(6.0))
.rounded(px(4.0))
.bg(bg)
.text_size(px(12.0))
.text_color(fg)
.cursor_pointer()
.hover(|s| s.bg(theme::hover_bg()))
.child(name.to_string())
}
// ── Settings Panel View ─────────────────────────────────────────────
pub struct SettingsPanel {
app_state: Entity<AppState>,
}
impl SettingsPanel {
pub fn new(app_state: Entity<AppState>) -> Self {
Self { app_state }
}
}
impl Render for SettingsPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.app_state.read(cx);
let settings = &state.settings;
div()
.id("settings-panel")
.w(px(280.0))
.h_full()
.flex()
.flex_col()
.bg(theme::MANTLE)
.border_r_1()
.border_color(theme::SURFACE0)
// Header
.child(
div()
.w_full()
.h(px(40.0))
.flex()
.flex_row()
.items_center()
.px(px(14.0))
.border_b_1()
.border_color(theme::SURFACE0)
.child(
div()
.text_size(px(13.0))
.text_color(theme::TEXT)
.child("Settings"),
)
.child(div().flex_1())
.child(
div()
.id("close-settings")
.w(px(24.0))
.h(px(24.0))
.flex()
.items_center()
.justify_center()
.rounded(px(4.0))
.text_size(px(14.0))
.text_color(theme::SUBTEXT0)
.cursor_pointer()
.hover(|s| s.bg(theme::SURFACE0))
.child("\u{2715}") // X
.on_click({
let app_state = self.app_state.clone();
move |_ev: &ClickEvent, _win: &mut Window, cx: &mut App| {
app_state.update(cx, |s, cx| {
s.settings_open = false;
cx.notify();
});
}
}),
),
)
// Scrollable content
.child(
div()
.id("settings-scroll")
.flex_1()
.w_full()
.flex()
.flex_col()
.gap(px(4.0))
.p(px(14.0))
.overflow_y_scroll()
// ── Appearance ───────────────────────────────
.child(section_header("APPEARANCE"))
.child(
div()
.flex()
.flex_col()
.gap(px(4.0))
.child(theme_option("Catppuccin Mocha", settings.theme == "Catppuccin Mocha"))
.child(theme_option("Catppuccin Macchiato", settings.theme == "Catppuccin Macchiato"))
.child(theme_option("Catppuccin Frappe", settings.theme == "Catppuccin Frappe"))
.child(theme_option("Tokyo Night", settings.theme == "Tokyo Night"))
.child(theme_option("Dracula", settings.theme == "Dracula"))
.child(theme_option("Nord", settings.theme == "Nord")),
)
// ── Typography ───────────────────────────────
.child(section_header("TYPOGRAPHY"))
.child(setting_row("UI Font", &settings.ui_font_family))
.child(setting_row(
"UI Font Size",
&format!("{:.0}px", settings.ui_font_size),
))
.child(setting_row("Terminal Font", &settings.term_font_family))
.child(setting_row(
"Terminal Font Size",
&format!("{:.0}px", settings.term_font_size),
))
// ── Defaults ─────────────────────────────────
.child(section_header("DEFAULTS"))
.child(setting_row("Shell", &settings.default_shell))
.child(setting_row("Working Directory", &settings.default_cwd)),
)
}
}

View file

@ -0,0 +1,97 @@
//! Sidebar: narrow icon rail on the left.
//!
//! Contains icon buttons for settings and project management.
//! Matches the existing Tauri app's GlobalTabBar (2.75rem icon rail).
use gpui::*;
use crate::state::AppState;
use crate::theme;
// ── Sidebar View ────────────────────────────────────────────────────
pub struct Sidebar {
app_state: Entity<AppState>,
}
impl Sidebar {
pub fn new(app_state: Entity<AppState>) -> Self {
Self { app_state }
}
}
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.app_state.read(cx);
let settings_active = state.settings_open;
let settings_bg = if settings_active {
theme::blue_wash()
} else {
theme::MANTLE
};
let settings_fg = if settings_active {
theme::BLUE
} else {
theme::SUBTEXT0
};
div()
.id("sidebar")
.w(px(48.0))
.h_full()
.flex()
.flex_col()
.items_center()
.gap(px(8.0))
.py(px(12.0))
.bg(theme::MANTLE)
.border_r_1()
.border_color(theme::SURFACE0)
// Settings button (gear icon)
.child(
div()
.id("sidebar-settings")
.w(px(40.0))
.h(px(40.0))
.flex()
.items_center()
.justify_center()
.rounded(px(8.0))
.bg(settings_bg)
.text_color(settings_fg)
.text_size(px(16.0))
.cursor_pointer()
.hover(|s| s.bg(theme::hover_bg()))
.child("\u{2699}".to_string())
.on_click({
let state = self.app_state.clone();
move |_event: &ClickEvent, _window: &mut Window, cx: &mut App| {
state.update(cx, |s, cx| {
s.toggle_settings();
cx.notify();
});
}
}),
)
// Spacer
.child(div().flex_1())
// Project count badge
.child(
div()
.w(px(32.0))
.h(px(32.0))
.flex()
.items_center()
.justify_center()
.rounded(px(6.0))
.bg(theme::SURFACE0)
.text_color(theme::SUBTEXT0)
.text_size(px(11.0))
.child({
let count = state.projects.len();
format!("{count}")
}),
)
}
}

View file

@ -0,0 +1,155 @@
//! StatusBar: bottom bar showing agent states, cost, token count.
//!
//! Equivalent to the Tauri app's Mission Control bar.
use gpui::*;
use crate::state::{AgentStatus, AppState};
use crate::theme;
// ── Status Pill ─────────────────────────────────────────────────────
/// Small colored pill with a count label.
fn status_pill(label: &str, count: usize, color: Rgba) -> Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(4.0))
.px(px(8.0))
.py(px(2.0))
.rounded(px(4.0))
.bg(theme::with_alpha(color, 0.12))
.child(
div()
.w(px(6.0))
.h(px(6.0))
.rounded(px(3.0))
.bg(color),
)
.child(
div()
.text_size(px(11.0))
.text_color(color)
.child(format!("{count} {label}")),
)
}
// ── StatusBar View ──────────────────────────────────────────────────
pub struct StatusBar {
app_state: Entity<AppState>,
}
impl StatusBar {
pub fn new(app_state: Entity<AppState>) -> Self {
Self { app_state }
}
}
impl Render for StatusBar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.app_state.read(cx);
let running = state
.projects
.iter()
.filter(|p| p.agent.status == AgentStatus::Running)
.count();
let idle = state
.projects
.iter()
.filter(|p| p.agent.status == AgentStatus::Idle)
.count();
let done = state
.projects
.iter()
.filter(|p| p.agent.status == AgentStatus::Done)
.count();
let errors = state
.projects
.iter()
.filter(|p| p.agent.status == AgentStatus::Error)
.count();
let total_cost = state.total_cost();
let total_tokens = state.total_tokens();
let project_count = state.projects.len();
let mut bar = div()
.id("status-bar")
.w_full()
.h(px(28.0))
.flex()
.flex_row()
.items_center()
.px(px(12.0))
.gap(px(12.0))
.bg(theme::CRUST)
.border_t_1()
.border_color(theme::SURFACE0)
// Agent status pills
.child(status_pill("running", running, theme::GREEN))
.child(status_pill("idle", idle, theme::OVERLAY0));
if done > 0 {
bar = bar.child(status_pill("done", done, theme::BLUE));
}
if errors > 0 {
bar = bar.child(status_pill("error", errors, theme::RED));
}
bar
// Spacer
.child(div().flex_1())
// Cost
.child(
div()
.flex()
.flex_row()
.items_center()
.gap(px(4.0))
.text_size(px(11.0))
.text_color(theme::SUBTEXT0)
.child(format!("${:.4}", total_cost)),
)
// Separator
.child(
div()
.w(px(1.0))
.h(px(14.0))
.bg(theme::SURFACE0),
)
// Tokens
.child(
div()
.text_size(px(11.0))
.text_color(theme::SUBTEXT0)
.child(format!("{}tok", format_number(total_tokens))),
)
// Separator
.child(
div()
.w(px(1.0))
.h(px(14.0))
.bg(theme::SURFACE0),
)
// Project count
.child(
div()
.text_size(px(11.0))
.text_color(theme::SUBTEXT0)
.child(format!("{project_count} projects")),
)
}
}
/// Format a number with K/M suffixes.
fn format_number(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
format!("{n}")
}
}

102
ui-gpui/src/main.rs Normal file
View file

@ -0,0 +1,102 @@
//! agor-gpui: GPU-accelerated Agent Orchestrator UI prototype.
//!
//! Uses Zed's GPUI framework for native GPU rendering.
//! This is a decision-making prototype comparing against the existing Tauri+Svelte app.
//!
//! # Architecture
//!
//! ```text
//! main.rs → Application::new().run()
//! └─ Workspace (root view)
//! ├─ Sidebar (icon rail)
//! ├─ SettingsPanel (drawer, optional)
//! ├─ ProjectGrid
//! │ ├─ ProjectBox[0]
//! │ │ ├─ AgentPane (message list + prompt)
//! │ │ └─ TerminalView (GPU-rendered via alacritty_terminal)
//! │ └─ ProjectBox[1]
//! │ ├─ AgentPane
//! │ └─ TerminalView
//! ├─ StatusBar (bottom)
//! └─ CommandPalette (overlay, optional)
//! ```
//!
//! # Backend Integration
//!
//! `backend.rs` bridges to `agor-core` (PtyManager, SidecarManager) via an
//! `EventSink` that queues events into an mpsc channel. The GPUI main loop
//! drains this channel to update entity state reactively.
//!
//! # Key Differentiator: GPU Terminal
//!
//! `terminal/renderer.rs` uses `alacritty_terminal::Term` for VT100 parsing
//! and renders each cell directly via GPUI's text pipeline — no DOM, no
//! Canvas 2D context, just GPU-accelerated glyph rendering.
mod backend;
mod components;
mod state;
mod theme;
mod terminal;
mod workspace;
use gpui::*;
use state::AppState;
use workspace::Workspace;
fn main() {
// Initialize the GPUI application
Application::new().run(|cx: &mut App| {
// Create shared application state with demo data
let app_state: Entity<AppState> = cx.new(|_cx| AppState::new_demo());
// Configure window
let window_options = WindowOptions {
// Use default window bounds (the OS will position/size it)
focus: true,
show: true,
..Default::default()
};
// Open the main window with Workspace as the root view
let _window = cx
.open_window(window_options, |window, cx| {
// Set window title
window.set_window_title("Agent Orchestrator — GPUI Prototype");
// Create the workspace root view
cx.new(|cx| Workspace::new(app_state.clone(), cx))
})
.expect("Failed to open window");
// TODO: Set up keyboard bindings for Ctrl+K (palette), Ctrl+B (sidebar), Ctrl+, (settings)
// GPUI uses an action dispatch system:
// 1. Define action structs with #[derive(Action)]
// 2. Register key bindings via cx.bind_keys()
// 3. Handle actions via window.on_action() or element .on_action()
//
// Example (requires gpui::actions! macro):
// actions!(agor, [TogglePalette, ToggleSidebar, OpenSettings]);
// cx.bind_keys([
// KeyBinding::new("ctrl-k", TogglePalette, None),
// KeyBinding::new("ctrl-b", ToggleSidebar, None),
// KeyBinding::new("ctrl-,", OpenSettings, None),
// ]);
// TODO: Start backend event polling loop
// In a full implementation, we'd spawn a timer or use cx.spawn() to
// periodically drain backend events and update the app state:
//
// let backend = Backend::new();
// cx.spawn(|cx| async move {
// loop {
// Timer::after(Duration::from_millis(16)).await; // ~60fps
// let events = backend.drain_events();
// for ev in events {
// // Route to appropriate entity updates
// }
// }
// }).detach();
});
}

262
ui-gpui/src/state.rs Normal file
View file

@ -0,0 +1,262 @@
//! Application state — projects, agents, settings.
//!
//! All mutable state lives in `AppState`, wrapped in `Entity<AppState>` for
//! GPUI reactivity. Components read via `entity.read(cx)` and mutate via
//! `entity.update(cx, |state, cx| { ... cx.notify(); })`.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── Agent Message Types ─────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageRole {
User,
Assistant,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentMessage {
pub id: String,
pub role: MessageRole,
pub content: String,
pub timestamp: u64,
/// For tool calls: the tool name.
pub tool_name: Option<String>,
/// For tool results: the result content.
pub tool_result: Option<String>,
/// Whether this message block is collapsed in the UI.
pub collapsed: bool,
}
// ── Agent State ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentStatus {
Idle,
Running,
Done,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSession {
pub session_id: String,
pub status: AgentStatus,
pub messages: Vec<AgentMessage>,
pub cost_usd: f64,
pub tokens_used: u64,
pub model: String,
}
impl AgentSession {
pub fn new() -> Self {
Self {
session_id: Uuid::new_v4().to_string(),
status: AgentStatus::Idle,
messages: Vec::new(),
cost_usd: 0.0,
tokens_used: 0,
model: "claude-sonnet-4-20250514".to_string(),
}
}
}
// ── Project ─────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProjectTab {
Model,
Docs,
Files,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub cwd: String,
pub active_tab: ProjectTab,
pub agent: AgentSession,
/// Accent color index (cycles through palette).
pub accent_index: usize,
}
impl Project {
pub fn new(name: &str, cwd: &str, accent_index: usize) -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
cwd: cwd.to_string(),
active_tab: ProjectTab::Model,
agent: AgentSession::new(),
accent_index,
}
}
}
// ── Settings ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub theme: String,
pub ui_font_family: String,
pub ui_font_size: f32,
pub term_font_family: String,
pub term_font_size: f32,
pub default_shell: String,
pub default_cwd: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: "Catppuccin Mocha".to_string(),
ui_font_family: "Inter".to_string(),
ui_font_size: 14.0,
term_font_family: "JetBrains Mono".to_string(),
term_font_size: 14.0,
default_shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
default_cwd: dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string()),
}
}
}
// ── Command Palette ─────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct PaletteCommand {
pub id: &'static str,
pub label: &'static str,
pub shortcut: Option<&'static str>,
}
pub fn all_commands() -> Vec<PaletteCommand> {
vec![
PaletteCommand {
id: "settings",
label: "Open Settings",
shortcut: Some("Ctrl+,"),
},
PaletteCommand {
id: "new_project",
label: "Add Project",
shortcut: Some("Ctrl+N"),
},
PaletteCommand {
id: "toggle_sidebar",
label: "Toggle Sidebar",
shortcut: Some("Ctrl+B"),
},
PaletteCommand {
id: "focus_next",
label: "Focus Next Project",
shortcut: Some("Ctrl+]"),
},
PaletteCommand {
id: "focus_prev",
label: "Focus Previous Project",
shortcut: Some("Ctrl+["),
},
PaletteCommand {
id: "close_project",
label: "Close Focused Project",
shortcut: None,
},
PaletteCommand {
id: "restart_agent",
label: "Restart Agent",
shortcut: None,
},
PaletteCommand {
id: "stop_agent",
label: "Stop Agent",
shortcut: None,
},
]
}
// ── Root Application State ──────────────────────────────────────────
pub struct AppState {
pub projects: Vec<Project>,
pub focused_project_idx: Option<usize>,
pub settings: Settings,
pub sidebar_open: bool,
pub settings_open: bool,
pub palette_open: bool,
pub palette_query: String,
}
impl AppState {
/// Create initial state with demo projects.
pub fn new_demo() -> Self {
Self {
projects: vec![
Project::new("agent-orchestrator", "~/code/ai/agent-orchestrator", 0),
Project::new("quanta-discord-bot", "~/code/quanta-discord-bot", 1),
],
focused_project_idx: Some(0),
settings: Settings::default(),
sidebar_open: true,
settings_open: false,
palette_open: false,
palette_query: String::new(),
}
}
pub fn focused_project(&self) -> Option<&Project> {
self.focused_project_idx
.and_then(|i| self.projects.get(i))
}
pub fn focused_project_mut(&mut self) -> Option<&mut Project> {
self.focused_project_idx
.and_then(|i| self.projects.get_mut(i))
}
pub fn toggle_sidebar(&mut self) {
self.sidebar_open = !self.sidebar_open;
}
pub fn toggle_settings(&mut self) {
self.settings_open = !self.settings_open;
}
pub fn toggle_palette(&mut self) {
self.palette_open = !self.palette_open;
if self.palette_open {
self.palette_query.clear();
}
}
/// Filtered palette commands based on current query.
pub fn filtered_commands(&self) -> Vec<PaletteCommand> {
let q = self.palette_query.to_lowercase();
all_commands()
.into_iter()
.filter(|cmd| q.is_empty() || cmd.label.to_lowercase().contains(&q))
.collect()
}
/// Total running agents.
pub fn running_agent_count(&self) -> usize {
self.projects
.iter()
.filter(|p| p.agent.status == AgentStatus::Running)
.count()
}
/// Total cost across all agents.
pub fn total_cost(&self) -> f64 {
self.projects.iter().map(|p| p.agent.cost_usd).sum()
}
/// Total tokens across all agents.
pub fn total_tokens(&self) -> u64 {
self.projects.iter().map(|p| p.agent.tokens_used).sum()
}
}

View file

@ -0,0 +1,9 @@
//! GPU-rendered terminal using alacritty_terminal + GPUI rendering.
//!
//! This is the key differentiator vs. xterm.js:
//! - No DOM/Canvas overhead — cells painted directly via GPU text pipeline
//! - Same VT100 state machine as Alacritty (battle-tested)
//! - PTY bridged through agor-core's PtyManager
pub mod pty_bridge;
pub mod renderer;

View file

@ -0,0 +1,74 @@
//! PTY integration via agor-core.
//!
//! Bridges agor-core's PtyManager to alacritty_terminal's event loop.
//! Reads PTY output in a background thread, feeds it into the Term state machine,
//! and notifies GPUI to repaint.
use agor_core::pty::{PtyManager, PtyOptions};
use std::sync::{Arc, Mutex};
/// Manages a single PTY ↔ Terminal connection.
pub struct PtyBridge {
pub pty_id: Option<String>,
pty_manager: Arc<PtyManager>,
/// Raw bytes received from PTY, buffered for the renderer to consume.
pub output_buffer: Arc<Mutex<Vec<u8>>>,
}
impl PtyBridge {
pub fn new(pty_manager: Arc<PtyManager>) -> Self {
Self {
pty_id: None,
pty_manager,
output_buffer: Arc::new(Mutex::new(Vec::with_capacity(8192))),
}
}
/// Spawn a PTY process and start reading its output.
pub fn spawn(&mut self, cwd: &str) -> Result<(), String> {
let id = self.pty_manager.spawn(PtyOptions {
shell: None,
cwd: Some(cwd.to_string()),
args: None,
cols: Some(120),
rows: Some(30),
})?;
self.pty_id = Some(id);
// NOTE: In a full implementation, we would start a background thread here
// that reads from the PTY master fd and pushes bytes into output_buffer.
// The PtyManager currently handles reading internally and emits events via
// EventSink. For the GPUI bridge, we would intercept those events in the
// Backend::drain_events() loop and feed them here.
Ok(())
}
/// Write user input to the PTY.
pub fn write(&self, data: &str) -> Result<(), String> {
if let Some(ref id) = self.pty_id {
self.pty_manager
.write(id, data)
.map_err(|e| format!("PTY write error: {e}"))
} else {
Err("No PTY spawned".to_string())
}
}
/// Resize the PTY.
pub fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
if let Some(ref id) = self.pty_id {
self.pty_manager
.resize(id, cols, rows)
.map_err(|e| format!("PTY resize error: {e}"))
} else {
Err("No PTY spawned".to_string())
}
}
/// Drain buffered output bytes (consumed by the renderer).
pub fn drain_output(&self) -> Vec<u8> {
let mut buf = self.output_buffer.lock().unwrap();
let data = buf.clone();
buf.clear();
data
}
}

View file

@ -0,0 +1,277 @@
//! GPU text rendering for terminal cells.
//!
//! Uses alacritty_terminal::Term for the VT state machine and renders each cell
//! as GPUI text elements. This is the "revolution" — no DOM, no Canvas 2D,
//! just GPU-accelerated glyph rendering at 120fps.
//!
//! Architecture:
//! 1. `vte::ansi::Processor` parses raw PTY bytes
//! 2. `alacritty_terminal::Term` (implements `vte::ansi::Handler`) processes escape sequences
//! 3. Grid cells are read via `grid[Line(i)][Column(j)]`
//! 4. Each cell is rendered as a GPUI div with the correct foreground color
use alacritty_terminal::event::{Event as AlacrittyEvent, EventListener};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::Config as TermConfig;
use alacritty_terminal::term::Term;
use alacritty_terminal::vte::ansi::{Color, NamedColor, Processor};
use gpui::*;
use std::sync::{Arc, Mutex};
use crate::theme;
// ── Alacritty Event Listener (no-op for prototype) ──────────────────
struct GpuiTermEventListener;
impl EventListener for GpuiTermEventListener {
fn send_event(&self, _event: AlacrittyEvent) {
// In a full implementation, forward bell, title changes, etc.
}
}
// ── ANSI Color Mapping ──────────────────────────────────────────────
/// Map alacritty's Color to Catppuccin Mocha Rgba.
fn ansi_to_rgba(color: Color) -> Rgba {
match color {
Color::Named(named) => named_to_rgba(named),
Color::Spec(rgb) => Rgba {
r: rgb.r as f32 / 255.0,
g: rgb.g as f32 / 255.0,
b: rgb.b as f32 / 255.0,
a: 1.0,
},
Color::Indexed(idx) => {
if idx < 16 {
// Map standard 16 colors to named
match idx {
0 => theme::SURFACE0,
1 => theme::RED,
2 => theme::GREEN,
3 => theme::YELLOW,
4 => theme::BLUE,
5 => theme::MAUVE,
6 => theme::TEAL,
7 => theme::SUBTEXT1,
8 => theme::OVERLAY0,
9 => theme::FLAMINGO,
10 => theme::GREEN,
11 => theme::YELLOW,
12 => theme::SAPPHIRE,
13 => theme::PINK,
14 => theme::SKY,
15 => theme::TEXT,
_ => theme::TEXT,
}
} else {
// 216-color cube + 24 grayscale — approximate for prototype
theme::TEXT
}
}
}
}
fn named_to_rgba(named: NamedColor) -> Rgba {
match named {
NamedColor::Black => theme::SURFACE0,
NamedColor::Red => theme::RED,
NamedColor::Green => theme::GREEN,
NamedColor::Yellow => theme::YELLOW,
NamedColor::Blue => theme::BLUE,
NamedColor::Magenta => theme::MAUVE,
NamedColor::Cyan => theme::TEAL,
NamedColor::White => theme::TEXT,
NamedColor::BrightBlack => theme::OVERLAY0,
NamedColor::BrightRed => theme::FLAMINGO,
NamedColor::BrightGreen => theme::GREEN,
NamedColor::BrightYellow => theme::YELLOW,
NamedColor::BrightBlue => theme::SAPPHIRE,
NamedColor::BrightMagenta => theme::PINK,
NamedColor::BrightCyan => theme::SKY,
NamedColor::BrightWhite => theme::TEXT,
NamedColor::Foreground => theme::TEXT,
NamedColor::Background => theme::BASE,
NamedColor::Cursor => theme::ROSEWATER,
NamedColor::DimForeground => theme::SUBTEXT0,
_ => theme::TEXT,
}
}
// ── Terminal Size Adapter ───────────────────────────────────────────
struct TerminalSize {
cols: usize,
rows: usize,
}
impl Dimensions for TerminalSize {
fn total_lines(&self) -> usize {
self.rows
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
fn last_column(&self) -> Column {
Column(self.cols.saturating_sub(1))
}
fn bottommost_line(&self) -> Line {
Line(self.rows as i32 - 1)
}
fn topmost_line(&self) -> Line {
Line(0)
}
}
// ── Terminal State ──────────────────────────────────────────────────
/// Wraps alacritty_terminal::Term with render cache for GPUI.
pub struct TerminalState {
term: Term<GpuiTermEventListener>,
processor: Processor,
pub cols: usize,
pub rows: usize,
/// Cached render data: Vec of rows, each row is Vec of (char, fg_color).
render_lines: Vec<Vec<(char, Rgba)>>,
}
impl TerminalState {
pub fn new(cols: usize, rows: usize) -> Self {
let size = TerminalSize { cols, rows };
let config = TermConfig::default();
let term = Term::new(config, &size, GpuiTermEventListener);
let processor = Processor::new();
Self {
term,
processor,
cols,
rows,
render_lines: Vec::new(),
}
}
/// Feed raw PTY output bytes into the VT state machine.
pub fn process_output(&mut self, data: &[u8]) {
self.processor.advance(&mut self.term, data);
}
/// Rebuild the render_lines cache from the term grid.
pub fn update_render_cache(&mut self) {
let grid = self.term.grid();
self.render_lines.clear();
for row_idx in 0..self.rows {
let mut line = Vec::with_capacity(self.cols);
let row = &grid[Line(row_idx as i32)];
for col_idx in 0..self.cols {
let cell = &row[Column(col_idx)];
let ch = cell.c;
let fg = ansi_to_rgba(cell.fg);
line.push((ch, fg));
}
self.render_lines.push(line);
}
}
/// Get the cached render lines.
pub fn lines(&self) -> &[Vec<(char, Rgba)>] {
&self.render_lines
}
/// Get cursor position (row, col).
pub fn cursor_point(&self) -> (usize, usize) {
let cursor = self.term.grid().cursor.point;
(cursor.line.0 as usize, cursor.column.0)
}
}
// ── GPUI Terminal View ──────────────────────────────────────────────
/// GPUI view that renders the terminal grid.
/// Each cell is rendered as a text span with the correct foreground color.
/// The cursor is rendered as a block highlight.
pub struct TerminalView {
pub state: TerminalState,
}
impl TerminalView {
pub fn new(cols: usize, rows: usize) -> Self {
Self {
state: TerminalState::new(cols, rows),
}
}
/// Feed demo content for visual testing.
pub fn feed_demo(&mut self) {
let demo = b"\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m $ \x1b[32mcargo build\x1b[0m\r\n\
\x1b[33mCompiling\x1b[0m agor-core v0.1.0\r\n\
\x1b[33mCompiling\x1b[0m agor-gpui v0.1.0\r\n\
\x1b[1;32m Finished\x1b[0m `dev` profile [unoptimized + debuginfo] in 4.2s\r\n\
\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m $ \x1b[7m \x1b[0m";
self.state.process_output(demo);
self.state.update_render_cache();
}
}
impl Render for TerminalView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
// Update render cache from terminal state
self.state.update_render_cache();
let (cursor_row, cursor_col) = self.state.cursor_point();
// Build rows as horizontal flex containers of character spans
let mut rows: Vec<Div> = Vec::new();
for (row_idx, line) in self.state.lines().iter().enumerate() {
let mut row_div = div().flex().flex_row();
for (col_idx, &(ch, fg)) in line.iter().enumerate() {
let is_cursor = row_idx == cursor_row && col_idx == cursor_col;
let display_char = if ch == '\0' || ch == ' ' {
' '
} else {
ch
};
let mut cell = div()
.w(px(8.4))
.h(px(18.0))
.flex()
.items_center()
.justify_center()
.text_size(px(14.0))
.text_color(fg);
if is_cursor {
cell = cell.bg(theme::ROSEWATER).text_color(theme::BASE);
}
row_div = row_div.child(cell.child(format!("{}", display_char)));
}
rows.push(row_div);
}
// Terminal container
let mut container = div()
.w_full()
.h_full()
.bg(theme::BASE)
.p(px(4.0))
.flex()
.flex_col()
.overflow_hidden()
.font_family("JetBrains Mono");
for row in rows {
container = container.child(row);
}
container
}
}

204
ui-gpui/src/theme.rs Normal file
View file

@ -0,0 +1,204 @@
//! Catppuccin Mocha palette as GPUI color constants.
//!
//! All colors are defined as `gpui::Rgba` via the `rgb()` helper.
//! Components import `theme::*` and use these constants for every visual property.
use gpui::Rgba;
/// Convert a 0xRRGGBB hex literal to `Rgba`.
/// Re-exports `gpui::rgb` for convenience.
pub fn color(hex: u32) -> Rgba {
gpui::rgb(hex)
}
// ── Backgrounds ─────────────────────────────────────────────────────
pub const BASE: Rgba = Rgba {
r: 0x1e as f32 / 255.0,
g: 0x1e as f32 / 255.0,
b: 0x2e as f32 / 255.0,
a: 1.0,
};
pub const MANTLE: Rgba = Rgba {
r: 0x18 as f32 / 255.0,
g: 0x16 as f32 / 255.0,
b: 0x25 as f32 / 255.0,
a: 1.0,
};
pub const CRUST: Rgba = Rgba {
r: 0x11 as f32 / 255.0,
g: 0x11 as f32 / 255.0,
b: 0x1b as f32 / 255.0,
a: 1.0,
};
// ── Surfaces ────────────────────────────────────────────────────────
pub const SURFACE0: Rgba = Rgba {
r: 0x31 as f32 / 255.0,
g: 0x32 as f32 / 255.0,
b: 0x44 as f32 / 255.0,
a: 1.0,
};
pub const SURFACE1: Rgba = Rgba {
r: 0x45 as f32 / 255.0,
g: 0x47 as f32 / 255.0,
b: 0x5a as f32 / 255.0,
a: 1.0,
};
pub const SURFACE2: Rgba = Rgba {
r: 0x58 as f32 / 255.0,
g: 0x5b as f32 / 255.0,
b: 0x70 as f32 / 255.0,
a: 1.0,
};
// ── Text ────────────────────────────────────────────────────────────
pub const TEXT: Rgba = Rgba {
r: 0xcd as f32 / 255.0,
g: 0xd6 as f32 / 255.0,
b: 0xf4 as f32 / 255.0,
a: 1.0,
};
pub const SUBTEXT0: Rgba = Rgba {
r: 0xa6 as f32 / 255.0,
g: 0xad as f32 / 255.0,
b: 0xc8 as f32 / 255.0,
a: 1.0,
};
pub const SUBTEXT1: Rgba = Rgba {
r: 0xba as f32 / 255.0,
g: 0xc2 as f32 / 255.0,
b: 0xde as f32 / 255.0,
a: 1.0,
};
// ── Overlays ────────────────────────────────────────────────────────
pub const OVERLAY0: Rgba = Rgba {
r: 0x6c as f32 / 255.0,
g: 0x70 as f32 / 255.0,
b: 0x86 as f32 / 255.0,
a: 1.0,
};
pub const OVERLAY1: Rgba = Rgba {
r: 0x7f as f32 / 255.0,
g: 0x84 as f32 / 255.0,
b: 0x9c as f32 / 255.0,
a: 1.0,
};
pub const OVERLAY2: Rgba = Rgba {
r: 0x93 as f32 / 255.0,
g: 0x99 as f32 / 255.0,
b: 0xb2 as f32 / 255.0,
a: 1.0,
};
// ── Accent Colors ───────────────────────────────────────────────────
pub const BLUE: Rgba = Rgba {
r: 0x89 as f32 / 255.0,
g: 0xb4 as f32 / 255.0,
b: 0xfa as f32 / 255.0,
a: 1.0,
};
pub const GREEN: Rgba = Rgba {
r: 0xa6 as f32 / 255.0,
g: 0xe3 as f32 / 255.0,
b: 0xa1 as f32 / 255.0,
a: 1.0,
};
pub const RED: Rgba = Rgba {
r: 0xf3 as f32 / 255.0,
g: 0x8b as f32 / 255.0,
b: 0xa8 as f32 / 255.0,
a: 1.0,
};
pub const YELLOW: Rgba = Rgba {
r: 0xf9 as f32 / 255.0,
g: 0xe2 as f32 / 255.0,
b: 0xaf as f32 / 255.0,
a: 1.0,
};
pub const MAUVE: Rgba = Rgba {
r: 0xcb as f32 / 255.0,
g: 0xa6 as f32 / 255.0,
b: 0xf7 as f32 / 255.0,
a: 1.0,
};
pub const PEACH: Rgba = Rgba {
r: 0xfa as f32 / 255.0,
g: 0xb3 as f32 / 255.0,
b: 0x87 as f32 / 255.0,
a: 1.0,
};
pub const TEAL: Rgba = Rgba {
r: 0x94 as f32 / 255.0,
g: 0xe2 as f32 / 255.0,
b: 0xd5 as f32 / 255.0,
a: 1.0,
};
pub const SAPPHIRE: Rgba = Rgba {
r: 0x74 as f32 / 255.0,
g: 0xc7 as f32 / 255.0,
b: 0xec as f32 / 255.0,
a: 1.0,
};
pub const LAVENDER: Rgba = Rgba {
r: 0xb4 as f32 / 255.0,
g: 0xbe as f32 / 255.0,
b: 0xfe as f32 / 255.0,
a: 1.0,
};
pub const FLAMINGO: Rgba = Rgba {
r: 0xf2 as f32 / 255.0,
g: 0xcd as f32 / 255.0,
b: 0xcd as f32 / 255.0,
a: 1.0,
};
pub const ROSEWATER: Rgba = Rgba {
r: 0xf5 as f32 / 255.0,
g: 0xe0 as f32 / 255.0,
b: 0xdc as f32 / 255.0,
a: 1.0,
};
pub const PINK: Rgba = Rgba {
r: 0xf5 as f32 / 255.0,
g: 0xc2 as f32 / 255.0,
b: 0xe7 as f32 / 255.0,
a: 1.0,
};
pub const MAROON: Rgba = Rgba {
r: 0xeb as f32 / 255.0,
g: 0xa0 as f32 / 255.0,
b: 0xac as f32 / 255.0,
a: 1.0,
};
pub const SKY: Rgba = Rgba {
r: 0x89 as f32 / 255.0,
g: 0xdc as f32 / 255.0,
b: 0xeb as f32 / 255.0,
a: 1.0,
};
// ── Semi-transparent helpers ────────────────────────────────────────
pub fn with_alpha(base: Rgba, alpha: f32) -> Rgba {
Rgba { a: alpha, ..base }
}
/// Accent blue at 10% opacity — for subtle hover/selection highlights.
pub fn blue_tint() -> Rgba {
with_alpha(BLUE, 0.10)
}
/// Accent blue at 20% opacity — for active/selected states.
pub fn blue_wash() -> Rgba {
with_alpha(BLUE, 0.20)
}
/// Surface for hover states — one step brighter than surface0.
pub fn hover_bg() -> Rgba {
SURFACE1
}

120
ui-gpui/src/workspace.rs Normal file
View file

@ -0,0 +1,120 @@
//! Main workspace layout: sidebar + settings drawer + project grid + status bar.
//!
//! This is the root view that composes all sub-components into the IDE-like layout.
use gpui::*;
use crate::components::command_palette::CommandPalette;
use crate::components::project_grid::ProjectGrid;
use crate::components::settings::SettingsPanel;
use crate::components::sidebar::Sidebar;
use crate::components::status_bar::StatusBar;
use crate::state::AppState;
use crate::theme;
// ── Workspace View ──────────────────────────────────────────────────
pub struct Workspace {
app_state: Entity<AppState>,
sidebar: Entity<Sidebar>,
settings_panel: Entity<SettingsPanel>,
project_grid: Entity<ProjectGrid>,
status_bar: Entity<StatusBar>,
command_palette: Entity<CommandPalette>,
}
impl Workspace {
pub fn new(app_state: Entity<AppState>, cx: &mut Context<Self>) -> Self {
let sidebar = cx.new({
let state = app_state.clone();
|_cx| Sidebar::new(state)
});
let settings_panel = cx.new({
let state = app_state.clone();
|_cx| SettingsPanel::new(state)
});
let project_grid = cx.new({
let state = app_state.clone();
|cx| ProjectGrid::new(state, cx)
});
let status_bar = cx.new({
let state = app_state.clone();
|_cx| StatusBar::new(state)
});
let command_palette = cx.new({
let state = app_state.clone();
|_cx| CommandPalette::new(state)
});
// Observe app_state changes to trigger re-renders
cx.observe(&app_state, |_this, _entity, cx| {
cx.notify();
})
.detach();
Self {
app_state,
sidebar,
settings_panel,
project_grid,
status_bar,
command_palette,
}
}
}
impl Render for Workspace {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.app_state.read(cx);
let sidebar_open = state.sidebar_open;
let settings_open = state.settings_open;
let palette_open = state.palette_open;
let mut root = div()
.id("workspace-root")
.size_full()
.flex()
.flex_col()
.bg(theme::CRUST)
.font_family("Inter");
// ── Main content row (sidebar + settings? + grid) ───
let mut main_row = div()
.id("main-row")
.flex_1()
.w_full()
.flex()
.flex_row()
.overflow_hidden();
// Sidebar (icon rail)
if sidebar_open {
main_row = main_row.child(self.sidebar.clone());
}
// Settings drawer (between sidebar and grid)
if settings_open {
main_row = main_row.child(self.settings_panel.clone());
}
// Project grid (fills remaining space)
main_row = main_row.child(
div()
.flex_1()
.h_full()
.child(self.project_grid.clone()),
);
root = root.child(main_row);
// ── Status bar (bottom) ─────────────────────────────
root = root.child(self.status_bar.clone());
// ── Command palette overlay (if open) ───────────────
if palette_open {
root = root.child(self.command_palette.clone());
}
root
}
}