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:
parent
90c7315336
commit
f3d2ca78ba
34 changed files with 17467 additions and 0 deletions
346
ui-gpui/src/components/agent_pane.rs
Normal file
346
ui-gpui/src/components/agent_pane.rs
Normal 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())
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue