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())
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
163
ui-gpui/src/components/command_palette.rs
Normal file
163
ui-gpui/src/components/command_palette.rs
Normal 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
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
7
ui-gpui/src/components/mod.rs
Normal file
7
ui-gpui/src/components/mod.rs
Normal 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;
|
||||
278
ui-gpui/src/components/project_box.rs
Normal file
278
ui-gpui/src/components/project_box.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
80
ui-gpui/src/components/project_grid.rs
Normal file
80
ui-gpui/src/components/project_grid.rs
Normal 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
|
||||
}
|
||||
}
|
||||
187
ui-gpui/src/components/settings.rs
Normal file
187
ui-gpui/src/components/settings.rs
Normal 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
97
ui-gpui/src/components/sidebar.rs
Normal file
97
ui-gpui/src/components/sidebar.rs
Normal 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}")
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
155
ui-gpui/src/components/status_bar.rs
Normal file
155
ui-gpui/src/components/status_bar.rs
Normal 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}")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue