diff --git a/client/src/app.rs b/client/src/app.rs index a012cfd..7f87d4b 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -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 @@ -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(), @@ -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); + } + _ => {} } } @@ -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(); } @@ -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, diff --git a/client/src/main.rs b/client/src/main.rs index 5421188..bec507a 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -73,12 +73,33 @@ async fn main() -> Result<(), Box> { } 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 { @@ -88,11 +109,17 @@ async fn main() -> Result<(), Box> { 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); } _ => {} }, @@ -110,10 +137,8 @@ async fn main() -> Result<(), Box> { // 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> Some(client.next_event().await) } else { - // Returns Option> tokio::time::sleep(Duration::from_secs(3600)).await; None } diff --git a/client/src/widgets/conversation.rs b/client/src/widgets/conversation.rs index fae23fc..1e8310c 100644 --- a/client/src/widgets/conversation.rs +++ b/client/src/widgets/conversation.rs @@ -49,6 +49,7 @@ impl Message { #[derive(Default)] pub struct ConversationWidget { pub messages: Vec, + pub scroll_offset: usize, } impl ConversationWidget { @@ -56,6 +57,7 @@ impl ConversationWidget { pub fn new() -> Self { Self { messages: Vec::new(), + scroll_offset: 0, } } @@ -74,18 +76,36 @@ 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 @@ -93,6 +113,7 @@ impl ConversationWidget { 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; } } @@ -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 @@ -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 ") @@ -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 = visible_heights .iter() diff --git a/client/src/widgets/prompt.rs b/client/src/widgets/prompt.rs index 2413da5..9b35104 100644 --- a/client/src/widgets/prompt.rs +++ b/client/src/widgets/prompt.rs @@ -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)); + } + } } } diff --git a/runtime/src/orion/runtime/run.py b/runtime/src/orion/runtime/run.py index 0e8d57c..b96c944 100644 --- a/runtime/src/orion/runtime/run.py +++ b/runtime/src/orion/runtime/run.py @@ -81,7 +81,7 @@ async def run() -> None: ) bridge = IPCBridge(bus) - + orchestrator = Orchestrator( bus=bus, config=OrchestratorConfig( @@ -91,7 +91,6 @@ async def run() -> None: ), ) - server = IPCServer( socket_path="/tmp/orion.sock", session_handler=bridge.serve,