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

180
ui-dioxus/src/backend.rs Normal file
View file

@ -0,0 +1,180 @@
/// Bridge between agor-core (PtyManager, SidecarManager) and Dioxus signals.
///
/// In the Tauri app, TauriEventSink implements EventSink by emitting Tauri events
/// that the Svelte frontend listens to. Here we implement EventSink to push
/// events into Dioxus signals, demonstrating native Rust -> UI reactivity
/// without any IPC layer.
use std::sync::{Arc, Mutex};
use agor_core::event::EventSink;
use agor_core::pty::{PtyManager, PtyOptions};
use agor_core::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
/// Collects events emitted by PtyManager and SidecarManager.
/// In a real app, these would drive Dioxus signal updates.
///
/// Key advantage over Tauri: no serialization/deserialization overhead.
/// Events are typed Rust values, not JSON blobs crossing an IPC bridge.
#[derive(Debug, Clone)]
pub struct DioxusEventSink {
/// Buffered events — a real implementation would use channels or
/// direct signal mutation via Dioxus's `schedule_update`.
events: Arc<Mutex<Vec<AppEvent>>>,
}
/// Typed event enum — replaces the untyped (event_name, JSON) pattern
/// used by the Tauri EventSink.
#[derive(Debug, Clone)]
pub enum AppEvent {
PtyOutput { id: String, data: String },
PtyExit { id: String, code: Option<i32> },
AgentMessage { session_id: String, payload: serde_json::Value },
AgentReady { provider: String },
AgentError { session_id: String, error: String },
}
impl DioxusEventSink {
pub fn new() -> Self {
Self {
events: Arc::new(Mutex::new(Vec::new())),
}
}
/// Drain all buffered events. Called from a Dioxus use_effect or timer.
pub fn drain_events(&self) -> Vec<AppEvent> {
let mut events = self.events.lock().unwrap();
std::mem::take(&mut *events)
}
}
impl EventSink for DioxusEventSink {
fn emit(&self, event: &str, payload: serde_json::Value) {
let app_event = match event {
"pty-output" => {
let id = payload["id"].as_str().unwrap_or("").to_string();
let data = payload["data"].as_str().unwrap_or("").to_string();
AppEvent::PtyOutput { id, data }
}
"pty-exit" => {
let id = payload["id"].as_str().unwrap_or("").to_string();
let code = payload["code"].as_i64().map(|c| c as i32);
AppEvent::PtyExit { id, code }
}
"agent-message" => {
let session_id = payload["sessionId"].as_str().unwrap_or("").to_string();
AppEvent::AgentMessage {
session_id,
payload: payload.clone(),
}
}
"sidecar-ready" => {
let provider = payload["provider"].as_str().unwrap_or("claude").to_string();
AppEvent::AgentReady { provider }
}
"agent-error" => {
let session_id = payload["sessionId"].as_str().unwrap_or("").to_string();
let error = payload["error"].as_str().unwrap_or("unknown error").to_string();
AppEvent::AgentError { session_id, error }
}
_ => {
// Unknown event — log and ignore
log::debug!("Unknown event from backend: {event}");
return;
}
};
if let Ok(mut events) = self.events.lock() {
events.push(app_event);
}
}
}
/// Backend handle holding initialized managers.
///
/// In the Tauri app, these live in AppState behind Arc<Mutex<>>
/// and are accessed via Tauri commands. Here they're directly
/// available to Dioxus components via use_context.
pub struct Backend {
pub pty_manager: PtyManager,
pub sidecar_manager: SidecarManager,
pub event_sink: DioxusEventSink,
}
impl Backend {
/// Initialize backend managers.
///
/// # Panics
/// Panics if sidecar search paths cannot be resolved (mirrors Tauri app behavior).
pub fn new() -> Self {
let event_sink = DioxusEventSink::new();
let sink: Arc<dyn EventSink> = Arc::new(event_sink.clone());
let pty_manager = PtyManager::new(Arc::clone(&sink));
// Resolve sidecar search paths — same logic as src-tauri/src/lib.rs
let mut search_paths = Vec::new();
if let Ok(exe_dir) = std::env::current_exe() {
if let Some(parent) = exe_dir.parent() {
search_paths.push(parent.join("sidecar"));
search_paths.push(parent.join("../sidecar/dist"));
}
}
// Also check the repo's sidecar/dist for dev mode
search_paths.push(std::path::PathBuf::from("../sidecar/dist"));
search_paths.push(std::path::PathBuf::from("./sidecar/dist"));
let sidecar_config = SidecarConfig {
search_paths,
env_overrides: std::collections::HashMap::new(),
sandbox: agor_core::sandbox::SandboxConfig::default(),
};
let sidecar_manager = SidecarManager::new(Arc::clone(&sink), sidecar_config);
Self {
pty_manager,
sidecar_manager,
event_sink,
}
}
/// Spawn a PTY with default options.
pub fn spawn_pty(&self, cwd: Option<&str>) -> Result<String, String> {
self.pty_manager.spawn(PtyOptions {
shell: None,
cwd: cwd.map(|s| s.to_string()),
args: None,
cols: Some(80),
rows: Some(24),
})
}
/// Start an agent query.
pub fn query_agent(&self, session_id: &str, prompt: &str, cwd: &str) -> Result<(), String> {
let options = AgentQueryOptions {
provider: "claude".to_string(),
session_id: session_id.to_string(),
prompt: prompt.to_string(),
cwd: Some(cwd.to_string()),
max_turns: None,
max_budget_usd: None,
resume_session_id: None,
permission_mode: Some("bypassPermissions".to_string()),
setting_sources: Some(vec!["user".to_string(), "project".to_string()]),
system_prompt: None,
model: None,
claude_config_dir: None,
additional_directories: None,
worktree_name: None,
provider_config: serde_json::Value::Null,
extra_env: std::collections::HashMap::new(),
};
self.sidecar_manager.query(&options)
}
/// Stop an agent session.
pub fn stop_agent(&self, session_id: &str) -> Result<(), String> {
self.sidecar_manager.stop_session(session_id)
}
}

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}"
}
}
}
}
}

136
ui-dioxus/src/main.rs Normal file
View file

@ -0,0 +1,136 @@
/// Agent Orchestrator — Dioxus 0.7 Desktop Prototype
///
/// This prototype demonstrates the core Agent Orchestrator experience
/// built with Dioxus 0.7 (desktop mode via wry/WebKit2GTK).
///
/// Architecture comparison vs Tauri+Svelte:
///
/// | Aspect | Tauri+Svelte (current) | Dioxus (this prototype) |
/// |---------------------|-------------------------------|-------------------------------|
/// | UI rendering | WebView (Svelte -> HTML) | WebView (RSX -> HTML) |
/// | State management | Svelte 5 runes ($state) | Dioxus signals (use_signal) |
/// | Backend bridge | Tauri IPC (invoke/listen) | Direct Rust (no IPC!) |
/// | Terminal | xterm.js via WebView | xterm.js via WebView (same) |
/// | Reactivity model | Compiler-based (Svelte 5) | Runtime signals (fine-grained)|
/// | Component model | .svelte files (HTML+JS+CSS) | Rust functions (rsx! macro) |
/// | Type safety | TypeScript (compile-time) | Rust (compile-time, stronger) |
/// | Hot reload | Vite HMR | dx serve (RSX hot reload) |
///
/// Key advantage: no IPC serialization boundary. Backend state (PtyManager,
/// SidecarManager) is directly accessible from UI code as typed Rust values.
#[allow(dead_code)]
mod backend;
mod components;
mod state;
mod theme;
use dioxus::prelude::*;
use components::command_palette::CommandPalette;
use components::project_grid::ProjectGrid;
use components::settings::SettingsPanel;
use components::sidebar::Sidebar;
use components::status_bar::{FleetState, StatusBar};
use state::demo_projects;
fn main() {
dioxus::LaunchBuilder::new()
.with_cfg(dioxus::desktop::Config::new()
.with_window(
dioxus::desktop::WindowBuilder::new()
.with_title("Agent Orchestrator — Dioxus Prototype")
.with_inner_size(dioxus::desktop::LogicalSize::new(1400.0, 900.0))
)
.with_custom_head(format!(
"<style>{}</style>",
theme::generate_css()
))
.with_background_color((30, 30, 46, 255)) // --ctp-base
)
.launch(App);
}
/// Root application component.
///
/// Layout: sidebar rail | optional drawer | project grid | status bar
/// Same structure as the Svelte app's App.svelte.
#[allow(non_snake_case)]
fn App() -> Element {
// Global UI state
let mut settings_open = use_signal(|| false);
let mut palette_open = use_signal(|| false);
// Projects — in the real app, loaded from groups.json via groups-bridge
let projects = use_signal(|| demo_projects());
// Fleet state — derived from all project health stores
let fleet = FleetState {
running: 1,
idle: 1,
stalled: 0,
total_cost: 0.1694,
total_tokens: 48_700,
project_count: projects.read().len(),
};
// Keyboard shortcuts
let on_keydown = move |e: KeyboardEvent| {
let modifiers = e.modifiers();
let is_ctrl = modifiers.ctrl();
match e.key() {
// Ctrl+K: Command palette
Key::Character(ref c) if is_ctrl && c == "k" => {
palette_open.set(true);
}
// Ctrl+,: Toggle settings
Key::Character(ref c) if is_ctrl && c == "," => {
let current = *settings_open.read();
settings_open.set(!current);
}
// Ctrl+B: Toggle sidebar drawer
Key::Character(ref c) if is_ctrl && c == "b" => {
let current = *settings_open.read();
settings_open.set(!current);
}
// Escape: close overlays
Key::Escape => {
if *palette_open.read() {
palette_open.set(false);
} else if *settings_open.read() {
settings_open.set(false);
}
}
_ => {}
}
};
rsx! {
div {
class: "app-shell",
onkeydown: on_keydown,
tabindex: "0",
// Main body: sidebar + optional drawer + workspace
div { class: "app-body",
// Left sidebar rail
Sidebar { settings_open: settings_open }
// Optional drawer panel (settings)
if *settings_open.read() {
SettingsPanel {}
}
// Main workspace: project grid
ProjectGrid { projects: projects.read().clone() }
}
// Bottom status bar
StatusBar { fleet: fleet }
// Command palette overlay
CommandPalette { visible: palette_open }
}
}
}

350
ui-dioxus/src/state.rs Normal file
View file

@ -0,0 +1,350 @@
/// Application state management via Dioxus signals.
///
/// Analogous to Svelte 5 runes ($state, $derived) — Dioxus signals provide
/// automatic dependency tracking and targeted re-renders.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Agent activity status — mirrors the Svelte health store's ActivityState.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentStatus {
Idle,
Running,
Done,
Stalled,
Error,
}
impl AgentStatus {
pub fn css_class(&self) -> &'static str {
match self {
AgentStatus::Idle => "idle",
AgentStatus::Running => "running",
AgentStatus::Done => "done",
AgentStatus::Stalled => "stalled",
AgentStatus::Error => "error",
}
}
pub fn label(&self) -> &'static str {
match self {
AgentStatus::Idle => "Idle",
AgentStatus::Running => "Running",
AgentStatus::Done => "Done",
AgentStatus::Stalled => "Stalled",
AgentStatus::Error => "Error",
}
}
}
/// A single message in an agent conversation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentMessage {
pub id: String,
pub role: MessageRole,
pub content: String,
/// For tool calls: tool name
pub tool_name: Option<String>,
/// For tool results: output text
pub tool_output: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageRole {
User,
Assistant,
Tool,
}
impl MessageRole {
pub fn css_class(&self) -> &'static str {
match self {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
}
}
pub fn label(&self) -> &'static str {
match self {
MessageRole::User => "You",
MessageRole::Assistant => "Claude",
MessageRole::Tool => "Tool",
}
}
}
/// Which tab is active in a ProjectBox.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectTab {
Model,
Docs,
Files,
}
impl ProjectTab {
pub fn label(&self) -> &'static str {
match self {
ProjectTab::Model => "Model",
ProjectTab::Docs => "Docs",
ProjectTab::Files => "Files",
}
}
pub fn all() -> &'static [ProjectTab] {
&[ProjectTab::Model, ProjectTab::Docs, ProjectTab::Files]
}
}
/// A project configuration — corresponds to GroupsFile ProjectConfig in the Svelte app.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectConfig {
pub id: String,
pub name: String,
pub cwd: String,
pub provider: String,
pub accent: String,
}
impl ProjectConfig {
pub fn new(name: &str, cwd: &str, provider: &str, accent: &str) -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
cwd: cwd.to_string(),
provider: provider.to_string(),
accent: accent.to_string(),
}
}
}
/// Per-project agent session state.
#[derive(Debug, Clone)]
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(),
}
}
}
/// Global app state — sidebar, command palette visibility, etc.
/// (Used when wiring backend; prototype uses individual signals.)
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub struct AppState {
pub settings_open: bool,
pub palette_open: bool,
}
impl Default for AppState {
fn default() -> Self {
Self {
settings_open: false,
palette_open: false,
}
}
}
/// Terminal output line for the mock terminal display.
#[derive(Debug, Clone, PartialEq)]
pub struct TerminalLine {
pub kind: TerminalLineKind,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalLineKind {
Prompt,
Output,
}
/// Create demo projects for the prototype.
pub fn demo_projects() -> Vec<ProjectConfig> {
vec![
ProjectConfig::new(
"agent-orchestrator",
"~/code/ai/agent-orchestrator",
"claude",
"#89b4fa", // blue
),
ProjectConfig::new(
"quanta-discord-bot",
"~/code/bots/quanta-discord-bot",
"claude",
"#a6e3a1", // green
),
]
}
/// Create demo messages for a project agent pane.
pub fn demo_messages() -> Vec<AgentMessage> {
vec![
AgentMessage {
id: "m1".to_string(),
role: MessageRole::User,
content: "Add error handling to the WebSocket reconnection logic. It should use \
exponential backoff with a 30s cap."
.to_string(),
tool_name: None,
tool_output: None,
},
AgentMessage {
id: "m2".to_string(),
role: MessageRole::Assistant,
content: "I'll add exponential backoff to the WebSocket reconnection in \
`remote.rs`. Let me first read the current implementation."
.to_string(),
tool_name: None,
tool_output: None,
},
AgentMessage {
id: "m3".to_string(),
role: MessageRole::Tool,
content: "Read src-tauri/src/remote.rs".to_string(),
tool_name: Some("Read".to_string()),
tool_output: Some(
"pub struct RemoteManager {\n connections: HashMap<String, WebSocketConn>,\n \
reconnect_attempts: HashMap<String, u32>,\n}"
.to_string(),
),
},
AgentMessage {
id: "m4".to_string(),
role: MessageRole::Assistant,
content: "I can see the current implementation tracks reconnect attempts but \
doesn't implement backoff. I'll add the exponential backoff logic \
with a 30-second cap."
.to_string(),
tool_name: None,
tool_output: None,
},
AgentMessage {
id: "m5".to_string(),
role: MessageRole::Tool,
content: "Edit src-tauri/src/remote.rs".to_string(),
tool_name: Some("Edit".to_string()),
tool_output: Some(
"Added reconnect_with_backoff() method:\n\
- Base delay: 1s\n\
- Multiplier: 2x per attempt\n\
- Cap: 30s\n\
- Max retries: 5"
.to_string(),
),
},
AgentMessage {
id: "m6".to_string(),
role: MessageRole::Assistant,
content: "Done. The WebSocket reconnection now uses exponential backoff \
(1s -> 2s -> 4s -> 8s -> 16s -> 30s cap). Added \
`reconnect_with_backoff()` method with 5 max retries before \
giving up and emitting a `remote-machine-failed` event."
.to_string(),
tool_name: None,
tool_output: None,
},
]
}
/// Demo terminal output lines.
pub fn demo_terminal_lines() -> Vec<TerminalLine> {
vec![
TerminalLine {
kind: TerminalLineKind::Prompt,
text: "~/code/ai/agent-orchestrator $ ".to_string(),
},
TerminalLine {
kind: TerminalLineKind::Output,
text: "cargo test --workspace".to_string(),
},
TerminalLine {
kind: TerminalLineKind::Output,
text: " Compiling agor-core v0.1.0".to_string(),
},
TerminalLine {
kind: TerminalLineKind::Output,
text: " Compiling agor-dioxus v0.1.0".to_string(),
},
TerminalLine {
kind: TerminalLineKind::Output,
text: " Running tests/unit.rs".to_string(),
},
TerminalLine {
kind: TerminalLineKind::Output,
text: "test result: ok. 47 passed; 0 failed".to_string(),
},
TerminalLine {
kind: TerminalLineKind::Prompt,
text: "~/code/ai/agent-orchestrator $ ".to_string(),
},
]
}
/// Command palette command entries.
#[derive(Debug, Clone)]
pub struct PaletteCommand {
pub label: String,
pub shortcut: Option<String>,
pub icon: String,
}
pub fn palette_commands() -> Vec<PaletteCommand> {
vec![
PaletteCommand {
label: "New Agent Session".to_string(),
shortcut: Some("Ctrl+N".to_string()),
icon: "\u{25B6}".to_string(), // play
},
PaletteCommand {
label: "Stop Agent".to_string(),
shortcut: Some("Ctrl+C".to_string()),
icon: "\u{25A0}".to_string(), // stop
},
PaletteCommand {
label: "Toggle Settings".to_string(),
shortcut: Some("Ctrl+,".to_string()),
icon: "\u{2699}".to_string(), // gear
},
PaletteCommand {
label: "Switch Project Group".to_string(),
shortcut: Some("Ctrl+G".to_string()),
icon: "\u{25A3}".to_string(), // box
},
PaletteCommand {
label: "Focus Next Project".to_string(),
shortcut: Some("Ctrl+]".to_string()),
icon: "\u{2192}".to_string(), // arrow right
},
PaletteCommand {
label: "Toggle Terminal".to_string(),
shortcut: Some("Ctrl+`".to_string()),
icon: "\u{2588}".to_string(), // terminal
},
PaletteCommand {
label: "Search Everything".to_string(),
shortcut: Some("Ctrl+Shift+F".to_string()),
icon: "\u{1F50D}".to_string(), // magnifying glass (will render as text)
},
PaletteCommand {
label: "Reload Agent Config".to_string(),
shortcut: None,
icon: "\u{21BB}".to_string(), // reload
},
]
}

760
ui-dioxus/src/theme.rs Normal file
View file

@ -0,0 +1,760 @@
/// Catppuccin Mocha palette + CSS custom property generation.
///
/// All UI colors flow through --ctp-* CSS custom properties.
/// Components NEVER hardcode colors — they reference these variables.
/// Catppuccin Mocha color palette — hex values.
pub struct CatppuccinMocha;
impl CatppuccinMocha {
// Base colors
pub const BASE: &str = "#1e1e2e";
pub const MANTLE: &str = "#181825";
pub const CRUST: &str = "#11111b";
// Surface colors
pub const SURFACE0: &str = "#313244";
pub const SURFACE1: &str = "#45475a";
pub const SURFACE2: &str = "#585b70";
// Overlay colors
pub const OVERLAY0: &str = "#6c7086";
pub const OVERLAY1: &str = "#7f849c";
pub const OVERLAY2: &str = "#9399b2";
// Text colors
pub const SUBTEXT0: &str = "#a6adc8";
pub const SUBTEXT1: &str = "#bac2de";
pub const TEXT: &str = "#cdd6f4";
// Accent colors
pub const ROSEWATER: &str = "#f5e0dc";
pub const FLAMINGO: &str = "#f2cdcd";
pub const PINK: &str = "#f5c2e7";
pub const MAUVE: &str = "#cba6f7";
pub const RED: &str = "#f38ba8";
pub const MAROON: &str = "#eba0ac";
pub const PEACH: &str = "#fab387";
pub const YELLOW: &str = "#f9e2af";
pub const GREEN: &str = "#a6e3a1";
pub const TEAL: &str = "#94e2d5";
pub const SKY: &str = "#89dceb";
pub const SAPPHIRE: &str = "#74c7ec";
pub const BLUE: &str = "#89b4fa";
pub const LAVENDER: &str = "#b4befe";
}
/// Generate complete CSS stylesheet with Catppuccin Mocha custom properties
/// and base layout styles.
pub fn generate_css() -> String {
format!(
r#"
/* === Catppuccin Mocha CSS Custom Properties === */
:root {{
/* Base */
--ctp-base: {base};
--ctp-mantle: {mantle};
--ctp-crust: {crust};
/* Surface */
--ctp-surface0: {surface0};
--ctp-surface1: {surface1};
--ctp-surface2: {surface2};
/* Overlay */
--ctp-overlay0: {overlay0};
--ctp-overlay1: {overlay1};
--ctp-overlay2: {overlay2};
/* Text */
--ctp-subtext0: {subtext0};
--ctp-subtext1: {subtext1};
--ctp-text: {text};
/* Accents */
--ctp-rosewater: {rosewater};
--ctp-flamingo: {flamingo};
--ctp-pink: {pink};
--ctp-mauve: {mauve};
--ctp-red: {red};
--ctp-maroon: {maroon};
--ctp-peach: {peach};
--ctp-yellow: {yellow};
--ctp-green: {green};
--ctp-teal: {teal};
--ctp-sky: {sky};
--ctp-sapphire: {sapphire};
--ctp-blue: {blue};
--ctp-lavender: {lavender};
/* Typography */
--ui-font-family: 'Inter', system-ui, -apple-system, sans-serif;
--ui-font-size: 0.875rem;
--term-font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--term-font-size: 0.8125rem;
}}
/* === Global Reset === */
*, *::before, *::after {{
box-sizing: border-box;
margin: 0;
padding: 0;
}}
body {{
font-family: var(--ui-font-family);
font-size: var(--ui-font-size);
color: var(--ctp-text);
background: var(--ctp-base);
overflow: hidden;
height: 100vh;
width: 100vw;
}}
#main {{
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
}}
/* === Scrollbar Styling === */
::-webkit-scrollbar {{
width: 0.5rem;
height: 0.5rem;
}}
::-webkit-scrollbar-track {{
background: var(--ctp-mantle);
}}
::-webkit-scrollbar-thumb {{
background: var(--ctp-surface1);
border-radius: 0.25rem;
}}
::-webkit-scrollbar-thumb:hover {{
background: var(--ctp-surface2);
}}
/* === Layout Classes === */
.app-shell {{
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}}
.app-body {{
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}}
/* === Sidebar === */
.sidebar-rail {{
width: 2.75rem;
background: var(--ctp-crust);
border-right: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 0.75rem;
gap: 0.5rem;
flex-shrink: 0;
}}
.sidebar-icon {{
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
cursor: pointer;
color: var(--ctp-overlay1);
transition: all 0.15s ease;
font-size: 1rem;
}}
.sidebar-icon:hover {{
background: var(--ctp-surface0);
color: var(--ctp-text);
}}
.sidebar-icon.active {{
background: var(--ctp-surface0);
color: var(--ctp-blue);
}}
.sidebar-spacer {{
flex: 1;
}}
/* === Drawer Panel === */
.drawer-panel {{
width: 18rem;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
padding: 1rem;
flex-shrink: 0;
}}
.drawer-title {{
font-size: 0.8125rem;
font-weight: 600;
color: var(--ctp-subtext1);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}}
/* === Project Grid === */
.project-grid {{
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
gap: 0.75rem;
padding: 0.75rem;
overflow-y: auto;
align-content: start;
}}
/* === Project Box === */
.project-box {{
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface0);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
min-height: 20rem;
max-height: 40rem;
overflow: hidden;
}}
.project-header {{
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--ctp-crust);
border-bottom: 1px solid var(--ctp-surface0);
border-radius: 0.5rem 0.5rem 0 0;
flex-shrink: 0;
}}
.project-header .status-dot {{
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}}
.status-dot.running {{
background: var(--ctp-green);
box-shadow: 0 0 6px var(--ctp-green);
animation: pulse 2s ease-in-out infinite;
}}
.status-dot.idle {{
background: var(--ctp-overlay0);
}}
.status-dot.stalled {{
background: var(--ctp-peach);
animation: pulse 1.5s ease-in-out infinite;
}}
.status-dot.error {{
background: var(--ctp-red);
}}
@keyframes pulse {{
0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.5; }}
}}
.project-name {{
font-weight: 600;
font-size: 0.875rem;
color: var(--ctp-text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}}
.project-cwd {{
font-size: 0.6875rem;
color: var(--ctp-overlay0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
max-width: 12rem;
}}
/* === Tab Bar === */
.tab-bar {{
display: flex;
gap: 0;
background: var(--ctp-crust);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}}
.tab {{
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
color: var(--ctp-overlay1);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s ease;
user-select: none;
}}
.tab:hover {{
color: var(--ctp-text);
background: var(--ctp-surface0);
}}
.tab.active {{
color: var(--ctp-blue);
border-bottom-color: var(--ctp-blue);
}}
/* === Tab Content === */
.tab-content {{
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}}
/* === Agent Pane === */
.agent-pane {{
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}}
.message-list {{
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}}
.message {{
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
line-height: 1.5;
word-wrap: break-word;
}}
.message.user {{
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
border-left: 3px solid var(--ctp-blue);
}}
.message.assistant {{
background: color-mix(in srgb, var(--ctp-mauve) 8%, transparent);
border-left: 3px solid var(--ctp-mauve);
}}
.message.tool {{
background: color-mix(in srgb, var(--ctp-teal) 8%, transparent);
border-left: 3px solid var(--ctp-teal);
font-family: var(--term-font-family);
font-size: 0.75rem;
}}
.message-role {{
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}}
.message.user .message-role {{ color: var(--ctp-blue); }}
.message.assistant .message-role {{ color: var(--ctp-mauve); }}
.message.tool .message-role {{ color: var(--ctp-teal); }}
.message-text {{
color: var(--ctp-text);
}}
.tool-details {{
margin-top: 0.25rem;
}}
.tool-details summary {{
cursor: pointer;
color: var(--ctp-overlay1);
font-size: 0.6875rem;
user-select: none;
}}
.tool-details summary:hover {{
color: var(--ctp-text);
}}
.tool-output {{
margin-top: 0.375rem;
padding: 0.375rem 0.5rem;
background: var(--ctp-crust);
border-radius: 0.25rem;
font-family: var(--term-font-family);
font-size: 0.6875rem;
color: var(--ctp-subtext0);
white-space: pre-wrap;
max-height: 10rem;
overflow-y: auto;
}}
/* === Prompt Input === */
.prompt-bar {{
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--ctp-crust);
border-top: 1px solid var(--ctp-surface0);
align-items: center;
flex-shrink: 0;
}}
.prompt-input {{
flex: 1;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
padding: 0.4375rem 0.75rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
outline: none;
transition: border-color 0.15s ease;
}}
.prompt-input:focus {{
border-color: var(--ctp-blue);
}}
.prompt-input::placeholder {{
color: var(--ctp-overlay0);
}}
.prompt-send {{
background: var(--ctp-blue);
color: var(--ctp-crust);
border: none;
border-radius: 0.375rem;
padding: 0.4375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s ease;
}}
.prompt-send:hover {{
opacity: 0.85;
}}
.prompt-send:disabled {{
opacity: 0.4;
cursor: not-allowed;
}}
/* === Agent Status Strip === */
.agent-status {{
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: var(--ctp-crust);
border-top: 1px solid var(--ctp-surface0);
font-size: 0.6875rem;
color: var(--ctp-overlay1);
flex-shrink: 0;
}}
.status-badge {{
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 600;
text-transform: uppercase;
font-size: 0.625rem;
letter-spacing: 0.05em;
}}
.status-badge.idle {{
background: color-mix(in srgb, var(--ctp-overlay0) 20%, transparent);
color: var(--ctp-overlay1);
}}
.status-badge.running {{
background: color-mix(in srgb, var(--ctp-green) 20%, transparent);
color: var(--ctp-green);
}}
.status-badge.done {{
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
color: var(--ctp-blue);
}}
.status-badge.error {{
background: color-mix(in srgb, var(--ctp-red) 20%, transparent);
color: var(--ctp-red);
}}
/* === Terminal Area === */
.terminal-area {{
background: var(--ctp-crust);
border-top: 1px solid var(--ctp-surface0);
height: 8rem;
overflow-y: auto;
padding: 0.5rem;
font-family: var(--term-font-family);
font-size: var(--term-font-size);
color: var(--ctp-green);
flex-shrink: 0;
}}
.terminal-line {{
line-height: 1.4;
white-space: pre-wrap;
}}
.terminal-prompt {{
color: var(--ctp-blue);
}}
.terminal-output {{
color: var(--ctp-subtext0);
}}
/* === Status Bar === */
.status-bar {{
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.75rem;
background: var(--ctp-crust);
border-top: 1px solid var(--ctp-surface0);
font-size: 0.6875rem;
color: var(--ctp-overlay1);
flex-shrink: 0;
height: 1.5rem;
}}
.status-bar-left,
.status-bar-right {{
display: flex;
align-items: center;
gap: 1rem;
}}
.status-item {{
display: flex;
align-items: center;
gap: 0.25rem;
}}
.status-count {{
font-weight: 600;
}}
.status-count.running {{ color: var(--ctp-green); }}
.status-count.idle {{ color: var(--ctp-overlay1); }}
.status-count.stalled {{ color: var(--ctp-peach); }}
.status-cost {{
color: var(--ctp-yellow);
font-weight: 600;
}}
/* === Settings Panel === */
.settings-section {{
margin-bottom: 1.5rem;
}}
.settings-section-title {{
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-subtext0);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--ctp-surface0);
}}
.settings-row {{
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0;
font-size: 0.8125rem;
}}
.settings-label {{
color: var(--ctp-subtext1);
}}
.settings-value {{
color: var(--ctp-text);
font-weight: 500;
}}
.settings-select {{
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
color: var(--ctp-text);
font-size: 0.75rem;
outline: none;
}}
.settings-select:focus {{
border-color: var(--ctp-blue);
}}
/* === Command Palette === */
.palette-overlay {{
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 20vh;
z-index: 1000;
}}
.palette-box {{
width: 32rem;
max-width: 90vw;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface0);
border-radius: 0.5rem;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.4);
overflow: hidden;
}}
.palette-input {{
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.9375rem;
outline: none;
}}
.palette-input::placeholder {{
color: var(--ctp-overlay0);
}}
.palette-results {{
max-height: 20rem;
overflow-y: auto;
}}
.palette-item {{
padding: 0.5rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
transition: background 0.1s ease;
}}
.palette-item:hover, .palette-item.selected {{
background: var(--ctp-surface0);
}}
.palette-item-icon {{
color: var(--ctp-overlay1);
font-size: 0.875rem;
width: 1.25rem;
text-align: center;
}}
.palette-item-label {{
color: var(--ctp-text);
font-size: 0.8125rem;
}}
.palette-item-shortcut {{
margin-left: auto;
font-size: 0.6875rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family);
}}
/* === Docs Tab === */
.docs-pane {{
padding: 0.75rem;
overflow-y: auto;
flex: 1;
}}
.docs-entry {{
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
color: var(--ctp-subtext1);
font-size: 0.8125rem;
transition: background 0.1s ease;
}}
.docs-entry:hover {{
background: var(--ctp-surface0);
color: var(--ctp-text);
}}
/* === Files Tab === */
.files-pane {{
padding: 0.75rem;
overflow-y: auto;
flex: 1;
}}
.file-tree-item {{
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
color: var(--ctp-subtext1);
font-size: 0.75rem;
font-family: var(--term-font-family);
}}
.file-tree-item:hover {{
background: var(--ctp-surface0);
color: var(--ctp-text);
}}
.file-tree-icon {{
color: var(--ctp-overlay0);
width: 1rem;
text-align: center;
}}
/* === Provider Badge === */
.provider-badge {{
font-size: 0.5625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.0625rem 0.3125rem;
border-radius: 0.1875rem;
background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent);
color: var(--ctp-mauve);
}}
"#,
base = CatppuccinMocha::BASE,
mantle = CatppuccinMocha::MANTLE,
crust = CatppuccinMocha::CRUST,
surface0 = CatppuccinMocha::SURFACE0,
surface1 = CatppuccinMocha::SURFACE1,
surface2 = CatppuccinMocha::SURFACE2,
overlay0 = CatppuccinMocha::OVERLAY0,
overlay1 = CatppuccinMocha::OVERLAY1,
overlay2 = CatppuccinMocha::OVERLAY2,
subtext0 = CatppuccinMocha::SUBTEXT0,
subtext1 = CatppuccinMocha::SUBTEXT1,
text = CatppuccinMocha::TEXT,
rosewater = CatppuccinMocha::ROSEWATER,
flamingo = CatppuccinMocha::FLAMINGO,
pink = CatppuccinMocha::PINK,
mauve = CatppuccinMocha::MAUVE,
red = CatppuccinMocha::RED,
maroon = CatppuccinMocha::MAROON,
peach = CatppuccinMocha::PEACH,
yellow = CatppuccinMocha::YELLOW,
green = CatppuccinMocha::GREEN,
teal = CatppuccinMocha::TEAL,
sky = CatppuccinMocha::SKY,
sapphire = CatppuccinMocha::SAPPHIRE,
blue = CatppuccinMocha::BLUE,
lavender = CatppuccinMocha::LAVENDER,
)
}