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

View file

@ -0,0 +1,155 @@
/// AgentPane — message list + prompt input for a single agent session.
///
/// Mirrors the Svelte app's AgentPane.svelte: sans-serif font, tool call
/// pairing with collapsible details, status strip, prompt bar.
use dioxus::prelude::*;
use crate::state::{AgentMessage, AgentSession, AgentStatus, MessageRole};
#[component]
pub fn AgentPane(
session: Signal<AgentSession>,
project_name: String,
) -> Element {
let mut prompt_text = use_signal(|| String::new());
let status = session.read().status;
let is_running = status == AgentStatus::Running;
let mut do_submit = move || {
let text = prompt_text.read().clone();
if text.trim().is_empty() || is_running {
return;
}
// Add user message to the session
let msg = AgentMessage {
id: uuid::Uuid::new_v4().to_string(),
role: MessageRole::User,
content: text.clone(),
tool_name: None,
tool_output: None,
};
session.write().messages.push(msg);
session.write().status = AgentStatus::Running;
prompt_text.set(String::new());
// In a real implementation, this would call:
// backend.query_agent(&session_id, &text, &cwd)
// For the prototype, we simulate a response after a brief delay.
};
let on_click = move |_: MouseEvent| {
do_submit();
};
let on_keydown = move |e: KeyboardEvent| {
if e.key() == Key::Enter && !e.modifiers().shift() {
do_submit();
}
};
rsx! {
div { class: "agent-pane",
// Message list
div { class: "message-list",
for msg in session.read().messages.iter() {
MessageBubble {
key: "{msg.id}",
message: msg.clone(),
}
}
// Running indicator
if is_running {
div {
class: "message assistant",
style: "opacity: 0.6; font-style: italic;",
div { class: "message-role", "Claude" }
div { class: "message-text", "Thinking..." }
}
}
}
// Status strip
div { class: "agent-status",
span {
class: "status-badge {status.css_class()}",
"{status.label()}"
}
span { "Session: {truncate_id(&session.read().session_id)}" }
span { "Model: {session.read().model}" }
if session.read().cost_usd > 0.0 {
span { class: "status-cost", "${session.read().cost_usd:.4}" }
}
}
// Prompt bar
div { class: "prompt-bar",
input {
class: "prompt-input",
r#type: "text",
placeholder: "Ask Claude...",
value: "{prompt_text}",
oninput: move |e| prompt_text.set(e.value()),
onkeydown: on_keydown,
disabled: is_running,
}
button {
class: "prompt-send",
onclick: on_click,
disabled: is_running || prompt_text.read().trim().is_empty(),
"Send"
}
}
}
}
}
/// A single message bubble with role label and optional tool details.
#[component]
fn MessageBubble(message: AgentMessage) -> Element {
let role_class = message.role.css_class();
let has_tool_output = message.tool_output.is_some();
rsx! {
div { class: "message {role_class}",
div { class: "message-role", "{message.role.label()}" }
if message.role == MessageRole::Tool {
// Tool call: show name and collapsible output
div { class: "message-text",
if let Some(ref tool_name) = message.tool_name {
span {
style: "color: var(--ctp-teal); font-weight: 600;",
"[{tool_name}] "
}
}
"{message.content}"
}
if has_tool_output {
details { class: "tool-details",
summary { "Show output" }
div { class: "tool-output",
"{message.tool_output.as_deref().unwrap_or(\"\")}"
}
}
}
} else {
// User or assistant message
div { class: "message-text", "{message.content}" }
}
}
}
}
/// Truncate a UUID to first 8 chars for display.
fn truncate_id(id: &str) -> String {
if id.len() > 8 {
format!("{}...", &id[..8])
} else {
id.to_string()
}
}

View file

@ -0,0 +1,85 @@
/// CommandPalette — Ctrl+K overlay with search and command list.
///
/// Mirrors the Svelte app's CommandPalette.svelte: Spotlight-style overlay
/// with search input and filtered command list.
use dioxus::prelude::*;
use crate::state::{palette_commands, PaletteCommand};
#[component]
pub fn CommandPalette(
visible: Signal<bool>,
) -> Element {
let mut search = use_signal(|| String::new());
let commands = palette_commands();
// Filter commands by search text
let search_text = search.read().to_lowercase();
let filtered: Vec<&PaletteCommand> = if search_text.is_empty() {
commands.iter().collect()
} else {
commands
.iter()
.filter(|c| c.label.to_lowercase().contains(&search_text))
.collect()
};
let close = move |_| {
visible.set(false);
search.set(String::new());
};
let on_key = move |e: KeyboardEvent| {
if e.key() == Key::Escape {
visible.set(false);
search.set(String::new());
}
};
if !*visible.read() {
return rsx! {};
}
rsx! {
div {
class: "palette-overlay",
onclick: close,
onkeydown: on_key,
div {
class: "palette-box",
// Stop click propagation so clicking inside doesn't close
onclick: move |e| e.stop_propagation(),
input {
class: "palette-input",
r#type: "text",
placeholder: "Type a command...",
value: "{search}",
oninput: move |e| search.set(e.value()),
autofocus: true,
}
div { class: "palette-results",
for cmd in filtered.iter() {
div { class: "palette-item",
span { class: "palette-item-icon", "{cmd.icon}" }
span { class: "palette-item-label", "{cmd.label}" }
if let Some(ref shortcut) = cmd.shortcut {
span { class: "palette-item-shortcut", "{shortcut}" }
}
}
}
if filtered.is_empty() {
div {
style: "padding: 1rem; text-align: center; color: var(--ctp-overlay0); font-size: 0.8125rem;",
"No matching commands"
}
}
}
}
}
}
}

View file

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

View file

@ -0,0 +1,149 @@
/// ProjectBox — individual project card with tab bar and content panes.
///
/// Mirrors the Svelte app's ProjectBox.svelte: header (status dot + name + CWD),
/// tab bar (Model/Docs/Files), and content area. The Model tab contains
/// AgentPane + TerminalArea.
use dioxus::prelude::*;
use crate::state::{
AgentSession, AgentStatus, ProjectConfig, ProjectTab,
demo_messages, demo_terminal_lines,
};
use crate::components::agent_pane::AgentPane;
use crate::components::terminal::TerminalArea;
#[component]
pub fn ProjectBox(
project: ProjectConfig,
initial_status: AgentStatus,
) -> Element {
let mut active_tab = use_signal(|| ProjectTab::Model);
// Per-project agent session state
let session = use_signal(|| {
let mut s = AgentSession::new();
s.status = initial_status;
s.messages = demo_messages();
s.cost_usd = 0.0847;
s.tokens_used = 24_350;
s
});
let terminal_lines = demo_terminal_lines();
let status_class = initial_status.css_class();
rsx! {
div {
class: "project-box",
style: "--accent: {project.accent};",
// Header
div { class: "project-header",
div { class: "status-dot {status_class}" }
div { class: "project-name", "{project.name}" }
span { class: "provider-badge", "{project.provider}" }
div { class: "project-cwd", "{project.cwd}" }
}
// Tab bar
div { class: "tab-bar",
for tab in ProjectTab::all().iter() {
{
let tab = *tab;
let is_active = *active_tab.read() == tab;
rsx! {
div {
class: if is_active { "tab active" } else { "tab" },
onclick: move |_| active_tab.set(tab),
"{tab.label()}"
}
}
}
}
}
// Tab content — uses display:flex/none to keep state across switches
// (same pattern as the Svelte app's ProjectBox)
div { class: "tab-content",
// Model tab
div {
style: if *active_tab.read() == ProjectTab::Model { "display: flex; flex-direction: column; flex: 1; min-height: 0;" } else { "display: none;" },
AgentPane {
session: session,
project_name: project.name.clone(),
}
TerminalArea { lines: terminal_lines.clone() }
}
// Docs tab
div {
style: if *active_tab.read() == ProjectTab::Docs { "display: flex; flex-direction: column; flex: 1;" } else { "display: none;" },
DocsTab { project_name: project.name.clone() }
}
// Files tab
div {
style: if *active_tab.read() == ProjectTab::Files { "display: flex; flex-direction: column; flex: 1;" } else { "display: none;" },
FilesTab { cwd: project.cwd.clone() }
}
}
}
}
}
/// Docs tab — shows a list of markdown files.
#[component]
fn DocsTab(project_name: String) -> Element {
let docs = vec![
"README.md",
"docs/architecture.md",
"docs/decisions.md",
"docs/phases.md",
"docs/findings.md",
"docs/sidecar.md",
"docs/orchestration.md",
];
rsx! {
div { class: "docs-pane",
for doc in docs.iter() {
div { class: "docs-entry",
"\u{1F4C4} {doc}"
}
}
}
}
}
/// Files tab — shows a mock file tree.
#[component]
fn FilesTab(cwd: String) -> Element {
let files = vec![
("dir", "src/"),
("dir", " components/"),
("file", " sidebar.rs"),
("file", " project_box.rs"),
("file", " agent_pane.rs"),
("file", " main.rs"),
("file", " state.rs"),
("file", " theme.rs"),
("file", " backend.rs"),
("dir", "tests/"),
("file", "Cargo.toml"),
("file", "Dioxus.toml"),
];
rsx! {
div { class: "files-pane",
for (kind, name) in files.iter() {
div { class: "file-tree-item",
span { class: "file-tree-icon",
if *kind == "dir" { "\u{1F4C1}" } else { "\u{1F4C4}" }
}
span { "{name}" }
}
}
}
}
}

View file

@ -0,0 +1,32 @@
/// ProjectGrid — responsive grid of ProjectBox cards.
///
/// Mirrors the Svelte app's ProjectGrid.svelte: CSS grid with
/// auto-fit columns, minimum 28rem each.
use dioxus::prelude::*;
use crate::state::{AgentStatus, ProjectConfig};
use crate::components::project_box::ProjectBox;
#[component]
pub fn ProjectGrid(projects: Vec<ProjectConfig>) -> Element {
// Assign different demo statuses to show variety
let statuses = vec![
AgentStatus::Running,
AgentStatus::Idle,
AgentStatus::Done,
AgentStatus::Stalled,
];
rsx! {
div { class: "project-grid",
for (i, project) in projects.iter().enumerate() {
ProjectBox {
key: "{project.id}",
project: project.clone(),
initial_status: statuses[i % statuses.len()],
}
}
}
}
}

View file

@ -0,0 +1,151 @@
/// Settings panel — drawer that slides out from the sidebar.
///
/// Mirrors the Svelte app's SettingsTab.svelte: theme selection, font controls,
/// provider configuration. Uses the same drawer pattern (18rem wide,
/// mantle background).
use dioxus::prelude::*;
#[component]
pub fn SettingsPanel() -> Element {
let mut theme = use_signal(|| "Catppuccin Mocha".to_string());
let mut ui_font = use_signal(|| "Inter".to_string());
let mut term_font = use_signal(|| "JetBrains Mono".to_string());
let mut default_shell = use_signal(|| "/bin/bash".to_string());
rsx! {
div { class: "drawer-panel",
div { class: "drawer-title", "Settings" }
// Appearance section
div { class: "settings-section",
div { class: "settings-section-title", "Appearance" }
div { class: "settings-row",
span { class: "settings-label", "Theme" }
select {
class: "settings-select",
value: "{theme}",
onchange: move |e| theme.set(e.value()),
option { value: "Catppuccin Mocha", "Catppuccin Mocha" }
option { value: "Catppuccin Macchiato", "Catppuccin Macchiato" }
option { value: "Tokyo Night", "Tokyo Night" }
option { value: "Dracula", "Dracula" }
option { value: "Nord", "Nord" }
}
}
div { class: "settings-row",
span { class: "settings-label", "UI Font" }
select {
class: "settings-select",
value: "{ui_font}",
onchange: move |e| ui_font.set(e.value()),
option { value: "Inter", "Inter" }
option { value: "system-ui", "System UI" }
option { value: "IBM Plex Sans", "IBM Plex Sans" }
}
}
div { class: "settings-row",
span { class: "settings-label", "Terminal Font" }
select {
class: "settings-select",
value: "{term_font}",
onchange: move |e| term_font.set(e.value()),
option { value: "JetBrains Mono", "JetBrains Mono" }
option { value: "Fira Code", "Fira Code" }
option { value: "Cascadia Code", "Cascadia Code" }
}
}
}
// Defaults section
div { class: "settings-section",
div { class: "settings-section-title", "Defaults" }
div { class: "settings-row",
span { class: "settings-label", "Shell" }
select {
class: "settings-select",
value: "{default_shell}",
onchange: move |e| default_shell.set(e.value()),
option { value: "/bin/bash", "/bin/bash" }
option { value: "/bin/zsh", "/bin/zsh" }
option { value: "/usr/bin/fish", "/usr/bin/fish" }
}
}
}
// Providers section
div { class: "settings-section",
div { class: "settings-section-title", "Providers" }
ProviderCard {
name: "Claude",
model: "claude-sonnet-4-20250514",
status: "Available",
accent: "var(--ctp-mauve)",
}
ProviderCard {
name: "Codex",
model: "gpt-5.4",
status: "Available",
accent: "var(--ctp-green)",
}
ProviderCard {
name: "Ollama",
model: "qwen3:8b",
status: "Not running",
accent: "var(--ctp-peach)",
}
}
}
}
}
#[component]
fn ProviderCard(
name: String,
model: String,
status: String,
accent: String,
) -> Element {
let is_available = status == "Available";
let badge_bg = if is_available {
"color-mix(in srgb, var(--ctp-green) 15%, transparent)"
} else {
"color-mix(in srgb, var(--ctp-overlay0) 15%, transparent)"
};
let badge_color = if is_available {
"var(--ctp-green)"
} else {
"var(--ctp-overlay0)"
};
let badge_style = format!(
"font-size: 0.625rem; padding: 0.0625rem 0.3125rem; border-radius: 0.1875rem; background: {badge_bg}; color: {badge_color};"
);
rsx! {
div {
style: "background: var(--ctp-surface0); border-radius: 0.375rem; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem;",
div {
style: "display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.25rem;",
span {
style: "font-weight: 600; font-size: 0.8125rem; color: {accent};",
"{name}"
}
span {
style: "{badge_style}",
"{status}"
}
}
div {
style: "font-size: 0.6875rem; color: var(--ctp-overlay1); font-family: var(--term-font-family);",
"{model}"
}
}
}
}

View file

@ -0,0 +1,31 @@
/// GlobalTabBar — narrow icon rail on the left edge.
///
/// Mirrors the Svelte app's GlobalTabBar.svelte: a 2.75rem-wide column
/// with a settings gear icon at the bottom.
use dioxus::prelude::*;
#[component]
pub fn Sidebar(
settings_open: Signal<bool>,
) -> Element {
let toggle_settings = move |_| {
let current = *settings_open.read();
settings_open.set(!current);
};
rsx! {
div { class: "sidebar-rail",
// Top spacer — in the real app, this holds group/workspace icons
div { class: "sidebar-spacer" }
// Settings gear — bottom of the rail
div {
class: if *settings_open.read() { "sidebar-icon active" } else { "sidebar-icon" },
onclick: toggle_settings,
title: "Settings (Ctrl+,)",
"\u{2699}" // gear symbol
}
}
}
}

View file

@ -0,0 +1,85 @@
/// StatusBar — bottom bar showing agent fleet state, cost, and attention.
///
/// Mirrors the Svelte app's StatusBar.svelte (Mission Control bar).
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct FleetState {
pub running: usize,
pub idle: usize,
pub stalled: usize,
pub total_cost: f64,
pub total_tokens: u64,
pub project_count: usize,
}
#[component]
pub fn StatusBar(fleet: FleetState) -> Element {
rsx! {
div { class: "status-bar",
// Left section: agent counts
div { class: "status-bar-left",
// Running
div { class: "status-item",
div {
class: "status-dot running",
style: "width: 6px; height: 6px; border-radius: 50%; display: inline-block;",
}
span { class: "status-count running", "{fleet.running}" }
span { "running" }
}
// Idle
div { class: "status-item",
div {
class: "status-dot idle",
style: "width: 6px; height: 6px; border-radius: 50%; display: inline-block;",
}
span { class: "status-count idle", "{fleet.idle}" }
span { "idle" }
}
// Stalled
if fleet.stalled > 0 {
div { class: "status-item",
div {
class: "status-dot stalled",
style: "width: 6px; height: 6px; border-radius: 50%; display: inline-block;",
}
span { class: "status-count stalled", "{fleet.stalled}" }
span { "stalled" }
}
}
// Separator
span { style: "color: var(--ctp-surface1);", "|" }
// Projects
div { class: "status-item",
span { "{fleet.project_count} projects" }
}
}
// Right section: cost + tokens
div { class: "status-bar-right",
div { class: "status-item",
span { class: "status-cost", "${fleet.total_cost:.4}" }
}
div { class: "status-item",
span { "{format_tokens(fleet.total_tokens)} tokens" }
}
}
}
}
}
fn format_tokens(tokens: u64) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}K", tokens as f64 / 1_000.0)
} else {
tokens.to_string()
}
}

View file

@ -0,0 +1,47 @@
/// Terminal component — demonstrates terminal rendering capability.
///
/// In the Tauri+Svelte app, terminals use xterm.js (Canvas addon) via WebView.
/// Dioxus desktop also uses wry/WebView, so xterm.js embedding IS possible
/// via `document::eval()` or an iframe. For this prototype, we render a styled
/// terminal area showing mock output to demonstrate the visual integration.
///
/// A production implementation would:
/// 1. Inject xterm.js via `with_custom_head()` in desktop Config
/// 2. Use `document::eval()` to create/manage Terminal instances
/// 3. Bridge PTY output from PtyManager through DioxusEventSink -> eval()
///
/// The key insight: Dioxus desktop uses the SAME wry/WebKit2GTK backend as
/// Tauri, so xterm.js works identically. No Canvas addon difference.
use dioxus::prelude::*;
use crate::state::{TerminalLine, TerminalLineKind};
#[component]
pub fn TerminalArea(lines: Vec<TerminalLine>) -> Element {
rsx! {
div { class: "terminal-area",
for line in lines.iter() {
div { class: "terminal-line",
match line.kind {
TerminalLineKind::Prompt => rsx! {
span { class: "terminal-prompt", "{line.text}" }
},
TerminalLineKind::Output => rsx! {
span { class: "terminal-output", "{line.text}" }
},
}
}
}
// Blinking cursor
div { class: "terminal-line",
span { class: "terminal-prompt", "$ " }
span {
style: "animation: pulse 1s step-end infinite; color: var(--ctp-text);",
"\u{2588}"
}
}
}
}
}