Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 99 additions & 23 deletions client/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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(),
}
}
Expand Down Expand Up @@ -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);
}
}
_ => {}
}
Expand All @@ -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,
Expand All @@ -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 => {
Expand All @@ -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);
}
}
6 changes: 3 additions & 3 deletions client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
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());
}
}

Expand Down Expand Up @@ -151,7 +151,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
} => {
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 => {}
}
}
Expand Down
48 changes: 43 additions & 5 deletions client/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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();
}
}
}
Loading
Loading