commit 3ec5b203e903f6be11a06c26cb0677528750bc15
parent c10d594f62a33346c63d30925ed0a0eef76f2f67
Author: William Casarin <jb55@jb55.com>
Date: Mon, 26 Jan 2026 11:46:12 -0800
dave: add RTS-style scene view for multi-agent visualization
Implements a 2D scene with pan/zoom using egui::Scene where agents
are displayed as interactive units. Features include:
- Agent status visualization (idle/working/needs-input/error/done)
- Click/shift-click/box selection for agents
- Drag to reposition agents in scene
- Chat side panel for selected agent interaction
- Camera auto-jumps to agents needing attention (permission requests)
- Toggle between scene view and classic chat view
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
5 files changed, 721 insertions(+), 3 deletions(-)
diff --git a/crates/notedeck_dave/src/agent_status.rs b/crates/notedeck_dave/src/agent_status.rs
@@ -0,0 +1,39 @@
+/// Represents the current status of an agent in the RTS scene
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum AgentStatus {
+ /// Agent is idle, no active work
+ #[default]
+ Idle,
+ /// Agent is actively processing (receiving tokens, executing tools)
+ Working,
+ /// Agent needs user input (permission request pending)
+ NeedsInput,
+ /// Agent encountered an error
+ Error,
+ /// Agent completed its task successfully
+ Done,
+}
+
+impl AgentStatus {
+ /// Get the color associated with this status
+ pub fn color(&self) -> egui::Color32 {
+ match self {
+ AgentStatus::Idle => egui::Color32::from_rgb(128, 128, 128), // Gray
+ AgentStatus::Working => egui::Color32::from_rgb(50, 205, 50), // Green
+ AgentStatus::NeedsInput => egui::Color32::from_rgb(255, 200, 0), // Yellow/amber
+ AgentStatus::Error => egui::Color32::from_rgb(220, 60, 60), // Red
+ AgentStatus::Done => egui::Color32::from_rgb(70, 130, 220), // Blue
+ }
+ }
+
+ /// Get a human-readable label for this status
+ pub fn label(&self) -> &'static str {
+ match self {
+ AgentStatus::Idle => "Idle",
+ AgentStatus::Working => "Working",
+ AgentStatus::NeedsInput => "Needs Input",
+ AgentStatus::Error => "Error",
+ AgentStatus::Done => "Done",
+ }
+ }
+}
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -1,3 +1,4 @@
+mod agent_status;
mod avatar;
mod backend;
mod config;
@@ -31,8 +32,8 @@ pub use tools::{
ToolResponses,
};
pub use ui::{
- DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, SessionListAction, SessionListUi,
- SettingsPanelAction,
+ AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, SceneAction, SceneResponse,
+ SessionListAction, SessionListUi, SettingsPanelAction,
};
pub use vec3::Vec3;
@@ -53,6 +54,10 @@ pub struct Dave {
settings: DaveSettings,
/// Settings panel UI state
settings_panel: DaveSettingsPanel,
+ /// RTS-style scene view
+ scene: AgentScene,
+ /// Whether to show scene view (vs classic chat view)
+ show_scene: bool,
}
/// Calculate an anonymous user_id from a keypair
@@ -134,6 +139,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
show_session_list: false,
settings,
settings_panel: DaveSettingsPanel::new(),
+ scene: AgentScene::new(),
+ show_scene: true, // Default to scene view
}
}
@@ -259,11 +266,122 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
if is_narrow(ui.ctx()) {
self.narrow_ui(app_ctx, ui)
+ } else if self.show_scene {
+ self.scene_ui(app_ctx, ui)
} else {
self.desktop_ui(app_ctx, ui)
}
}
+ /// Scene view with RTS-style agent visualization and chat side panel
+ fn scene_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
+ let mut dave_response = DaveResponse::default();
+ let available = ui.available_rect_before_wrap();
+ let panel_width = 400.0;
+
+ // Scene area (main)
+ let scene_rect = egui::Rect::from_min_size(
+ available.min,
+ egui::vec2(available.width() - panel_width, available.height()),
+ );
+
+ // Chat panel area (right side)
+ let panel_rect = egui::Rect::from_min_size(
+ egui::pos2(available.max.x - panel_width, available.min.y),
+ egui::vec2(panel_width, available.height()),
+ );
+
+ // Update all session statuses
+ self.session_manager.update_all_statuses();
+
+ // Check for agents needing attention and auto-jump to them
+ if let Some(attention_id) = self.scene.check_attention(&self.session_manager) {
+ // Also sync with session manager's active session
+ self.session_manager.switch_to(attention_id);
+ }
+
+ // Render scene
+ let scene_response = ui
+ .allocate_new_ui(egui::UiBuilder::new().max_rect(scene_rect), |ui| {
+ // Scene toolbar at top
+ ui.horizontal(|ui| {
+ if ui.button("+ New Agent").clicked() {
+ dave_response = DaveResponse::new(DaveAction::NewChat);
+ }
+ ui.separator();
+ if ui.button("Classic View").clicked() {
+ self.show_scene = false;
+ }
+ });
+ ui.separator();
+
+ // Render the scene
+ self.scene.ui(&self.session_manager, ui)
+ })
+ .inner;
+
+ // Handle scene actions
+ if let Some(action) = scene_response.action {
+ match action {
+ SceneAction::SelectionChanged(ids) => {
+ // Selection updated, sync with session manager's active
+ if let Some(id) = ids.first() {
+ self.session_manager.switch_to(*id);
+ }
+ }
+ SceneAction::SpawnAgent => {
+ dave_response = DaveResponse::new(DaveAction::NewChat);
+ }
+ SceneAction::DeleteSelected => {
+ for id in self.scene.selected.clone() {
+ self.session_manager.delete_session(id);
+ }
+ self.scene.clear_selection();
+ }
+ SceneAction::AgentMoved { id, position } => {
+ if let Some(session) = self.session_manager.get_mut(id) {
+ session.scene_position = position;
+ }
+ }
+ }
+ }
+
+ // Render chat side panel
+ ui.allocate_new_ui(egui::UiBuilder::new().max_rect(panel_rect), |ui| {
+ egui::Frame::new()
+ .fill(ui.visuals().faint_bg_color)
+ .inner_margin(egui::Margin::symmetric(8, 12))
+ .show(ui, |ui| {
+ if let Some(selected_id) = self.scene.primary_selection() {
+ if let Some(session) = self.session_manager.get_mut(selected_id) {
+ // Show title
+ ui.heading(&session.title);
+ ui.separator();
+
+ // Render chat UI for selected session
+ let response = DaveUi::new(
+ self.model_config.trial,
+ &session.chat,
+ &mut session.input,
+ )
+ .ui(app_ctx, ui);
+
+ if response.action.is_some() {
+ dave_response = response;
+ }
+ }
+ } else {
+ // No selection
+ ui.centered_and_justified(|ui| {
+ ui.label("Select an agent to view chat");
+ });
+ }
+ });
+ });
+
+ dave_response
+ }
+
/// Desktop layout with sidebar for session list
fn desktop_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
let available = ui.available_rect_before_wrap();
@@ -282,7 +400,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
egui::Frame::new()
.fill(ui.visuals().faint_bg_color)
.inner_margin(egui::Margin::symmetric(8, 12))
- .show(ui, |ui| SessionListUi::new(&self.session_manager).ui(ui))
+ .show(ui, |ui| {
+ // Add scene view toggle button
+ if ui.button("Scene View").clicked() {
+ self.show_scene = true;
+ }
+ ui.separator();
+ SessionListUi::new(&self.session_manager).ui(ui)
+ })
.inner
})
.inner;
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
+use crate::agent_status::AgentStatus;
use crate::messages::PermissionResponse;
use crate::{DaveApiResponse, Message};
use tokio::sync::oneshot;
@@ -20,6 +21,10 @@ pub struct ChatSession {
/// Handle to the background task processing this session's AI requests.
/// Aborted on drop to clean up the subprocess.
pub task_handle: Option<tokio::task::JoinHandle<()>>,
+ /// Position in the RTS scene (in scene coordinates)
+ pub scene_position: egui::Vec2,
+ /// Cached status for the agent (derived from session state)
+ cached_status: AgentStatus,
}
impl Drop for ChatSession {
@@ -32,6 +37,12 @@ impl Drop for ChatSession {
impl ChatSession {
pub fn new(id: SessionId) -> Self {
+ // Arrange sessions in a grid pattern
+ let col = (id as i32 - 1) % 4;
+ let row = (id as i32 - 1) / 4;
+ let x = col as f32 * 150.0 - 225.0; // Center around origin
+ let y = row as f32 * 150.0 - 75.0;
+
ChatSession {
id,
title: "New Chat".to_string(),
@@ -40,6 +51,8 @@ impl ChatSession {
incoming_tokens: None,
pending_permissions: HashMap::new(),
task_handle: None,
+ scene_position: egui::Vec2::new(x, y),
+ cached_status: AgentStatus::Idle,
}
}
@@ -58,6 +71,49 @@ impl ChatSession {
}
}
}
+
+ /// Get the current status of this session/agent
+ pub fn status(&self) -> AgentStatus {
+ self.cached_status
+ }
+
+ /// Update the cached status based on current session state
+ pub fn update_status(&mut self) {
+ self.cached_status = self.derive_status();
+ }
+
+ /// Derive status from the current session state
+ fn derive_status(&self) -> AgentStatus {
+ // Check for pending permission requests (needs input)
+ if !self.pending_permissions.is_empty() {
+ return AgentStatus::NeedsInput;
+ }
+
+ // Check for error in last message
+ if let Some(Message::Error(_)) = self.chat.last() {
+ return AgentStatus::Error;
+ }
+
+ // Check if actively working (has task handle and receiving tokens)
+ if self.task_handle.is_some() && self.incoming_tokens.is_some() {
+ return AgentStatus::Working;
+ }
+
+ // Check if done (has messages and no active task)
+ if !self.chat.is_empty() && self.task_handle.is_none() {
+ // Check if the last meaningful message was from assistant
+ for msg in self.chat.iter().rev() {
+ match msg {
+ Message::Assistant(_) => return AgentStatus::Done,
+ Message::User(_) => return AgentStatus::Idle, // Waiting for response
+ Message::Error(_) => return AgentStatus::Error,
+ _ => continue,
+ }
+ }
+ }
+
+ AgentStatus::Idle
+ }
}
/// Manages multiple chat sessions
@@ -170,4 +226,46 @@ impl SessionManager {
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
+
+ /// Get a reference to a session by ID
+ pub fn get(&self, id: SessionId) -> Option<&ChatSession> {
+ self.sessions.get(&id)
+ }
+
+ /// Get a mutable reference to a session by ID
+ pub fn get_mut(&mut self, id: SessionId) -> Option<&mut ChatSession> {
+ self.sessions.get_mut(&id)
+ }
+
+ /// Iterate over all sessions
+ pub fn iter(&self) -> impl Iterator<Item = &ChatSession> {
+ self.sessions.values()
+ }
+
+ /// Iterate over all sessions mutably
+ pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut ChatSession> {
+ self.sessions.values_mut()
+ }
+
+ /// Update status for all sessions
+ pub fn update_all_statuses(&mut self) {
+ for session in self.sessions.values_mut() {
+ session.update_status();
+ }
+ }
+
+ /// Get the first session that needs attention (NeedsInput status)
+ pub fn find_needs_attention(&self) -> Option<SessionId> {
+ for session in self.sessions.values() {
+ if session.status() == AgentStatus::NeedsInput {
+ return Some(session.id);
+ }
+ }
+ None
+ }
+
+ /// Get all session IDs
+ pub fn session_ids(&self) -> Vec<SessionId> {
+ self.order.clone()
+ }
}
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -1,7 +1,9 @@
mod dave;
+pub mod scene;
pub mod session_list;
mod settings;
pub use dave::{DaveAction, DaveResponse, DaveUi};
+pub use scene::{AgentScene, SceneAction, SceneResponse};
pub use session_list::{SessionListAction, SessionListUi};
pub use settings::{DaveSettingsPanel, SettingsPanelAction};
diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs
@@ -0,0 +1,454 @@
+use crate::agent_status::AgentStatus;
+use crate::session::{SessionId, SessionManager};
+use egui::{Color32, Pos2, Rect, Response, Sense, Vec2};
+
+/// The RTS-style scene view for managing agents
+pub struct AgentScene {
+ /// Camera/view transform state managed by egui::Scene
+ scene_rect: Rect,
+ /// Currently selected agent IDs
+ pub selected: Vec<SessionId>,
+ /// Drag selection state
+ drag_select: Option<DragSelect>,
+ /// Target camera position for smooth animation
+ camera_target: Option<Vec2>,
+ /// Animation progress (0.0 to 1.0)
+ animation_progress: f32,
+ /// Sessions that have already been alerted (to avoid re-jumping)
+ alerted_sessions: Vec<SessionId>,
+}
+
+/// State for box/marquee selection
+struct DragSelect {
+ start: Pos2,
+ current: Pos2,
+}
+
+/// Action generated by the scene UI
+#[derive(Debug, Clone)]
+pub enum SceneAction {
+ /// Selection changed
+ SelectionChanged(Vec<SessionId>),
+ /// Request to spawn a new agent
+ SpawnAgent,
+ /// Request to delete selected agents
+ DeleteSelected,
+ /// Agent was dragged to new position
+ AgentMoved { id: SessionId, position: Vec2 },
+}
+
+/// Response from scene rendering
+#[derive(Default)]
+pub struct SceneResponse {
+ pub action: Option<SceneAction>,
+}
+
+impl SceneResponse {
+ pub fn new(action: SceneAction) -> Self {
+ Self {
+ action: Some(action),
+ }
+ }
+}
+
+impl Default for AgentScene {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl AgentScene {
+ pub fn new() -> Self {
+ Self {
+ scene_rect: Rect::from_min_max(Pos2::new(-500.0, -500.0), Pos2::new(500.0, 500.0)),
+ selected: Vec::new(),
+ drag_select: None,
+ camera_target: None,
+ animation_progress: 1.0,
+ alerted_sessions: Vec::new(),
+ }
+ }
+
+ /// Check if an agent is selected
+ pub fn is_selected(&self, id: SessionId) -> bool {
+ self.selected.contains(&id)
+ }
+
+ /// Set selection to a single agent
+ pub fn select(&mut self, id: SessionId) {
+ self.selected.clear();
+ self.selected.push(id);
+ }
+
+ /// Add an agent to the selection
+ pub fn add_to_selection(&mut self, id: SessionId) {
+ if !self.selected.contains(&id) {
+ self.selected.push(id);
+ }
+ }
+
+ /// Clear all selection
+ pub fn clear_selection(&mut self) {
+ self.selected.clear();
+ }
+
+ /// Get the first selected agent (for chat panel)
+ pub fn primary_selection(&self) -> Option<SessionId> {
+ self.selected.first().copied()
+ }
+
+ /// Animate camera to focus on a position
+ pub fn focus_on(&mut self, position: Vec2) {
+ self.camera_target = Some(position);
+ self.animation_progress = 0.0;
+ }
+
+ /// Check for agents needing attention and jump to them if not already alerted.
+ /// Returns the ID of the agent that was jumped to, if any.
+ pub fn check_attention(&mut self, session_manager: &SessionManager) -> Option<SessionId> {
+ // Clean up alerted list - remove sessions that no longer need input
+ self.alerted_sessions.retain(|id| {
+ session_manager
+ .get(*id)
+ .map(|s| s.status() == AgentStatus::NeedsInput)
+ .unwrap_or(false)
+ });
+
+ // Find first session needing attention that we haven't alerted yet
+ for session in session_manager.iter() {
+ if session.status() == AgentStatus::NeedsInput
+ && !self.alerted_sessions.contains(&session.id)
+ {
+ // Mark as alerted
+ self.alerted_sessions.push(session.id);
+
+ // Focus camera on this agent
+ self.focus_on(session.scene_position);
+
+ // Select this agent
+ self.select(session.id);
+
+ return Some(session.id);
+ }
+ }
+
+ None
+ }
+
+ /// Render the scene
+ pub fn ui(&mut self, session_manager: &SessionManager, ui: &mut egui::Ui) -> SceneResponse {
+ let mut response = SceneResponse::default();
+
+ // Update camera animation towards target
+ if let Some(target) = self.camera_target {
+ if self.animation_progress < 1.0 {
+ self.animation_progress += 0.08;
+ self.animation_progress = self.animation_progress.min(1.0);
+
+ // Smoothly interpolate scene_rect center towards target
+ let current_center = self.scene_rect.center();
+ let target_pos = Pos2::new(target.x, target.y);
+ let t = ease_out_cubic(self.animation_progress);
+ let new_center = current_center.lerp(target_pos, t);
+
+ // Shift the scene_rect to center on new position
+ let offset = new_center - current_center;
+ self.scene_rect = self.scene_rect.translate(offset);
+
+ ui.ctx().request_repaint();
+ } else {
+ // Animation complete
+ self.camera_target = None;
+ }
+ }
+
+ // Track interactions from inside the scene closure
+ let mut clicked_agent: Option<(SessionId, bool, Vec2)> = None; // (id, shift_held, position)
+ let mut dragged_agent: Option<(SessionId, Vec2)> = None; // (id, new_position)
+ let mut bg_clicked = false;
+ let mut bg_drag_started = false;
+
+ // Use a local copy of scene_rect to avoid borrow conflict
+ let mut scene_rect = self.scene_rect;
+ let selected_ids = &self.selected;
+
+ egui::Scene::new()
+ .zoom_range(0.1..=4.0)
+ .show(ui, &mut scene_rect, |ui| {
+ // Draw background grid
+ Self::draw_grid(ui);
+
+ // Draw agents and collect interaction responses
+ for session in session_manager.iter() {
+ let id = session.id;
+ let position = session.scene_position;
+ let status = session.status();
+ let title = &session.title;
+ let is_selected = selected_ids.contains(&id);
+
+ let agent_response =
+ Self::draw_agent(ui, id, position, status, title, is_selected);
+
+ if agent_response.clicked() {
+ let shift = ui.input(|i| i.modifiers.shift);
+ clicked_agent = Some((id, shift, position));
+ }
+
+ if agent_response.dragged() && is_selected {
+ let delta = agent_response.drag_delta();
+ dragged_agent = Some((id, position + delta));
+ }
+ }
+
+ // Handle click on empty space to deselect
+ let bg_response = ui.interact(
+ ui.max_rect(),
+ ui.id().with("scene_bg"),
+ Sense::click_and_drag(),
+ );
+
+ if bg_response.clicked() && clicked_agent.is_none() {
+ bg_clicked = true;
+ }
+
+ if bg_response.drag_started() && clicked_agent.is_none() {
+ bg_drag_started = true;
+ }
+ });
+
+ self.scene_rect = scene_rect;
+
+ // Process agent click
+ if let Some((id, shift, _position)) = clicked_agent {
+ if shift {
+ self.add_to_selection(id);
+ } else {
+ self.select(id);
+ }
+ response = SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone()));
+ }
+
+ // Process agent drag
+ if let Some((id, new_pos)) = dragged_agent {
+ response = SceneResponse::new(SceneAction::AgentMoved {
+ id,
+ position: new_pos,
+ });
+ }
+
+ // Process background click
+ if bg_clicked && response.action.is_none() && !self.selected.is_empty() {
+ self.selected.clear();
+ response = SceneResponse::new(SceneAction::SelectionChanged(Vec::new()));
+ }
+
+ // Start drag selection
+ if bg_drag_started {
+ if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
+ self.drag_select = Some(DragSelect {
+ start: pos,
+ current: pos,
+ });
+ }
+ }
+
+ // Update drag selection position
+ if let Some(drag) = &mut self.drag_select {
+ if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
+ drag.current = pos;
+ }
+ }
+
+ // Handle keyboard input
+ if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
+ if !self.selected.is_empty() {
+ response = SceneResponse::new(SceneAction::DeleteSelected);
+ }
+ }
+
+ // Handle 'n' key to spawn new agent
+ if ui.input(|i| i.key_pressed(egui::Key::N)) {
+ response = SceneResponse::new(SceneAction::SpawnAgent);
+ }
+
+ // Handle box selection completion
+ if let Some(drag) = &self.drag_select {
+ if ui.input(|i| i.pointer.primary_released()) {
+ let selection_rect = Rect::from_two_pos(drag.start, drag.current);
+ self.selected.clear();
+
+ for session in session_manager.iter() {
+ let agent_pos = Pos2::new(session.scene_position.x, session.scene_position.y);
+ if selection_rect.contains(agent_pos) {
+ self.selected.push(session.id);
+ }
+ }
+
+ if !self.selected.is_empty() {
+ response =
+ SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone()));
+ }
+
+ self.drag_select = None;
+ }
+ }
+
+ // Draw box selection overlay
+ if let Some(drag) = &self.drag_select {
+ let rect = Rect::from_two_pos(drag.start, drag.current);
+ let painter = ui.painter();
+ painter.rect_filled(
+ rect,
+ 0.0,
+ Color32::from_rgba_unmultiplied(100, 150, 255, 30),
+ );
+ painter.rect_stroke(
+ rect,
+ 0.0,
+ egui::Stroke::new(1.0, Color32::from_rgb(100, 150, 255)),
+ egui::StrokeKind::Outside,
+ );
+ }
+
+ response
+ }
+
+ /// Draw the background grid
+ fn draw_grid(ui: &mut egui::Ui) {
+ let painter = ui.painter();
+ let rect = ui.max_rect();
+ let grid_spacing = 50.0;
+ let grid_color = ui
+ .visuals()
+ .widgets
+ .noninteractive
+ .bg_stroke
+ .color
+ .gamma_multiply(0.3);
+
+ // Vertical lines
+ let start_x = (rect.min.x / grid_spacing).floor() * grid_spacing;
+ let mut x = start_x;
+ while x < rect.max.x {
+ painter.line_segment(
+ [Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)],
+ egui::Stroke::new(1.0, grid_color),
+ );
+ x += grid_spacing;
+ }
+
+ // Horizontal lines
+ let start_y = (rect.min.y / grid_spacing).floor() * grid_spacing;
+ let mut y = start_y;
+ while y < rect.max.y {
+ painter.line_segment(
+ [Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)],
+ egui::Stroke::new(1.0, grid_color),
+ );
+ y += grid_spacing;
+ }
+
+ // Draw origin axes slightly brighter
+ let axis_color = ui
+ .visuals()
+ .widgets
+ .noninteractive
+ .bg_stroke
+ .color
+ .gamma_multiply(0.6);
+ if rect.min.x < 0.0 && rect.max.x > 0.0 {
+ painter.line_segment(
+ [Pos2::new(0.0, rect.min.y), Pos2::new(0.0, rect.max.y)],
+ egui::Stroke::new(2.0, axis_color),
+ );
+ }
+ if rect.min.y < 0.0 && rect.max.y > 0.0 {
+ painter.line_segment(
+ [Pos2::new(rect.min.x, 0.0), Pos2::new(rect.max.x, 0.0)],
+ egui::Stroke::new(2.0, axis_color),
+ );
+ }
+ }
+
+ /// Draw a single agent unit and return the interaction Response
+ fn draw_agent(
+ ui: &mut egui::Ui,
+ id: SessionId,
+ position: Vec2,
+ status: AgentStatus,
+ title: &str,
+ is_selected: bool,
+ ) -> Response {
+ let agent_radius = 30.0;
+ let center = Pos2::new(position.x, position.y);
+ let agent_rect = Rect::from_center_size(center, Vec2::splat(agent_radius * 2.0));
+
+ // Interact with the agent
+ let response = ui.interact(
+ agent_rect,
+ ui.id().with(("agent", id)),
+ Sense::click_and_drag(),
+ );
+
+ let painter = ui.painter();
+
+ // Selection highlight (outer ring)
+ if is_selected {
+ painter.circle_stroke(
+ center,
+ agent_radius + 4.0,
+ egui::Stroke::new(3.0, Color32::from_rgb(255, 255, 100)),
+ );
+ }
+
+ // Status ring
+ let status_color = status.color();
+ painter.circle_stroke(center, agent_radius, egui::Stroke::new(3.0, status_color));
+
+ // Fill
+ let fill_color = if response.hovered() {
+ ui.visuals().widgets.hovered.bg_fill
+ } else {
+ ui.visuals().widgets.inactive.bg_fill
+ };
+ painter.circle_filled(center, agent_radius - 2.0, fill_color);
+
+ // Agent icon/letter in center
+ let icon_char = title.chars().next().unwrap_or('?');
+ let icon_text: String = icon_char.to_uppercase().collect();
+ painter.text(
+ center,
+ egui::Align2::CENTER_CENTER,
+ &icon_text,
+ egui::FontId::proportional(20.0),
+ ui.visuals().text_color(),
+ );
+
+ // Title below
+ let title_pos = center + Vec2::new(0.0, agent_radius + 10.0);
+ painter.text(
+ title_pos,
+ egui::Align2::CENTER_TOP,
+ title,
+ egui::FontId::proportional(11.0),
+ ui.visuals().text_color().gamma_multiply(0.8),
+ );
+
+ // Status label
+ let status_pos = center + Vec2::new(0.0, agent_radius + 24.0);
+ painter.text(
+ status_pos,
+ egui::Align2::CENTER_TOP,
+ status.label(),
+ egui::FontId::proportional(9.0),
+ status_color.gamma_multiply(0.9),
+ );
+
+ response
+ }
+}
+
+/// Easing function for smooth camera animation
+fn ease_out_cubic(t: f32) -> f32 {
+ 1.0 - (1.0 - t).powi(3)
+}