//! 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 {\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`. 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) -> 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()) }, ), ), ) } }