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
5312
ui-dioxus/Cargo.lock
generated
Normal file
5312
ui-dioxus/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
ui-dioxus/Cargo.toml
Normal file
18
ui-dioxus/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "agor-dioxus"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Agent Orchestrator UI prototype — Dioxus 0.7 desktop"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
# Standalone — not part of the parent workspace
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dioxus = { version = "0.7", features = ["desktop"] }
|
||||||
|
agor-core = { path = "../agor-core" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
log = "0.4"
|
||||||
6
ui-dioxus/Dioxus.toml
Normal file
6
ui-dioxus/Dioxus.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[application]
|
||||||
|
name = "Agent Orchestrator"
|
||||||
|
default_platform = "desktop"
|
||||||
|
|
||||||
|
[desktop]
|
||||||
|
title = "Agent Orchestrator — Dioxus Prototype"
|
||||||
180
ui-dioxus/src/backend.rs
Normal file
180
ui-dioxus/src/backend.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
ui-dioxus/src/main.rs
Normal file
136
ui-dioxus/src/main.rs
Normal 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
350
ui-dioxus/src/state.rs
Normal 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
760
ui-dioxus/src/theme.rs
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
7452
ui-gpui/Cargo.lock
generated
Normal file
7452
ui-gpui/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
ui-gpui/Cargo.toml
Normal file
20
ui-gpui/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "agor-gpui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "GPU-accelerated Agent Orchestrator UI prototype using Zed's GPUI framework"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
# Standalone — NOT part of the workspace Cargo.toml
|
||||||
|
# Build with: cd ui-gpui && cargo build
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
gpui = "0.2"
|
||||||
|
agor-core = { path = "../agor-core" }
|
||||||
|
alacritty_terminal = "0.25"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
dirs = "5"
|
||||||
129
ui-gpui/src/backend.rs
Normal file
129
ui-gpui/src/backend.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
//! Bridge to agor-core: PtyManager, SidecarManager, EventSink.
|
||||||
|
//!
|
||||||
|
//! Implements the `EventSink` trait from agor-core so that PTY and sidecar
|
||||||
|
//! events flow into GPUI's entity system via channel-based async dispatch.
|
||||||
|
|
||||||
|
use agor_core::event::EventSink;
|
||||||
|
use agor_core::pty::{PtyManager, PtyOptions};
|
||||||
|
use agor_core::sandbox::SandboxConfig;
|
||||||
|
use agor_core::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
|
||||||
|
// ── GPUI EventSink ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Event payload sent from backend threads to the GPUI main thread.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BackendEvent {
|
||||||
|
pub event_name: String,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `EventSink` that queues events into an `mpsc` channel.
|
||||||
|
/// The GPUI main loop drains this channel each frame to update entity state.
|
||||||
|
pub struct GpuiEventSink {
|
||||||
|
sender: mpsc::Sender<BackendEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GpuiEventSink {
|
||||||
|
pub fn new(sender: mpsc::Sender<BackendEvent>) -> Self {
|
||||||
|
Self { sender }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventSink for GpuiEventSink {
|
||||||
|
fn emit(&self, event: &str, payload: serde_json::Value) {
|
||||||
|
let _ = self.sender.send(BackendEvent {
|
||||||
|
event_name: event.to_string(),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backend Manager ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Owns the agor-core managers and the event channel.
|
||||||
|
/// Created once at app startup; shared via `Arc` or `Entity<Backend>`.
|
||||||
|
pub struct Backend {
|
||||||
|
pub pty_manager: PtyManager,
|
||||||
|
pub sidecar_manager: SidecarManager,
|
||||||
|
pub event_rx: mpsc::Receiver<BackendEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
/// Create backend with default sidecar search paths.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let sink: Arc<dyn EventSink> = Arc::new(GpuiEventSink::new(tx));
|
||||||
|
|
||||||
|
let pty_manager = PtyManager::new(Arc::clone(&sink));
|
||||||
|
|
||||||
|
// Sidecar search paths: look next to the binary, then common install locations
|
||||||
|
let mut search_paths = vec![
|
||||||
|
PathBuf::from("./sidecar/dist"),
|
||||||
|
PathBuf::from("../sidecar/dist"),
|
||||||
|
];
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
search_paths.push(dir.join("sidecar"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sidecar_config = SidecarConfig {
|
||||||
|
search_paths,
|
||||||
|
env_overrides: HashMap::new(),
|
||||||
|
sandbox: SandboxConfig::default(),
|
||||||
|
};
|
||||||
|
let sidecar_manager = SidecarManager::new(Arc::clone(&sink), sidecar_config);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
pty_manager,
|
||||||
|
sidecar_manager,
|
||||||
|
event_rx: rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a PTY for a project terminal.
|
||||||
|
pub fn spawn_pty(&self, cwd: &str) -> Result<String, String> {
|
||||||
|
self.pty_manager.spawn(PtyOptions {
|
||||||
|
shell: None,
|
||||||
|
cwd: Some(cwd.to_string()),
|
||||||
|
args: None,
|
||||||
|
cols: Some(120),
|
||||||
|
rows: Some(30),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start an agent query (sends to sidecar, non-blocking).
|
||||||
|
pub fn start_agent(&self, session_id: &str, prompt: &str, cwd: &str) {
|
||||||
|
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: HashMap::new(),
|
||||||
|
};
|
||||||
|
let _ = self.sidecar_manager.query(&options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain all pending backend events (call once per frame / tick).
|
||||||
|
pub fn drain_events(&self) -> Vec<BackendEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
while let Ok(ev) = self.event_rx.try_recv() {
|
||||||
|
events.push(ev);
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
}
|
||||||
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
102
ui-gpui/src/main.rs
Normal file
102
ui-gpui/src/main.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
//! agor-gpui: GPU-accelerated Agent Orchestrator UI prototype.
|
||||||
|
//!
|
||||||
|
//! Uses Zed's GPUI framework for native GPU rendering.
|
||||||
|
//! This is a decision-making prototype comparing against the existing Tauri+Svelte app.
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! main.rs → Application::new().run()
|
||||||
|
//! └─ Workspace (root view)
|
||||||
|
//! ├─ Sidebar (icon rail)
|
||||||
|
//! ├─ SettingsPanel (drawer, optional)
|
||||||
|
//! ├─ ProjectGrid
|
||||||
|
//! │ ├─ ProjectBox[0]
|
||||||
|
//! │ │ ├─ AgentPane (message list + prompt)
|
||||||
|
//! │ │ └─ TerminalView (GPU-rendered via alacritty_terminal)
|
||||||
|
//! │ └─ ProjectBox[1]
|
||||||
|
//! │ ├─ AgentPane
|
||||||
|
//! │ └─ TerminalView
|
||||||
|
//! ├─ StatusBar (bottom)
|
||||||
|
//! └─ CommandPalette (overlay, optional)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Backend Integration
|
||||||
|
//!
|
||||||
|
//! `backend.rs` bridges to `agor-core` (PtyManager, SidecarManager) via an
|
||||||
|
//! `EventSink` that queues events into an mpsc channel. The GPUI main loop
|
||||||
|
//! drains this channel to update entity state reactively.
|
||||||
|
//!
|
||||||
|
//! # Key Differentiator: GPU Terminal
|
||||||
|
//!
|
||||||
|
//! `terminal/renderer.rs` uses `alacritty_terminal::Term` for VT100 parsing
|
||||||
|
//! and renders each cell directly via GPUI's text pipeline — no DOM, no
|
||||||
|
//! Canvas 2D context, just GPU-accelerated glyph rendering.
|
||||||
|
|
||||||
|
mod backend;
|
||||||
|
mod components;
|
||||||
|
mod state;
|
||||||
|
mod theme;
|
||||||
|
mod terminal;
|
||||||
|
mod workspace;
|
||||||
|
|
||||||
|
use gpui::*;
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
use workspace::Workspace;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Initialize the GPUI application
|
||||||
|
Application::new().run(|cx: &mut App| {
|
||||||
|
// Create shared application state with demo data
|
||||||
|
let app_state: Entity<AppState> = cx.new(|_cx| AppState::new_demo());
|
||||||
|
|
||||||
|
// Configure window
|
||||||
|
let window_options = WindowOptions {
|
||||||
|
// Use default window bounds (the OS will position/size it)
|
||||||
|
focus: true,
|
||||||
|
show: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open the main window with Workspace as the root view
|
||||||
|
let _window = cx
|
||||||
|
.open_window(window_options, |window, cx| {
|
||||||
|
// Set window title
|
||||||
|
window.set_window_title("Agent Orchestrator — GPUI Prototype");
|
||||||
|
|
||||||
|
// Create the workspace root view
|
||||||
|
cx.new(|cx| Workspace::new(app_state.clone(), cx))
|
||||||
|
})
|
||||||
|
.expect("Failed to open window");
|
||||||
|
|
||||||
|
// TODO: Set up keyboard bindings for Ctrl+K (palette), Ctrl+B (sidebar), Ctrl+, (settings)
|
||||||
|
// GPUI uses an action dispatch system:
|
||||||
|
// 1. Define action structs with #[derive(Action)]
|
||||||
|
// 2. Register key bindings via cx.bind_keys()
|
||||||
|
// 3. Handle actions via window.on_action() or element .on_action()
|
||||||
|
//
|
||||||
|
// Example (requires gpui::actions! macro):
|
||||||
|
// actions!(agor, [TogglePalette, ToggleSidebar, OpenSettings]);
|
||||||
|
// cx.bind_keys([
|
||||||
|
// KeyBinding::new("ctrl-k", TogglePalette, None),
|
||||||
|
// KeyBinding::new("ctrl-b", ToggleSidebar, None),
|
||||||
|
// KeyBinding::new("ctrl-,", OpenSettings, None),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// TODO: Start backend event polling loop
|
||||||
|
// In a full implementation, we'd spawn a timer or use cx.spawn() to
|
||||||
|
// periodically drain backend events and update the app state:
|
||||||
|
//
|
||||||
|
// let backend = Backend::new();
|
||||||
|
// cx.spawn(|cx| async move {
|
||||||
|
// loop {
|
||||||
|
// Timer::after(Duration::from_millis(16)).await; // ~60fps
|
||||||
|
// let events = backend.drain_events();
|
||||||
|
// for ev in events {
|
||||||
|
// // Route to appropriate entity updates
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }).detach();
|
||||||
|
});
|
||||||
|
}
|
||||||
262
ui-gpui/src/state.rs
Normal file
262
ui-gpui/src/state.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
//! Application state — projects, agents, settings.
|
||||||
|
//!
|
||||||
|
//! All mutable state lives in `AppState`, wrapped in `Entity<AppState>` for
|
||||||
|
//! GPUI reactivity. Components read via `entity.read(cx)` and mutate via
|
||||||
|
//! `entity.update(cx, |state, cx| { ... cx.notify(); })`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ── Agent Message Types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum MessageRole {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub role: MessageRole,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
/// For tool calls: the tool name.
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
/// For tool results: the result content.
|
||||||
|
pub tool_result: Option<String>,
|
||||||
|
/// Whether this message block is collapsed in the UI.
|
||||||
|
pub collapsed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent State ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum AgentStatus {
|
||||||
|
Idle,
|
||||||
|
Running,
|
||||||
|
Done,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ProjectTab {
|
||||||
|
Model,
|
||||||
|
Docs,
|
||||||
|
Files,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Project {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub cwd: String,
|
||||||
|
pub active_tab: ProjectTab,
|
||||||
|
pub agent: AgentSession,
|
||||||
|
/// Accent color index (cycles through palette).
|
||||||
|
pub accent_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Project {
|
||||||
|
pub fn new(name: &str, cwd: &str, accent_index: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
cwd: cwd.to_string(),
|
||||||
|
active_tab: ProjectTab::Model,
|
||||||
|
agent: AgentSession::new(),
|
||||||
|
accent_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub theme: String,
|
||||||
|
pub ui_font_family: String,
|
||||||
|
pub ui_font_size: f32,
|
||||||
|
pub term_font_family: String,
|
||||||
|
pub term_font_size: f32,
|
||||||
|
pub default_shell: String,
|
||||||
|
pub default_cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
theme: "Catppuccin Mocha".to_string(),
|
||||||
|
ui_font_family: "Inter".to_string(),
|
||||||
|
ui_font_size: 14.0,
|
||||||
|
term_font_family: "JetBrains Mono".to_string(),
|
||||||
|
term_font_size: 14.0,
|
||||||
|
default_shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
|
||||||
|
default_cwd: dirs::home_dir()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "/".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command Palette ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PaletteCommand {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
pub shortcut: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_commands() -> Vec<PaletteCommand> {
|
||||||
|
vec![
|
||||||
|
PaletteCommand {
|
||||||
|
id: "settings",
|
||||||
|
label: "Open Settings",
|
||||||
|
shortcut: Some("Ctrl+,"),
|
||||||
|
},
|
||||||
|
PaletteCommand {
|
||||||
|
id: "new_project",
|
||||||
|
label: "Add Project",
|
||||||
|
shortcut: Some("Ctrl+N"),
|
||||||
|
},
|
||||||
|
PaletteCommand {
|
||||||
|
id: "toggle_sidebar",
|
||||||
|
label: "Toggle Sidebar",
|
||||||
|
shortcut: Some("Ctrl+B"),
|
||||||
|
},
|
||||||
|
PaletteCommand {
|
||||||
|
id: "focus_next",
|
||||||
|
label: "Focus Next Project",
|
||||||
|
shortcut: Some("Ctrl+]"),
|
||||||
|
},
|
||||||
|
PaletteCommand {
|
||||||
|
id: "focus_prev",
|
||||||
|
label: "Focus Previous Project",
|
||||||
|
shortcut: Some("Ctrl+["),
|
||||||
|
},
|
||||||
|
PaletteCommand {
|
||||||
|
id: "close_project",
|
||||||
|
label: "Close Focused Project",
|
||||||
|
shortcut: None,
|
||||||
|
},
|
||||||
|
PaletteCommand {
|
||||||
|
id: "restart_agent",
|
||||||
|
label: "Restart Agent",
|
||||||
|
shortcut: None,
|
||||||
|
},
|
||||||
|
PaletteCommand {
|
||||||
|
id: "stop_agent",
|
||||||
|
label: "Stop Agent",
|
||||||
|
shortcut: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Root Application State ──────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub projects: Vec<Project>,
|
||||||
|
pub focused_project_idx: Option<usize>,
|
||||||
|
pub settings: Settings,
|
||||||
|
pub sidebar_open: bool,
|
||||||
|
pub settings_open: bool,
|
||||||
|
pub palette_open: bool,
|
||||||
|
pub palette_query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Create initial state with demo projects.
|
||||||
|
pub fn new_demo() -> Self {
|
||||||
|
Self {
|
||||||
|
projects: vec![
|
||||||
|
Project::new("agent-orchestrator", "~/code/ai/agent-orchestrator", 0),
|
||||||
|
Project::new("quanta-discord-bot", "~/code/quanta-discord-bot", 1),
|
||||||
|
],
|
||||||
|
focused_project_idx: Some(0),
|
||||||
|
settings: Settings::default(),
|
||||||
|
sidebar_open: true,
|
||||||
|
settings_open: false,
|
||||||
|
palette_open: false,
|
||||||
|
palette_query: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focused_project(&self) -> Option<&Project> {
|
||||||
|
self.focused_project_idx
|
||||||
|
.and_then(|i| self.projects.get(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focused_project_mut(&mut self) -> Option<&mut Project> {
|
||||||
|
self.focused_project_idx
|
||||||
|
.and_then(|i| self.projects.get_mut(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_sidebar(&mut self) {
|
||||||
|
self.sidebar_open = !self.sidebar_open;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_settings(&mut self) {
|
||||||
|
self.settings_open = !self.settings_open;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_palette(&mut self) {
|
||||||
|
self.palette_open = !self.palette_open;
|
||||||
|
if self.palette_open {
|
||||||
|
self.palette_query.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filtered palette commands based on current query.
|
||||||
|
pub fn filtered_commands(&self) -> Vec<PaletteCommand> {
|
||||||
|
let q = self.palette_query.to_lowercase();
|
||||||
|
all_commands()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|cmd| q.is_empty() || cmd.label.to_lowercase().contains(&q))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total running agents.
|
||||||
|
pub fn running_agent_count(&self) -> usize {
|
||||||
|
self.projects
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.agent.status == AgentStatus::Running)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total cost across all agents.
|
||||||
|
pub fn total_cost(&self) -> f64 {
|
||||||
|
self.projects.iter().map(|p| p.agent.cost_usd).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total tokens across all agents.
|
||||||
|
pub fn total_tokens(&self) -> u64 {
|
||||||
|
self.projects.iter().map(|p| p.agent.tokens_used).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
9
ui-gpui/src/terminal/mod.rs
Normal file
9
ui-gpui/src/terminal/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//! GPU-rendered terminal using alacritty_terminal + GPUI rendering.
|
||||||
|
//!
|
||||||
|
//! This is the key differentiator vs. xterm.js:
|
||||||
|
//! - No DOM/Canvas overhead — cells painted directly via GPU text pipeline
|
||||||
|
//! - Same VT100 state machine as Alacritty (battle-tested)
|
||||||
|
//! - PTY bridged through agor-core's PtyManager
|
||||||
|
|
||||||
|
pub mod pty_bridge;
|
||||||
|
pub mod renderer;
|
||||||
74
ui-gpui/src/terminal/pty_bridge.rs
Normal file
74
ui-gpui/src/terminal/pty_bridge.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
//! PTY integration via agor-core.
|
||||||
|
//!
|
||||||
|
//! Bridges agor-core's PtyManager to alacritty_terminal's event loop.
|
||||||
|
//! Reads PTY output in a background thread, feeds it into the Term state machine,
|
||||||
|
//! and notifies GPUI to repaint.
|
||||||
|
|
||||||
|
use agor_core::pty::{PtyManager, PtyOptions};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Manages a single PTY ↔ Terminal connection.
|
||||||
|
pub struct PtyBridge {
|
||||||
|
pub pty_id: Option<String>,
|
||||||
|
pty_manager: Arc<PtyManager>,
|
||||||
|
/// Raw bytes received from PTY, buffered for the renderer to consume.
|
||||||
|
pub output_buffer: Arc<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PtyBridge {
|
||||||
|
pub fn new(pty_manager: Arc<PtyManager>) -> Self {
|
||||||
|
Self {
|
||||||
|
pty_id: None,
|
||||||
|
pty_manager,
|
||||||
|
output_buffer: Arc::new(Mutex::new(Vec::with_capacity(8192))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a PTY process and start reading its output.
|
||||||
|
pub fn spawn(&mut self, cwd: &str) -> Result<(), String> {
|
||||||
|
let id = self.pty_manager.spawn(PtyOptions {
|
||||||
|
shell: None,
|
||||||
|
cwd: Some(cwd.to_string()),
|
||||||
|
args: None,
|
||||||
|
cols: Some(120),
|
||||||
|
rows: Some(30),
|
||||||
|
})?;
|
||||||
|
self.pty_id = Some(id);
|
||||||
|
// NOTE: In a full implementation, we would start a background thread here
|
||||||
|
// that reads from the PTY master fd and pushes bytes into output_buffer.
|
||||||
|
// The PtyManager currently handles reading internally and emits events via
|
||||||
|
// EventSink. For the GPUI bridge, we would intercept those events in the
|
||||||
|
// Backend::drain_events() loop and feed them here.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write user input to the PTY.
|
||||||
|
pub fn write(&self, data: &str) -> Result<(), String> {
|
||||||
|
if let Some(ref id) = self.pty_id {
|
||||||
|
self.pty_manager
|
||||||
|
.write(id, data)
|
||||||
|
.map_err(|e| format!("PTY write error: {e}"))
|
||||||
|
} else {
|
||||||
|
Err("No PTY spawned".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the PTY.
|
||||||
|
pub fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||||
|
if let Some(ref id) = self.pty_id {
|
||||||
|
self.pty_manager
|
||||||
|
.resize(id, cols, rows)
|
||||||
|
.map_err(|e| format!("PTY resize error: {e}"))
|
||||||
|
} else {
|
||||||
|
Err("No PTY spawned".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain buffered output bytes (consumed by the renderer).
|
||||||
|
pub fn drain_output(&self) -> Vec<u8> {
|
||||||
|
let mut buf = self.output_buffer.lock().unwrap();
|
||||||
|
let data = buf.clone();
|
||||||
|
buf.clear();
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
277
ui-gpui/src/terminal/renderer.rs
Normal file
277
ui-gpui/src/terminal/renderer.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
//! GPU text rendering for terminal cells.
|
||||||
|
//!
|
||||||
|
//! Uses alacritty_terminal::Term for the VT state machine and renders each cell
|
||||||
|
//! as GPUI text elements. This is the "revolution" — no DOM, no Canvas 2D,
|
||||||
|
//! just GPU-accelerated glyph rendering at 120fps.
|
||||||
|
//!
|
||||||
|
//! Architecture:
|
||||||
|
//! 1. `vte::ansi::Processor` parses raw PTY bytes
|
||||||
|
//! 2. `alacritty_terminal::Term` (implements `vte::ansi::Handler`) processes escape sequences
|
||||||
|
//! 3. Grid cells are read via `grid[Line(i)][Column(j)]`
|
||||||
|
//! 4. Each cell is rendered as a GPUI div with the correct foreground color
|
||||||
|
|
||||||
|
use alacritty_terminal::event::{Event as AlacrittyEvent, EventListener};
|
||||||
|
use alacritty_terminal::grid::Dimensions;
|
||||||
|
use alacritty_terminal::index::{Column, Line};
|
||||||
|
use alacritty_terminal::term::Config as TermConfig;
|
||||||
|
use alacritty_terminal::term::Term;
|
||||||
|
use alacritty_terminal::vte::ansi::{Color, NamedColor, Processor};
|
||||||
|
use gpui::*;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
// ── Alacritty Event Listener (no-op for prototype) ──────────────────
|
||||||
|
|
||||||
|
struct GpuiTermEventListener;
|
||||||
|
|
||||||
|
impl EventListener for GpuiTermEventListener {
|
||||||
|
fn send_event(&self, _event: AlacrittyEvent) {
|
||||||
|
// In a full implementation, forward bell, title changes, etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ANSI Color Mapping ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Map alacritty's Color to Catppuccin Mocha Rgba.
|
||||||
|
fn ansi_to_rgba(color: Color) -> Rgba {
|
||||||
|
match color {
|
||||||
|
Color::Named(named) => named_to_rgba(named),
|
||||||
|
Color::Spec(rgb) => Rgba {
|
||||||
|
r: rgb.r as f32 / 255.0,
|
||||||
|
g: rgb.g as f32 / 255.0,
|
||||||
|
b: rgb.b as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
},
|
||||||
|
Color::Indexed(idx) => {
|
||||||
|
if idx < 16 {
|
||||||
|
// Map standard 16 colors to named
|
||||||
|
match idx {
|
||||||
|
0 => theme::SURFACE0,
|
||||||
|
1 => theme::RED,
|
||||||
|
2 => theme::GREEN,
|
||||||
|
3 => theme::YELLOW,
|
||||||
|
4 => theme::BLUE,
|
||||||
|
5 => theme::MAUVE,
|
||||||
|
6 => theme::TEAL,
|
||||||
|
7 => theme::SUBTEXT1,
|
||||||
|
8 => theme::OVERLAY0,
|
||||||
|
9 => theme::FLAMINGO,
|
||||||
|
10 => theme::GREEN,
|
||||||
|
11 => theme::YELLOW,
|
||||||
|
12 => theme::SAPPHIRE,
|
||||||
|
13 => theme::PINK,
|
||||||
|
14 => theme::SKY,
|
||||||
|
15 => theme::TEXT,
|
||||||
|
_ => theme::TEXT,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 216-color cube + 24 grayscale — approximate for prototype
|
||||||
|
theme::TEXT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named_to_rgba(named: NamedColor) -> Rgba {
|
||||||
|
match named {
|
||||||
|
NamedColor::Black => theme::SURFACE0,
|
||||||
|
NamedColor::Red => theme::RED,
|
||||||
|
NamedColor::Green => theme::GREEN,
|
||||||
|
NamedColor::Yellow => theme::YELLOW,
|
||||||
|
NamedColor::Blue => theme::BLUE,
|
||||||
|
NamedColor::Magenta => theme::MAUVE,
|
||||||
|
NamedColor::Cyan => theme::TEAL,
|
||||||
|
NamedColor::White => theme::TEXT,
|
||||||
|
NamedColor::BrightBlack => theme::OVERLAY0,
|
||||||
|
NamedColor::BrightRed => theme::FLAMINGO,
|
||||||
|
NamedColor::BrightGreen => theme::GREEN,
|
||||||
|
NamedColor::BrightYellow => theme::YELLOW,
|
||||||
|
NamedColor::BrightBlue => theme::SAPPHIRE,
|
||||||
|
NamedColor::BrightMagenta => theme::PINK,
|
||||||
|
NamedColor::BrightCyan => theme::SKY,
|
||||||
|
NamedColor::BrightWhite => theme::TEXT,
|
||||||
|
NamedColor::Foreground => theme::TEXT,
|
||||||
|
NamedColor::Background => theme::BASE,
|
||||||
|
NamedColor::Cursor => theme::ROSEWATER,
|
||||||
|
NamedColor::DimForeground => theme::SUBTEXT0,
|
||||||
|
_ => theme::TEXT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Terminal Size Adapter ───────────────────────────────────────────
|
||||||
|
|
||||||
|
struct TerminalSize {
|
||||||
|
cols: usize,
|
||||||
|
rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dimensions for TerminalSize {
|
||||||
|
fn total_lines(&self) -> usize {
|
||||||
|
self.rows
|
||||||
|
}
|
||||||
|
fn screen_lines(&self) -> usize {
|
||||||
|
self.rows
|
||||||
|
}
|
||||||
|
fn columns(&self) -> usize {
|
||||||
|
self.cols
|
||||||
|
}
|
||||||
|
fn last_column(&self) -> Column {
|
||||||
|
Column(self.cols.saturating_sub(1))
|
||||||
|
}
|
||||||
|
fn bottommost_line(&self) -> Line {
|
||||||
|
Line(self.rows as i32 - 1)
|
||||||
|
}
|
||||||
|
fn topmost_line(&self) -> Line {
|
||||||
|
Line(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Terminal State ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wraps alacritty_terminal::Term with render cache for GPUI.
|
||||||
|
pub struct TerminalState {
|
||||||
|
term: Term<GpuiTermEventListener>,
|
||||||
|
processor: Processor,
|
||||||
|
pub cols: usize,
|
||||||
|
pub rows: usize,
|
||||||
|
/// Cached render data: Vec of rows, each row is Vec of (char, fg_color).
|
||||||
|
render_lines: Vec<Vec<(char, Rgba)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalState {
|
||||||
|
pub fn new(cols: usize, rows: usize) -> Self {
|
||||||
|
let size = TerminalSize { cols, rows };
|
||||||
|
let config = TermConfig::default();
|
||||||
|
let term = Term::new(config, &size, GpuiTermEventListener);
|
||||||
|
let processor = Processor::new();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
term,
|
||||||
|
processor,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
render_lines: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed raw PTY output bytes into the VT state machine.
|
||||||
|
pub fn process_output(&mut self, data: &[u8]) {
|
||||||
|
self.processor.advance(&mut self.term, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the render_lines cache from the term grid.
|
||||||
|
pub fn update_render_cache(&mut self) {
|
||||||
|
let grid = self.term.grid();
|
||||||
|
self.render_lines.clear();
|
||||||
|
|
||||||
|
for row_idx in 0..self.rows {
|
||||||
|
let mut line = Vec::with_capacity(self.cols);
|
||||||
|
let row = &grid[Line(row_idx as i32)];
|
||||||
|
for col_idx in 0..self.cols {
|
||||||
|
let cell = &row[Column(col_idx)];
|
||||||
|
let ch = cell.c;
|
||||||
|
let fg = ansi_to_rgba(cell.fg);
|
||||||
|
line.push((ch, fg));
|
||||||
|
}
|
||||||
|
self.render_lines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the cached render lines.
|
||||||
|
pub fn lines(&self) -> &[Vec<(char, Rgba)>] {
|
||||||
|
&self.render_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cursor position (row, col).
|
||||||
|
pub fn cursor_point(&self) -> (usize, usize) {
|
||||||
|
let cursor = self.term.grid().cursor.point;
|
||||||
|
(cursor.line.0 as usize, cursor.column.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GPUI Terminal View ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// GPUI view that renders the terminal grid.
|
||||||
|
/// Each cell is rendered as a text span with the correct foreground color.
|
||||||
|
/// The cursor is rendered as a block highlight.
|
||||||
|
pub struct TerminalView {
|
||||||
|
pub state: TerminalState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalView {
|
||||||
|
pub fn new(cols: usize, rows: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
state: TerminalState::new(cols, rows),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed demo content for visual testing.
|
||||||
|
pub fn feed_demo(&mut self) {
|
||||||
|
let demo = b"\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m $ \x1b[32mcargo build\x1b[0m\r\n\
|
||||||
|
\x1b[33mCompiling\x1b[0m agor-core v0.1.0\r\n\
|
||||||
|
\x1b[33mCompiling\x1b[0m agor-gpui v0.1.0\r\n\
|
||||||
|
\x1b[1;32m Finished\x1b[0m `dev` profile [unoptimized + debuginfo] in 4.2s\r\n\
|
||||||
|
\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m $ \x1b[7m \x1b[0m";
|
||||||
|
self.state.process_output(demo);
|
||||||
|
self.state.update_render_cache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TerminalView {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
// Update render cache from terminal state
|
||||||
|
self.state.update_render_cache();
|
||||||
|
let (cursor_row, cursor_col) = self.state.cursor_point();
|
||||||
|
|
||||||
|
// Build rows as horizontal flex containers of character spans
|
||||||
|
let mut rows: Vec<Div> = Vec::new();
|
||||||
|
|
||||||
|
for (row_idx, line) in self.state.lines().iter().enumerate() {
|
||||||
|
let mut row_div = div().flex().flex_row();
|
||||||
|
|
||||||
|
for (col_idx, &(ch, fg)) in line.iter().enumerate() {
|
||||||
|
let is_cursor = row_idx == cursor_row && col_idx == cursor_col;
|
||||||
|
let display_char = if ch == '\0' || ch == ' ' {
|
||||||
|
' '
|
||||||
|
} else {
|
||||||
|
ch
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cell = div()
|
||||||
|
.w(px(8.4))
|
||||||
|
.h(px(18.0))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_size(px(14.0))
|
||||||
|
.text_color(fg);
|
||||||
|
|
||||||
|
if is_cursor {
|
||||||
|
cell = cell.bg(theme::ROSEWATER).text_color(theme::BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
row_div = row_div.child(cell.child(format!("{}", display_char)));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(row_div);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal container
|
||||||
|
let mut container = div()
|
||||||
|
.w_full()
|
||||||
|
.h_full()
|
||||||
|
.bg(theme::BASE)
|
||||||
|
.p(px(4.0))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.overflow_hidden()
|
||||||
|
.font_family("JetBrains Mono");
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
container = container.child(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
}
|
||||||
204
ui-gpui/src/theme.rs
Normal file
204
ui-gpui/src/theme.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
//! Catppuccin Mocha palette as GPUI color constants.
|
||||||
|
//!
|
||||||
|
//! All colors are defined as `gpui::Rgba` via the `rgb()` helper.
|
||||||
|
//! Components import `theme::*` and use these constants for every visual property.
|
||||||
|
|
||||||
|
use gpui::Rgba;
|
||||||
|
|
||||||
|
/// Convert a 0xRRGGBB hex literal to `Rgba`.
|
||||||
|
/// Re-exports `gpui::rgb` for convenience.
|
||||||
|
pub fn color(hex: u32) -> Rgba {
|
||||||
|
gpui::rgb(hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backgrounds ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const BASE: Rgba = Rgba {
|
||||||
|
r: 0x1e as f32 / 255.0,
|
||||||
|
g: 0x1e as f32 / 255.0,
|
||||||
|
b: 0x2e as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const MANTLE: Rgba = Rgba {
|
||||||
|
r: 0x18 as f32 / 255.0,
|
||||||
|
g: 0x16 as f32 / 255.0,
|
||||||
|
b: 0x25 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const CRUST: Rgba = Rgba {
|
||||||
|
r: 0x11 as f32 / 255.0,
|
||||||
|
g: 0x11 as f32 / 255.0,
|
||||||
|
b: 0x1b as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Surfaces ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const SURFACE0: Rgba = Rgba {
|
||||||
|
r: 0x31 as f32 / 255.0,
|
||||||
|
g: 0x32 as f32 / 255.0,
|
||||||
|
b: 0x44 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const SURFACE1: Rgba = Rgba {
|
||||||
|
r: 0x45 as f32 / 255.0,
|
||||||
|
g: 0x47 as f32 / 255.0,
|
||||||
|
b: 0x5a as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const SURFACE2: Rgba = Rgba {
|
||||||
|
r: 0x58 as f32 / 255.0,
|
||||||
|
g: 0x5b as f32 / 255.0,
|
||||||
|
b: 0x70 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Text ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const TEXT: Rgba = Rgba {
|
||||||
|
r: 0xcd as f32 / 255.0,
|
||||||
|
g: 0xd6 as f32 / 255.0,
|
||||||
|
b: 0xf4 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const SUBTEXT0: Rgba = Rgba {
|
||||||
|
r: 0xa6 as f32 / 255.0,
|
||||||
|
g: 0xad as f32 / 255.0,
|
||||||
|
b: 0xc8 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const SUBTEXT1: Rgba = Rgba {
|
||||||
|
r: 0xba as f32 / 255.0,
|
||||||
|
g: 0xc2 as f32 / 255.0,
|
||||||
|
b: 0xde as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Overlays ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const OVERLAY0: Rgba = Rgba {
|
||||||
|
r: 0x6c as f32 / 255.0,
|
||||||
|
g: 0x70 as f32 / 255.0,
|
||||||
|
b: 0x86 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const OVERLAY1: Rgba = Rgba {
|
||||||
|
r: 0x7f as f32 / 255.0,
|
||||||
|
g: 0x84 as f32 / 255.0,
|
||||||
|
b: 0x9c as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const OVERLAY2: Rgba = Rgba {
|
||||||
|
r: 0x93 as f32 / 255.0,
|
||||||
|
g: 0x99 as f32 / 255.0,
|
||||||
|
b: 0xb2 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Accent Colors ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const BLUE: Rgba = Rgba {
|
||||||
|
r: 0x89 as f32 / 255.0,
|
||||||
|
g: 0xb4 as f32 / 255.0,
|
||||||
|
b: 0xfa as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const GREEN: Rgba = Rgba {
|
||||||
|
r: 0xa6 as f32 / 255.0,
|
||||||
|
g: 0xe3 as f32 / 255.0,
|
||||||
|
b: 0xa1 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const RED: Rgba = Rgba {
|
||||||
|
r: 0xf3 as f32 / 255.0,
|
||||||
|
g: 0x8b as f32 / 255.0,
|
||||||
|
b: 0xa8 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const YELLOW: Rgba = Rgba {
|
||||||
|
r: 0xf9 as f32 / 255.0,
|
||||||
|
g: 0xe2 as f32 / 255.0,
|
||||||
|
b: 0xaf as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const MAUVE: Rgba = Rgba {
|
||||||
|
r: 0xcb as f32 / 255.0,
|
||||||
|
g: 0xa6 as f32 / 255.0,
|
||||||
|
b: 0xf7 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const PEACH: Rgba = Rgba {
|
||||||
|
r: 0xfa as f32 / 255.0,
|
||||||
|
g: 0xb3 as f32 / 255.0,
|
||||||
|
b: 0x87 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const TEAL: Rgba = Rgba {
|
||||||
|
r: 0x94 as f32 / 255.0,
|
||||||
|
g: 0xe2 as f32 / 255.0,
|
||||||
|
b: 0xd5 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const SAPPHIRE: Rgba = Rgba {
|
||||||
|
r: 0x74 as f32 / 255.0,
|
||||||
|
g: 0xc7 as f32 / 255.0,
|
||||||
|
b: 0xec as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const LAVENDER: Rgba = Rgba {
|
||||||
|
r: 0xb4 as f32 / 255.0,
|
||||||
|
g: 0xbe as f32 / 255.0,
|
||||||
|
b: 0xfe as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const FLAMINGO: Rgba = Rgba {
|
||||||
|
r: 0xf2 as f32 / 255.0,
|
||||||
|
g: 0xcd as f32 / 255.0,
|
||||||
|
b: 0xcd as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const ROSEWATER: Rgba = Rgba {
|
||||||
|
r: 0xf5 as f32 / 255.0,
|
||||||
|
g: 0xe0 as f32 / 255.0,
|
||||||
|
b: 0xdc as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const PINK: Rgba = Rgba {
|
||||||
|
r: 0xf5 as f32 / 255.0,
|
||||||
|
g: 0xc2 as f32 / 255.0,
|
||||||
|
b: 0xe7 as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const MAROON: Rgba = Rgba {
|
||||||
|
r: 0xeb as f32 / 255.0,
|
||||||
|
g: 0xa0 as f32 / 255.0,
|
||||||
|
b: 0xac as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
pub const SKY: Rgba = Rgba {
|
||||||
|
r: 0x89 as f32 / 255.0,
|
||||||
|
g: 0xdc as f32 / 255.0,
|
||||||
|
b: 0xeb as f32 / 255.0,
|
||||||
|
a: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Semi-transparent helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn with_alpha(base: Rgba, alpha: f32) -> Rgba {
|
||||||
|
Rgba { a: alpha, ..base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accent blue at 10% opacity — for subtle hover/selection highlights.
|
||||||
|
pub fn blue_tint() -> Rgba {
|
||||||
|
with_alpha(BLUE, 0.10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accent blue at 20% opacity — for active/selected states.
|
||||||
|
pub fn blue_wash() -> Rgba {
|
||||||
|
with_alpha(BLUE, 0.20)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Surface for hover states — one step brighter than surface0.
|
||||||
|
pub fn hover_bg() -> Rgba {
|
||||||
|
SURFACE1
|
||||||
|
}
|
||||||
120
ui-gpui/src/workspace.rs
Normal file
120
ui-gpui/src/workspace.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
//! Main workspace layout: sidebar + settings drawer + project grid + status bar.
|
||||||
|
//!
|
||||||
|
//! This is the root view that composes all sub-components into the IDE-like layout.
|
||||||
|
|
||||||
|
use gpui::*;
|
||||||
|
|
||||||
|
use crate::components::command_palette::CommandPalette;
|
||||||
|
use crate::components::project_grid::ProjectGrid;
|
||||||
|
use crate::components::settings::SettingsPanel;
|
||||||
|
use crate::components::sidebar::Sidebar;
|
||||||
|
use crate::components::status_bar::StatusBar;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
// ── Workspace View ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Workspace {
|
||||||
|
app_state: Entity<AppState>,
|
||||||
|
sidebar: Entity<Sidebar>,
|
||||||
|
settings_panel: Entity<SettingsPanel>,
|
||||||
|
project_grid: Entity<ProjectGrid>,
|
||||||
|
status_bar: Entity<StatusBar>,
|
||||||
|
command_palette: Entity<CommandPalette>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
pub fn new(app_state: Entity<AppState>, cx: &mut Context<Self>) -> Self {
|
||||||
|
let sidebar = cx.new({
|
||||||
|
let state = app_state.clone();
|
||||||
|
|_cx| Sidebar::new(state)
|
||||||
|
});
|
||||||
|
let settings_panel = cx.new({
|
||||||
|
let state = app_state.clone();
|
||||||
|
|_cx| SettingsPanel::new(state)
|
||||||
|
});
|
||||||
|
let project_grid = cx.new({
|
||||||
|
let state = app_state.clone();
|
||||||
|
|cx| ProjectGrid::new(state, cx)
|
||||||
|
});
|
||||||
|
let status_bar = cx.new({
|
||||||
|
let state = app_state.clone();
|
||||||
|
|_cx| StatusBar::new(state)
|
||||||
|
});
|
||||||
|
let command_palette = cx.new({
|
||||||
|
let state = app_state.clone();
|
||||||
|
|_cx| CommandPalette::new(state)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe app_state changes to trigger re-renders
|
||||||
|
cx.observe(&app_state, |_this, _entity, cx| {
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
app_state,
|
||||||
|
sidebar,
|
||||||
|
settings_panel,
|
||||||
|
project_grid,
|
||||||
|
status_bar,
|
||||||
|
command_palette,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Workspace {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let state = self.app_state.read(cx);
|
||||||
|
let sidebar_open = state.sidebar_open;
|
||||||
|
let settings_open = state.settings_open;
|
||||||
|
let palette_open = state.palette_open;
|
||||||
|
|
||||||
|
let mut root = div()
|
||||||
|
.id("workspace-root")
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.bg(theme::CRUST)
|
||||||
|
.font_family("Inter");
|
||||||
|
|
||||||
|
// ── Main content row (sidebar + settings? + grid) ───
|
||||||
|
let mut main_row = div()
|
||||||
|
.id("main-row")
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.overflow_hidden();
|
||||||
|
|
||||||
|
// Sidebar (icon rail)
|
||||||
|
if sidebar_open {
|
||||||
|
main_row = main_row.child(self.sidebar.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings drawer (between sidebar and grid)
|
||||||
|
if settings_open {
|
||||||
|
main_row = main_row.child(self.settings_panel.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project grid (fills remaining space)
|
||||||
|
main_row = main_row.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.h_full()
|
||||||
|
.child(self.project_grid.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
root = root.child(main_row);
|
||||||
|
|
||||||
|
// ── Status bar (bottom) ─────────────────────────────
|
||||||
|
root = root.child(self.status_bar.clone());
|
||||||
|
|
||||||
|
// ── Command palette overlay (if open) ───────────────
|
||||||
|
if palette_open {
|
||||||
|
root = root.child(self.command_palette.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
root
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue