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
155
ui-dioxus/src/components/agent_pane.rs
Normal file
155
ui-dioxus/src/components/agent_pane.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
85
ui-dioxus/src/components/command_palette.rs
Normal file
85
ui-dioxus/src/components/command_palette.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
ui-dioxus/src/components/mod.rs
Normal file
8
ui-dioxus/src/components/mod.rs
Normal 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;
|
||||
149
ui-dioxus/src/components/project_box.rs
Normal file
149
ui-dioxus/src/components/project_box.rs
Normal 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
ui-dioxus/src/components/project_grid.rs
Normal file
32
ui-dioxus/src/components/project_grid.rs
Normal 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()],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
ui-dioxus/src/components/settings.rs
Normal file
151
ui-dioxus/src/components/settings.rs
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
ui-dioxus/src/components/sidebar.rs
Normal file
31
ui-dioxus/src/components/sidebar.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
ui-dioxus/src/components/status_bar.rs
Normal file
85
ui-dioxus/src/components/status_bar.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
47
ui-dioxus/src/components/terminal.rs
Normal file
47
ui-dioxus/src/components/terminal.rs
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue