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
1 change: 1 addition & 0 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ uuid = { version = "1", features = ["v4", "serde"] }
serde_json = "1"
thiserror = "1"
futures-util = "0.3"
tachyonfx = "=0.16.0"
99 changes: 16 additions & 83 deletions client/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Position, Rect},
};

use crate::widgets::{
conversation::{Author, ConversationWidget, Message},
events::EventStreamWidget,
header::BannerWidget,
prompt::PromptWidget,
status::StatusWidget,
};
use crate::{
ipc::{client::OrionClient, events::RuntimeEvent},
theme::default_style,
};
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};

#[derive(Debug, PartialEq, Eq)]
pub enum InputMode {
Expand All @@ -38,6 +28,9 @@ pub struct App {

// Store layout bounds for mouse click target checks
pub prompt_area: Rect,

// Animations (tachyonfx)
pub effects: Effects,
}

impl App {
Expand All @@ -54,6 +47,7 @@ impl App {
msg_counter: 0,
client: None,
prompt_area: Rect::default(),
effects: Effects::new(),
}
}

Expand All @@ -76,6 +70,7 @@ impl App {
pub fn enter_char(&mut self, new_char: char) {
self.input.insert(self.cursor_position, new_char);
self.move_cursor_right();
self.effects.on_keystroke();
}

pub fn delete_char(&mut self) {
Expand Down Expand Up @@ -134,6 +129,7 @@ impl App {
Author::User,
content,
));
self.effects.on_message();

self.input.clear();
self.cursor_position = 0; // Reset cursor position on submit
Expand All @@ -145,10 +141,12 @@ impl App {
match event {
RuntimeEvent::Connected => {
self.mode = "CONNECTED".into();
self.effects.on_status_change(theme::OK);
}

RuntimeEvent::Disconnected => {
self.mode = "DISCONNECTED".into();
self.effects.on_status_change(theme::DANGER);
}

RuntimeEvent::AssistantStart => {
Expand All @@ -158,6 +156,7 @@ impl App {
// Begin a single message bubble for the streaming response
self.conversation
.begin_assistant_message(format!("msg-{}", self.msg_counter));
self.effects.on_message();
}

RuntimeEvent::AssistantChunk(text) => {
Expand Down Expand Up @@ -185,6 +184,7 @@ impl App {

RuntimeEvent::Error { message, .. } => {
self.mode = format!("ERROR: {}", message);
self.effects.on_status_change(theme::DANGER);
}

RuntimeEvent::Ping => {
Expand All @@ -210,71 +210,4 @@ impl App {
RuntimeEvent::Unknown(_) => {}
}
}

pub fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
frame.render_widget(
ratatui::widgets::Block::default().style(default_style()),
area,
);

// Main Vertical Split
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(2), // Banner
Constraint::Min(0), // Content
Constraint::Length(1), // Status Bar
])
.split(area);

// Horizontal Middle Split: Left Column (66%), Right Column (34%)
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(main_chunks[1]);

// Left Column Vertical Split: Conversation Area (Flex) + Prompt Box (3 rows)
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Conversation
Constraint::Length(3), // Prompt input box
])
.split(content_chunks[0]);

// Cache prompt area for mouse click collision detection
self.prompt_area = left_chunks[1];

// 1. Render Banner
frame.render_widget(BannerWidget::render(main_chunks[0]), main_chunks[0]);

// 2. Render Left Column
self.conversation.render(frame, left_chunks[0]);

// Render Prompt with current cursor position
let is_focused = self.input_mode == InputMode::Insert;
PromptWidget::render(
frame,
left_chunks[1],
&self.input,
self.cursor_position,
is_focused,
);

// 3. Render Right Column
frame.render_widget(EventStreamWidget::render(), content_chunks[1]);

// 4. Render Status Bar
frame.render_widget(
StatusWidget::update_status(
&self.mode,
&self.input_mode,
self.events_count,
self.frame_tick,
),
main_chunks[2],
);
}
}
200 changes: 200 additions & 0 deletions client/src/effects.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
//! Animated visual effects, powered by [`tachyonfx`].
//!
//! Effects are post-processing passes applied to the already-rendered frame
//! buffer each tick. [`Effects`] owns the named effects, exposes small
//! `on_*` triggers that [`crate::app::App`] calls on state changes, and
//! `render_*` passes that [`crate::ui`] calls with the relevant screen region
//! and the frame's elapsed time.
//!
//! The recipes are adapted from junkdog's `exabind` tachyonfx tech demo:
//! a layered sweep+coalesce intro (its `open_category`), a per-keystroke
//! "click" flash (its `key_press`), and a border glow that pulses while the
//! assistant is busy (its LED-border idea, scoped to border cells via
//! [`CellFilter::Outer`]).

use ratatui::{
Frame,
layout::{Margin, Rect},
style::Color,
};
use tachyonfx::{CellFilter, Duration, Effect, EffectRenderer, Interpolation, Motion, Shader, fx};

use crate::theme;

/// Owns every animation in the client and drives it frame by frame.
pub struct Effects {
/// One-shot full-screen reveal played once on launch.
startup: Effect,
/// Endless, gentle "breathing" glow on the header accent.
header_shimmer: Effect,
/// Endless cyan border pulse — rendered only while the assistant is busy.
thinking: Effect,
/// Brief "materialize" pass when a new message bubble appears.
message_in: Option<Effect>,
/// Brief tint flash on the status bar for connection / state changes.
status_pulse: Option<Effect>,
/// Brief accent flash on the prompt for each keystroke.
prompt_flash: Option<Effect>,
}

impl Effects {
pub fn new() -> Self {
Self {
startup: startup_effect(),
header_shimmer: header_shimmer_effect(),
thinking: thinking_effect(),
message_in: None,
status_pulse: None,
prompt_flash: None,
}
}

// --- triggers: called from `App` when state changes -------------------

/// A new message appeared — play a short materialize over the transcript.
pub fn on_message(&mut self) {
self.message_in = Some(message_in_effect());
}

/// Connection / activity state changed — flash the status bar in `color`.
pub fn on_status_change(&mut self, color: Color) {
self.status_pulse = Some(status_pulse_effect(color));
}

/// A key was typed into the prompt — flash the input accent.
pub fn on_keystroke(&mut self) {
self.prompt_flash = Some(prompt_flash_effect());
}

// --- render passes: called from `ui::draw` after widgets render -------

pub fn render_startup(&mut self, frame: &mut Frame, area: Rect, dt: Duration) {
if self.startup.running() {
frame.render_effect(&mut self.startup, area, dt);
}
}

pub fn render_header(&mut self, frame: &mut Frame, area: Rect, dt: Duration) {
frame.render_effect(&mut self.header_shimmer, area, dt);
}

/// Pulse the transcript border while `active` (assistant busy).
pub fn render_thinking(&mut self, frame: &mut Frame, area: Rect, dt: Duration, active: bool) {
if active {
frame.render_effect(&mut self.thinking, area, dt);
}
}

pub fn render_conversation(&mut self, frame: &mut Frame, area: Rect, dt: Duration) {
Self::render_optional(&mut self.message_in, frame, area, dt);
}

pub fn render_status(&mut self, frame: &mut Frame, area: Rect, dt: Duration) {
Self::render_optional(&mut self.status_pulse, frame, area, dt);
}

pub fn render_prompt(&mut self, frame: &mut Frame, area: Rect, dt: Duration) {
Self::render_optional(&mut self.prompt_flash, frame, area, dt);
}

/// Render a one-shot effect if it is still running, otherwise drop it.
fn render_optional(slot: &mut Option<Effect>, frame: &mut Frame, area: Rect, dt: Duration) {
if let Some(effect) = slot.as_mut() {
if effect.running() {
frame.render_effect(effect, area, dt);
} else {
*slot = None;
}
}
}
}

impl Default for Effects {
fn default() -> Self {
Self::new()
}
}

// --- effect recipes -------------------------------------------------------

/// Layered intro: a left-to-right reveal with the whole frame coalescing in
/// underneath it (adapted from exabind's `open_category`).
fn startup_effect() -> Effect {
fx::parallel(&[
fx::sweep_in(
Motion::LeftToRight,
25,
10,
theme::BG,
(850, Interpolation::QuadOut),
),
fx::coalesce((900, Interpolation::SineOut)),
])
}

/// A hue/lightness ping-pong on the header — a clearly visible "breathing"
/// glow so the UI reads as alive even while idle and offline.
fn header_shimmer_effect() -> Effect {
fx::repeating(fx::ping_pong(fx::hsl_shift(
Some([28.0, 0.0, 32.0]),
None,
(1100, Interpolation::SineInOut),
)))
}

/// Endless cyan pulse restricted to the border ring of whatever area it is
/// rendered on. Used on the transcript panel while the assistant is busy.
fn thinking_effect() -> Effect {
fx::repeating(fx::ping_pong(
fx::fade_to_fg(theme::ORION_ACCENT, (700, Interpolation::SineInOut))
.with_filter(CellFilter::Outer(Margin::new(1, 1))),
))
}

/// New bubbles coalesce into place rather than snapping in.
fn message_in_effect() -> Effect {
fx::coalesce((380, Interpolation::SineOut))
}

/// A short foreground tint that fades back to normal.
fn status_pulse_effect(color: Color) -> Effect {
fx::fade_from_fg(color, (500, Interpolation::SineOut))
}

/// A quick "click" flash on the prompt for tactile keystroke feedback
/// (adapted from exabind's `key_press`).
fn prompt_flash_effect() -> Effect {
fx::fade_from_fg(theme::ORION_ACCENT, (220, Interpolation::SineOut))
}

#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};

/// Every effect must process over a real buffer without panicking.
#[test]
fn effects_render_without_panicking() {
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
let mut effects = Effects::new();
effects.on_message();
effects.on_status_change(theme::OK);
effects.on_keystroke();

// Drive several frames so one-shot effects run to completion.
for _ in 0..20 {
terminal
.draw(|frame| {
let area = frame.area();
let dt = Duration::from_millis(16);
effects.render_startup(frame, area, dt);
effects.render_header(frame, area, dt);
effects.render_thinking(frame, area, dt, true);
effects.render_conversation(frame, area, dt);
effects.render_status(frame, area, dt);
effects.render_prompt(frame, area, dt);
})
.unwrap();
}
}
}
7 changes: 2 additions & 5 deletions client/src/ipc/protocol.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
use crate::ipc::{
error::IpcError,
messages::Envelope,
};
use crate::ipc::{error::IpcError, messages::Envelope};

/// Serialize an IPC message into bytes.
///
Expand All @@ -16,4 +13,4 @@ pub fn encode(message: &Envelope) -> Result<Vec<u8>, IpcError> {
/// Deserialize an IPC message received from the socket.
pub fn decode(data: &str) -> Result<Envelope, IpcError> {
Ok(serde_json::from_str(data.trim_end())?)
}
}
Loading
Loading