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

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()
}
}