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
69 changes: 56 additions & 13 deletions client/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub struct App {
pub frame_tick: usize,
pub conversation: ConversationWidget,
pub input: String,
pub cursor_position: usize, // Track character index inside `input`
pub msg_counter: usize,

// Client
Expand All @@ -49,6 +50,7 @@ impl App {
frame_tick: 0,
conversation: ConversationWidget::new(),
input: String::new(),
cursor_position: 0,
msg_counter: 0,
client: None,
prompt_area: Rect::default(),
Expand All @@ -59,17 +61,54 @@ impl App {
self.frame_tick = self.frame_tick.wrapping_add(1);
}

pub fn handle_mouse(&mut self, event: MouseEvent) {
if event.kind == MouseEventKind::Down(MouseButton::Left) {
let click_pos = Position::new(event.column, event.row);
pub fn move_cursor_left(&mut self) {
let cursor_moved_left = self.cursor_position.saturating_sub(1);
self.cursor_position = cursor_moved_left;
}

pub fn move_cursor_right(&mut self) {
let cursor_moved_right = self.cursor_position.saturating_add(1);
if cursor_moved_right <= self.input.len() {
self.cursor_position = cursor_moved_right;
}
}

pub fn enter_char(&mut self, new_char: char) {
self.input.insert(self.cursor_position, new_char);
self.move_cursor_right();
}

pub fn delete_char(&mut self) {
if self.cursor_position != 0 {
let current_index = self.cursor_position;
let from_left_to_current_index = current_index - 1;

// If user clicks inside the prompt box -> Enter Insert mode
if self.prompt_area.contains(click_pos) {
self.input_mode = InputMode::Insert;
} else {
// Clicking anywhere else switches back to Normal mode
self.input_mode = InputMode::Normal;
self.input.remove(from_left_to_current_index);
self.move_cursor_left();
}
}

pub fn handle_mouse(&mut self, event: MouseEvent) {
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
let click_pos = Position::new(event.column, event.row);

// If user clicks inside the prompt box -> Enter Insert mode
if self.prompt_area.contains(click_pos) {
self.input_mode = InputMode::Insert;
} else {
// Clicking anywhere else switches back to Normal mode
self.input_mode = InputMode::Normal;
}
}
// Vertical scroll wheel support
MouseEventKind::ScrollUp => {
self.conversation.scroll_up(2);
}
MouseEventKind::ScrollDown => {
self.conversation.scroll_down(2);
}
_ => {}
}
}

Expand Down Expand Up @@ -97,6 +136,7 @@ impl App {
));

self.input.clear();
self.cursor_position = 0; // Reset cursor position on submit
self.events_count += 1;
self.mode = "THINKING".into();
}
Expand Down Expand Up @@ -213,17 +253,20 @@ impl App {
// 2. Render Left Column
self.conversation.render(frame, left_chunks[0]);

// Render Prompt (Highlight border when focused in Insert mode)
// Render Prompt with current cursor position
let is_focused = self.input_mode == InputMode::Insert;
frame.render_widget(
PromptWidget::render(&self.input, is_focused),
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 (with updated input mode indicator)
// 4. Render Status Bar
frame.render_widget(
StatusWidget::update_status(
&self.mode,
Expand Down
33 changes: 29 additions & 4 deletions client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,33 @@ async fn main() -> Result<(), Box<dyn Error>> {
} else {
match app.input_mode {
InputMode::Normal => match key.code {
// Entering Insert Mode
KeyCode::Char('i') | KeyCode::Char('a') => {
app.input_mode = InputMode::Insert;
}
// Quit
KeyCode::Char('q') | KeyCode::Esc => {
app.should_quit = true;
}
// Conversation Scrolling (Vim keys & standard navigation)
KeyCode::Up | KeyCode::Char('k') => {
app.conversation.scroll_up(1);
}
KeyCode::Down | KeyCode::Char('j') => {
app.conversation.scroll_down(1);
}
KeyCode::PageUp => {
app.conversation.scroll_up(5);
}
KeyCode::PageDown => {
app.conversation.scroll_down(5);
}
KeyCode::Home => {
app.conversation.scroll_up(usize::MAX);
}
KeyCode::End | KeyCode::Char('G') => {
app.conversation.scroll_to_bottom();
}
_ => {}
},
InputMode::Insert => match key.code {
Expand All @@ -88,11 +109,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
KeyCode::Enter => {
app.submit_prompt().await;
}
KeyCode::Left => {
app.move_cursor_left();
}
KeyCode::Right => {
app.move_cursor_right();
}
KeyCode::Backspace => {
app.input.pop();
app.delete_char();
}
KeyCode::Char(c) => {
app.input.push(c);
app.enter_char(c);
}
_ => {}
},
Expand All @@ -110,10 +137,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
// Receive IPC stream events from Orion Python Runtime
runtime_event = async {
if let Some(client) = &mut app.client {
// Wrap in Some() so this branch returns Option<Result<RuntimeEvent, IpcError>>
Some(client.next_event().await)
} else {
// Returns Option<Result<RuntimeEvent, IpcError>>
tokio::time::sleep(Duration::from_secs(3600)).await;
None
}
Expand Down
56 changes: 45 additions & 11 deletions client/src/widgets/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ impl Message {
#[derive(Default)]
pub struct ConversationWidget {
pub messages: Vec<Message>,
pub scroll_offset: usize,
}

impl ConversationWidget {
/// Creates a clean, empty conversation state
pub fn new() -> Self {
Self {
messages: Vec::new(),
scroll_offset: 0,
}
}

Expand All @@ -74,25 +76,44 @@ impl ConversationWidget {
"All systems operational. Listening for event triggers...".to_string(),
),
],
scroll_offset: 0,
}
}

/// Scroll up by N items
pub fn scroll_up(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_add(lines);
}

/// Scroll down by N items
pub fn scroll_down(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(lines);
}

/// Reset scroll to the most recent messages at the bottom
pub fn scroll_to_bottom(&mut self) {
self.scroll_offset = 0;
}

/// Public interface for adding a complete message
pub fn add_message(&mut self, message: Message) {
self.messages.push(message);
self.scroll_to_bottom();
}

/// Starts streaming a new assistant message
pub fn begin_assistant_message(&mut self, id: String) {
self.messages
.push(Message::new(id, Author::Orion, String::new()));
self.scroll_to_bottom();
}

/// Appends incoming streamed chunk to the active assistant response bubble
pub fn append_assistant_chunk(&mut self, chunk: &str) {
if let Some(last_msg) = self.messages.last_mut() {
if last_msg.author == Author::Orion {
last_msg.content.push_str(chunk);
self.scroll_to_bottom();
return;
}
}
Expand All @@ -101,6 +122,7 @@ impl ConversationWidget {
let fallback_id = format!("msg-{}", self.messages.len() + 1);
self.messages
.push(Message::new(fallback_id, Author::Orion, chunk.to_string()));
self.scroll_to_bottom();
}

/// Called when streaming response terminates
Expand All @@ -110,9 +132,10 @@ impl ConversationWidget {

pub fn clear(&mut self) {
self.messages.clear();
self.scroll_offset = 0;
}

pub fn render(&self, frame: &mut Frame, area: Rect) {
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
// Outer Panel Block
let outer_block = Block::default()
.title(" conversation ")
Expand Down Expand Up @@ -153,25 +176,36 @@ impl ConversationWidget {
})
.collect();

// Calculate auto-scroll window (bottom-up view)
let total_height: u16 = message_heights.iter().sum();
let available_height = inner_area.height;

// Clamp scroll offset to prevent scrolling past the top message boundary
let max_scroll = if total_height > available_height {
self.messages.len().saturating_sub(1)
} else {
0
};
self.scroll_offset = self.scroll_offset.min(max_scroll);

// Calculate bottom-up visible window including scroll offset
let mut start_idx = 0;
let mut end_idx = self.messages.len().saturating_sub(self.scroll_offset);
let mut accumulated_height = 0;

if total_height > available_height {
for (i, &h) in message_heights.iter().enumerate().rev() {
if accumulated_height + h > available_height {
start_idx = i + 1;
break;
}
accumulated_height += h;
for (i, &h) in message_heights[..end_idx].iter().enumerate().rev() {
if accumulated_height + h > available_height {
start_idx = i + 1;
break;
}
accumulated_height += h;
}

if start_idx > end_idx {
start_idx = end_idx;
}

let visible_messages = &self.messages[start_idx..];
let visible_heights = &message_heights[start_idx..];
let visible_messages = &self.messages[start_idx..end_idx];
let visible_heights = &message_heights[start_idx..end_idx];

let constraints: Vec<Constraint> = visible_heights
.iter()
Expand Down
61 changes: 43 additions & 18 deletions client/src/widgets/prompt.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,61 @@
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph}
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
};

use crate::theme::{border_style, FG, ORION_ACCENT, PANEL_BG};
use crate::theme::{FG, ORION_ACCENT, PANEL_BG, border_style};

pub struct PromptWidget;

impl PromptWidget {
pub fn render(input:&str, is_focused: bool) -> Paragraph<'static> {
pub fn render(
frame: &mut Frame,
area: Rect,
input: &str,
cursor_position: usize,
is_focused: bool,
) {
let border_color = if is_focused {
ORION_ACCENT
} else {
border_style().fg.unwrap_or(ORION_ACCENT)
};

let prompt_prefix = "> ";
let prompt_text = vec![Line::from(vec![
Span::styled(
"> ",
Style::default().fg(ORION_ACCENT).add_modifier(Modifier::BOLD),
),
Span::styled(input.to_string(), Style::default().fg(FG)),
])];

Paragraph::new(prompt_text).block(
Block::default()
.title(" prompt ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.bg(PANEL_BG),
)
Span::styled(
prompt_prefix,
Style::default()
.fg(ORION_ACCENT)
.add_modifier(Modifier::BOLD),
),
Span::styled(input, Style::default().fg(FG)),
])];

let widget = Paragraph::new(prompt_text)
.wrap(Wrap { trim: false })
.block(
Block::default()
.title(" prompt ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.bg(PANEL_BG),
);

frame.render_widget(widget, area);

// Place terminal cursor at active cursor_position index
if is_focused {
let cursor_x = area.x + 1 + prompt_prefix.len() as u16 + cursor_position as u16;
let cursor_y = area.y + 1;

if cursor_x < area.x + area.width - 1 {
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
Loading
Loading