From fea255b2e9042a20bc64dddd02f7c0496bc87347 Mon Sep 17 00:00:00 2001 From: Mayu-infinite Date: Sun, 26 Jul 2026 19:21:36 +0530 Subject: [PATCH] feat(client): real-time event stream + Copilot-style activity logs Rework the event panel into a live EventLog: timestamped entries with event type and running/completed/failed status, auto-scroll with manual scroll preserved (wheel over the panel). Tool events also surface as concise inline activity logs in the conversation (Running/Finished/Failed), distinct from assistant bubbles and grouped together. Closes #16 --- client/src/app.rs | 122 +++++++++++++++---- client/src/main.rs | 6 +- client/src/ui.rs | 48 +++++++- client/src/widgets/conversation.rs | 61 ++++++++-- client/src/widgets/events.rs | 184 ++++++++++++++++++++++++++--- 5 files changed, 365 insertions(+), 56 deletions(-) diff --git a/client/src/app.rs b/client/src/app.rs index 832c829..b0f42a9 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -4,7 +4,8 @@ use ratatui::layout::{Position, Rect}; use crate::effects::Effects; use crate::ipc::{client::OrionClient, events::RuntimeEvent}; use crate::theme; -use crate::widgets::conversation::{Author, ConversationWidget, Message}; +use crate::widgets::conversation::{ActivityKind, Author, ConversationWidget, Message}; +use crate::widgets::events::{EventLog, EventStatus}; #[derive(Debug, PartialEq, Eq)] pub enum InputMode { @@ -28,6 +29,10 @@ pub struct App { // Store layout bounds for mouse click target checks pub prompt_area: Rect, + pub events_area: Rect, + + // Real-time runtime event trace + pub events: EventLog, // Animations (tachyonfx) pub effects: Effects, @@ -47,6 +52,8 @@ impl App { msg_counter: 0, client: None, prompt_area: Rect::default(), + events_area: Rect::default(), + events: EventLog::new(), effects: Effects::new(), } } @@ -96,12 +103,22 @@ impl App { self.input_mode = InputMode::Normal; } } - // Vertical scroll wheel support + // Vertical scroll wheel — route to whichever panel is hovered. MouseEventKind::ScrollUp => { - self.conversation.scroll_up(2); + let pos = Position::new(event.column, event.row); + if self.events_area.contains(pos) { + self.events.scroll_up(2); + } else { + self.conversation.scroll_up(2); + } } MouseEventKind::ScrollDown => { - self.conversation.scroll_down(2); + let pos = Position::new(event.column, event.row); + if self.events_area.contains(pos) { + self.events.scroll_down(2); + } else { + self.conversation.scroll_down(2); + } } _ => {} } @@ -124,6 +141,8 @@ impl App { self.msg_counter += 1; // Clean construction using Message::new + self.events + .push("PROMPT", EventStatus::Info, "prompt submitted"); self.conversation.add_message(Message::new( format!("msg-{}", self.msg_counter), Author::User, @@ -139,14 +158,13 @@ impl App { pub fn handle_runtime_event(&mut self, event: RuntimeEvent) { match event { - RuntimeEvent::Connected => { - self.mode = "CONNECTED".into(); - self.effects.on_status_change(theme::OK); - } + RuntimeEvent::Connected => self.on_connected(), RuntimeEvent::Disconnected => { self.mode = "DISCONNECTED".into(); self.effects.on_status_change(theme::DANGER); + self.events + .push("DISCONNECTED", EventStatus::Failed, "runtime disconnected"); } RuntimeEvent::AssistantStart => { @@ -157,57 +175,115 @@ impl App { self.conversation .begin_assistant_message(format!("msg-{}", self.msg_counter)); self.effects.on_message(); + self.events + .push("RESPONSE", EventStatus::Running, "assistant responding"); } RuntimeEvent::AssistantChunk(text) => { - // Append text chunk directly to active message bubble self.conversation.append_assistant_chunk(&text); + // Coalesce chunk spam onto the running RESPONSE trace line. + self.events + .bump_last("RESPONSE", EventStatus::Running, "streaming…"); } RuntimeEvent::AssistantEnd => { self.mode = "IDLE".into(); self.conversation.finish_assistant_message(); + self.events + .bump_last("RESPONSE", EventStatus::Completed, "response complete"); } RuntimeEvent::ToolStarted { name } => { self.mode = format!("TOOL: {}", name); + self.events + .push("TOOL_STARTED", EventStatus::Running, name.clone()); + self.add_activity(format!("Running {}…", name), ActivityKind::Running); } RuntimeEvent::ToolFinished { name, success } => { - let status = if success { "OK" } else { "FAILED" }; - self.mode = format!("TOOL {}: {}", status, name); + let label = if success { "OK" } else { "FAILED" }; + self.mode = format!("TOOL {}: {}", label, name); + + let status = if success { + EventStatus::Completed + } else { + EventStatus::Failed + }; + self.events.push("TOOL_FINISHED", status, name.clone()); + + if success { + self.add_activity(format!("Finished {}", name), ActivityKind::Done); + } else { + self.add_activity(format!("Failed {}", name), ActivityKind::Failed); + } } RuntimeEvent::Status(status) => { - self.mode = status; + self.mode = status.clone(); + self.events.push("STATUS", EventStatus::Info, status); } - RuntimeEvent::Error { message, .. } => { + RuntimeEvent::Error { code, message } => { self.mode = format!("ERROR: {}", message); self.effects.on_status_change(theme::DANGER); + self.events.push( + "ERROR", + EventStatus::Failed, + format!("{}: {}", code, message), + ); } - RuntimeEvent::Ping => { - // Heartbeat ping received from runtime - } - - RuntimeEvent::Pong => { - // Heartbeat pong response - } + // Heartbeat — intentionally not traced (too noisy). + RuntimeEvent::Ping | RuntimeEvent::Pong => {} RuntimeEvent::VoiceStart => { self.mode = "VOICE RECORDING".into(); + self.events + .push("VOICE_START", EventStatus::Running, "recording"); } - RuntimeEvent::VoiceChunk { .. } => { - // Voice stream data chunk received - } + RuntimeEvent::VoiceChunk { .. } => {} RuntimeEvent::VoiceEnd => { self.mode = "PROCESSING VOICE".into(); + self.events + .push("VOICE_END", EventStatus::Info, "processing"); } RuntimeEvent::Unknown(_) => {} } } + + /// Append a Copilot-style activity log line to the conversation. + fn add_activity(&mut self, text: String, kind: ActivityKind) { + self.msg_counter += 1; + self.conversation.add_message(Message::activity( + format!("act-{}", self.msg_counter), + text, + kind, + )); + self.effects.on_message(); + } + + /// Runtime socket connected (called from the event loop on startup). + pub fn on_connected(&mut self) { + self.mode = "CONNECTED".into(); + self.effects.on_status_change(theme::OK); + self.events + .push("CONNECTED", EventStatus::Completed, "runtime connected"); + } + + /// Runtime socket unavailable at startup. + pub fn on_offline(&mut self, detail: String) { + self.mode = format!("OFFLINE ({})", detail); + self.effects.on_status_change(theme::DANGER); + self.events.push("OFFLINE", EventStatus::Failed, detail); + } + + /// IPC stream error while running. + pub fn on_ipc_error(&mut self, detail: String) { + self.mode = format!("IPC ERROR: {}", detail); + self.effects.on_status_change(theme::DANGER); + self.events.push("IPC_ERROR", EventStatus::Failed, detail); + } } diff --git a/client/src/main.rs b/client/src/main.rs index cf7bea7..474a43d 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -44,10 +44,10 @@ async fn main() -> Result<(), Box> { match OrionClient::connect(SOCKET_PATH).await { Ok(client) => { app.client = Some(client); - app.mode = "CONNECTED".to_string(); + app.on_connected(); } Err(err) => { - app.mode = format!("OFFLINE ({})", err); + app.on_offline(err.to_string()); } } @@ -151,7 +151,7 @@ async fn main() -> Result<(), Box> { } => { match runtime_event { Some(Ok(event)) => app.handle_runtime_event(event), - Some(Err(err)) => app.mode = format!("IPC ERROR: {}", err), + Some(Err(err)) => app.on_ipc_error(err.to_string()), None => {} } } diff --git a/client/src/ui.rs b/client/src/ui.rs index 936d920..2f5c5ac 100644 --- a/client/src/ui.rs +++ b/client/src/ui.rs @@ -12,9 +12,7 @@ use tachyonfx::Duration; use crate::app::{App, InputMode}; use crate::theme::default_style; -use crate::widgets::{ - events::EventStreamWidget, header::BannerWidget, prompt::PromptWidget, status::StatusWidget, -}; +use crate::widgets::{header::BannerWidget, prompt::PromptWidget, status::StatusWidget}; /// Render one frame: widgets first, then the animated effect passes. pub fn draw(app: &mut App, frame: &mut Frame, dt: Duration) { @@ -52,8 +50,9 @@ pub fn draw(app: &mut App, frame: &mut Frame, dt: Duration) { let events_area = content[1]; let status_area = main[2]; - // Cache prompt bounds for mouse hit-testing. + // Cache panel bounds for mouse hit-testing / scroll routing. app.prompt_area = prompt_area; + app.events_area = events_area; // --- widgets -------------------------------------------------------- frame.render_widget(BannerWidget::render(header_area), header_area); @@ -68,7 +67,7 @@ pub fn draw(app: &mut App, frame: &mut Frame, dt: Duration) { is_focused, ); - frame.render_widget(EventStreamWidget::render(), events_area); + app.events.render(frame, events_area); frame.render_widget( StatusWidget::render(&app.mode, &app.input_mode, app.events_count, app.frame_tick), status_area, @@ -85,3 +84,42 @@ pub fn draw(app: &mut App, frame: &mut Frame, dt: Duration) { app.effects.render_status(frame, status_area, dt); app.effects.render_startup(frame, area, dt); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use crate::ipc::events::RuntimeEvent; + use ratatui::{Terminal, backend::TestBackend}; + use tachyonfx::Duration; + + /// Feed a realistic sequence of runtime events through the app and render + /// several frames — the event stream, activity logs, and effects must all + /// process without panicking. + #[test] + fn renders_events_and_activities_without_panicking() { + let mut app = App::new(); + app.handle_runtime_event(RuntimeEvent::Connected); + app.handle_runtime_event(RuntimeEvent::ToolStarted { + name: "read_file".into(), + }); + app.handle_runtime_event(RuntimeEvent::ToolFinished { + name: "read_file".into(), + success: true, + }); + app.handle_runtime_event(RuntimeEvent::ToolFinished { + name: "write_file".into(), + success: false, + }); + app.handle_runtime_event(RuntimeEvent::AssistantStart); + app.handle_runtime_event(RuntimeEvent::AssistantChunk("hello".into())); + app.handle_runtime_event(RuntimeEvent::AssistantEnd); + + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + for _ in 0..5 { + terminal + .draw(|frame| draw(&mut app, frame, Duration::from_millis(16))) + .unwrap(); + } + } +} diff --git a/client/src/widgets/conversation.rs b/client/src/widgets/conversation.rs index a4883dc..a618437 100644 --- a/client/src/widgets/conversation.rs +++ b/client/src/widgets/conversation.rs @@ -10,8 +10,8 @@ use ratatui::{ use serde::{Deserialize, Serialize}; use crate::theme::{ - FG, ORION_ACCENT, ORION_BUBBLE, ORION_EDGE, ORION_ICON, PANEL_BG, USER_ACCENT, USER_BUBBLE, - USER_EDGE, USER_NAME, border_style, + DANGER, FG, MUTED, OK, ORION_ACCENT, ORION_BUBBLE, ORION_EDGE, ORION_ICON, PANEL_BG, + USER_ACCENT, USER_BUBBLE, USER_EDGE, USER_NAME, border_style, }; /// IPC-friendly payload enum for identifying sender roles @@ -19,6 +19,16 @@ use crate::theme::{ pub enum Author { Orion, User, + /// A Copilot-style inline activity log, not a chat bubble. + Activity, +} + +/// Status of a Copilot-style activity log line. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ActivityKind { + Running, + Done, + Failed, } /// Dynamic message model designed to be serialized/deserialized over IPC channels @@ -28,24 +38,40 @@ pub struct Message { pub author: Author, pub content: String, pub timestamp: u64, + /// Present only for `Author::Activity` messages. + pub activity: Option, } impl Message { pub fn new(id: String, author: Author, content: String) -> Self { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - Self { id, author, content, - timestamp, + timestamp: now_secs(), + activity: None, + } + } + + /// Build a concise activity log line (rendered inline, not as a bubble). + pub fn activity(id: String, content: String, kind: ActivityKind) -> Self { + Self { + id, + author: Author::Activity, + content, + timestamp: now_secs(), + activity: Some(kind), } } } +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + #[derive(Default)] pub struct ConversationWidget { pub messages: Vec, @@ -157,6 +183,11 @@ impl ConversationWidget { .messages .iter() .map(|msg| { + // Activity logs are single compact lines, not bubbles. + if msg.author == Author::Activity { + return 1; + } + let text_width = max_bubble_width.saturating_sub(4).max(1) as usize; let lines: usize = msg @@ -223,6 +254,20 @@ impl ConversationWidget { } match msg.author { + Author::Activity => { + let (icon, color) = match msg.activity { + Some(ActivityKind::Running) => ("◐", ORION_ACCENT), + Some(ActivityKind::Done) => ("✓", OK), + Some(ActivityKind::Failed) => ("✕", DANGER), + None => ("·", MUTED), + }; + let text_color = if color == DANGER { DANGER } else { MUTED }; + let line = Line::from(vec![ + Span::styled(format!(" {} ", icon), Style::default().fg(color)), + Span::styled(&msg.content, Style::default().fg(text_color)), + ]); + frame.render_widget(Paragraph::new(line), rows[i]); + } Author::User => { let content_len = (msg.content.len() as u16 + 4).max(USER_NAME.len() as u16 + 4); diff --git a/client/src/widgets/events.rs b/client/src/widgets/events.rs index afcfdd1..e63c26a 100644 --- a/client/src/widgets/events.rs +++ b/client/src/widgets/events.rs @@ -1,26 +1,176 @@ +//! Real-time event stream panel. +//! +//! [`EventLog`] is a scrollable, auto-following trace of runtime events. Each +//! entry carries a timestamp, an event type (e.g. `TOOL_STARTED`), an +//! execution status, and an optional detail. The app pushes entries as +//! [`crate::ipc::events::RuntimeEvent`]s arrive; the panel auto-scrolls to the +//! tail unless the user has scrolled up (manual scrolling is preserved). + use ratatui::{ - style::Stylize, // Fix E0599: Import Stylize for .bg() - text::Line, + Frame, + layout::Rect, + style::{Color, Style, Stylize}, + text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use crate::theme::{PANEL_BG, border_style}; +use crate::theme::{DANGER, DIM, MUTED, OK, ORION_ACCENT, PANEL_BG, border_style}; + +/// Cap on retained entries so a long session doesn't grow unbounded. +const MAX_ENTRIES: usize = 500; + +/// Execution status of an event, driving its icon and color. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventStatus { + Info, + Running, + Completed, + Failed, +} + +impl EventStatus { + fn icon(self) -> &'static str { + match self { + EventStatus::Info => "·", + EventStatus::Running => "◐", + EventStatus::Completed => "✓", + EventStatus::Failed => "✕", + } + } + + fn color(self) -> Color { + match self { + EventStatus::Info => MUTED, + EventStatus::Running => ORION_ACCENT, + EventStatus::Completed => OK, + EventStatus::Failed => DANGER, + } + } +} + +#[derive(Debug, Clone)] +pub struct EventEntry { + pub time: String, + pub kind: String, + pub status: EventStatus, + pub detail: String, +} + +/// Scrollable, auto-following log of runtime events. +#[derive(Default)] +pub struct EventLog { + entries: Vec, + /// Lines above the bottom; `0` follows the tail (auto-scroll). + scroll_offset: u16, +} + +impl EventLog { + pub fn new() -> Self { + Self::default() + } + + /// Append a new event. If the user has scrolled up, the viewport is + /// preserved; otherwise the panel keeps following the tail. + pub fn push( + &mut self, + kind: impl Into, + status: EventStatus, + detail: impl Into, + ) { + self.entries.push(EventEntry { + time: now_hms(), + kind: kind.into(), + status, + detail: detail.into(), + }); + + if self.entries.len() > MAX_ENTRIES { + let overflow = self.entries.len() - MAX_ENTRIES; + self.entries.drain(0..overflow); + } + + if self.scroll_offset > 0 { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + } -pub struct EventStreamWidget; + /// Update the most recent entry if it shares `kind` (used to coalesce + /// high-frequency events like response chunks); otherwise push a new one. + pub fn bump_last(&mut self, kind: &str, status: EventStatus, detail: impl Into) { + if let Some(last) = self.entries.last_mut() { + if last.kind == kind { + last.status = status; + last.detail = detail.into(); + last.time = now_hms(); + return; + } + } + self.push(kind, status, detail); + } -impl EventStreamWidget { - pub fn render() -> Paragraph<'static> { - let events = vec![ - Line::from("[15:31:00] INFO: App initialized"), - Line::from("[15:31:05] OK: Connection established"), - ]; + pub fn scroll_up(&mut self, lines: u16) { + self.scroll_offset = self.scroll_offset.saturating_add(lines); + } - Paragraph::new(events).block( - Block::default() - .title(" Event Stream ") - .borders(Borders::ALL) - .border_style(border_style()) - .bg(PANEL_BG), - ) + pub fn scroll_down(&mut self, lines: u16) { + self.scroll_offset = self.scroll_offset.saturating_sub(lines); } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(" event stream ") + .borders(Borders::ALL) + .border_style(border_style()) + .bg(PANEL_BG); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 { + return; + } + + let lines: Vec = self + .entries + .iter() + .map(|e| { + let detail = if e.detail.is_empty() { + String::new() + } else { + format!(" {}", e.detail) + }; + Line::from(vec![ + Span::styled(format!("{} ", e.time), Style::default().fg(DIM)), + Span::styled( + format!("{} {}", e.status.icon(), e.kind), + Style::default().fg(e.status.color()), + ), + Span::styled(detail, Style::default().fg(MUTED)), + ]) + }) + .collect(); + + // Auto-follow the tail; `scroll_offset` moves the viewport upward. + let total = lines.len() as u16; + let max_scroll = total.saturating_sub(inner.height); + let offset = self.scroll_offset.min(max_scroll); + let scroll_y = max_scroll.saturating_sub(offset); + + frame.render_widget(Paragraph::new(lines).scroll((scroll_y, 0)), inner); + } +} + +/// UTC `HH:MM:SS` timestamp for the trace (monotonic, timezone-agnostic). +fn now_hms() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!( + "{:02}:{:02}:{:02}", + (secs / 3600) % 24, + (secs / 60) % 60, + secs % 60 + ) }