Files
owlen/crates/owlen-tui/src/chat_app.rs

1615 lines
71 KiB
Rust

use anyhow::Result;
use owlen_core::{
session::{SessionController, SessionOutcome},
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
ui::{AppState, AutoScroll, FocusedPanel, InputMode},
};
use ratatui::style::{Color, Modifier, Style};
use tokio::sync::mpsc;
use tui_textarea::{Input, TextArea};
use uuid::Uuid;
use crate::config;
use crate::events::Event;
use std::collections::HashSet;
/// Messages emitted by asynchronous streaming tasks
#[derive(Debug)]
pub enum SessionEvent {
StreamChunk {
message_id: Uuid,
response: ChatResponse,
},
StreamError {
message: String,
},
}
pub struct ChatApp {
controller: SessionController,
pub mode: InputMode,
pub status: String,
pub error: Option<String>,
models: Vec<ModelInfo>, // All models fetched
pub available_providers: Vec<String>, // Unique providers from models
pub selected_provider: String, // The currently selected provider
pub selected_provider_index: usize, // Index into the available_providers list
pub selected_model: Option<usize>, // Index into the *filtered* models list
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
viewport_height: usize, // Track the height of the messages viewport
thinking_viewport_height: usize, // Track the height of the thinking viewport
content_width: usize, // Track the content width for line wrapping calculations
session_tx: mpsc::UnboundedSender<SessionEvent>,
streaming: HashSet<Uuid>,
textarea: TextArea<'static>, // Advanced text input widget
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
loading_animation_frame: usize, // Frame counter for loading animation
is_loading: bool, // Whether we're currently loading a response
current_thinking: Option<String>, // Current thinking content from last assistant message
pending_key: Option<char>, // For multi-key sequences like gg, dd
clipboard: String, // Vim-style clipboard for yank/paste
command_buffer: String, // Buffer for command mode input
visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel
visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels
focused_panel: FocusedPanel, // Currently focused panel for scrolling
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
}
impl ChatApp {
pub fn new(controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) {
let (session_tx, session_rx) = mpsc::unbounded_channel();
let mut textarea = TextArea::default();
configure_textarea_defaults(&mut textarea);
let app = Self {
controller,
mode: InputMode::Normal,
status: "Ready".to_string(),
error: None,
models: Vec::new(),
available_providers: Vec::new(),
selected_provider: "ollama".to_string(), // Default, will be updated in initialize_models
selected_provider_index: 0,
selected_model: None,
auto_scroll: AutoScroll::default(),
thinking_scroll: AutoScroll::default(),
viewport_height: 10, // Default viewport height, will be updated during rendering
thinking_viewport_height: 4, // Default thinking viewport height
content_width: 80, // Default content width, will be updated during rendering
session_tx,
streaming: std::collections::HashSet::new(),
textarea,
pending_llm_request: false,
loading_animation_frame: 0,
is_loading: false,
current_thinking: None,
pending_key: None,
clipboard: String::new(),
command_buffer: String::new(),
visual_start: None,
visual_end: None,
focused_panel: FocusedPanel::Input,
chat_cursor: (0, 0),
thinking_cursor: (0, 0),
};
(app, session_rx)
}
pub fn status_message(&self) -> &str {
&self.status
}
pub fn error_message(&self) -> Option<&String> {
self.error.as_ref()
}
pub fn mode(&self) -> InputMode {
self.mode
}
pub fn conversation(&self) -> &Conversation {
self.controller.conversation()
}
pub fn models(&self) -> Vec<&ModelInfo> {
self.models
.iter()
.filter(|m| m.provider == self.selected_provider)
.collect()
}
pub fn selected_model(&self) -> &str {
self.controller.selected_model()
}
pub fn config(&self) -> &owlen_core::config::Config {
self.controller.config()
}
pub fn selected_model_index(&self) -> Option<usize> {
self.selected_model
}
pub fn auto_scroll(&self) -> &AutoScroll {
&self.auto_scroll
}
pub fn auto_scroll_mut(&mut self) -> &mut AutoScroll {
&mut self.auto_scroll
}
pub fn scroll(&self) -> usize {
self.auto_scroll.scroll
}
pub fn thinking_scroll(&self) -> &AutoScroll {
&self.thinking_scroll
}
pub fn thinking_scroll_mut(&mut self) -> &mut AutoScroll {
&mut self.thinking_scroll
}
pub fn thinking_scroll_position(&self) -> usize {
self.thinking_scroll.scroll
}
pub fn message_count(&self) -> usize {
self.controller.conversation().messages.len()
}
pub fn streaming_count(&self) -> usize {
self.streaming.len()
}
pub fn formatter(&self) -> &owlen_core::formatting::MessageFormatter {
self.controller.formatter()
}
pub fn input_buffer(&self) -> &owlen_core::input::InputBuffer {
self.controller.input_buffer()
}
pub fn input_buffer_mut(&mut self) -> &mut owlen_core::input::InputBuffer {
self.controller.input_buffer_mut()
}
pub fn textarea(&self) -> &TextArea<'static> {
&self.textarea
}
pub fn textarea_mut(&mut self) -> &mut TextArea<'static> {
&mut self.textarea
}
pub fn command_buffer(&self) -> &str {
&self.command_buffer
}
pub fn focused_panel(&self) -> FocusedPanel {
self.focused_panel
}
pub fn visual_selection(&self) -> Option<((usize, usize), (usize, usize))> {
if let (Some(start), Some(end)) = (self.visual_start, self.visual_end) {
Some((start, end))
} else {
None
}
}
pub fn chat_cursor(&self) -> (usize, usize) {
self.chat_cursor
}
pub fn thinking_cursor(&self) -> (usize, usize) {
self.thinking_cursor
}
pub fn cycle_focus_forward(&mut self) {
self.focused_panel = match self.focused_panel {
FocusedPanel::Chat => {
if self.current_thinking.is_some() {
FocusedPanel::Thinking
} else {
FocusedPanel::Input
}
}
FocusedPanel::Thinking => FocusedPanel::Input,
FocusedPanel::Input => FocusedPanel::Chat,
};
}
pub fn cycle_focus_backward(&mut self) {
self.focused_panel = match self.focused_panel {
FocusedPanel::Chat => FocusedPanel::Input,
FocusedPanel::Thinking => FocusedPanel::Chat,
FocusedPanel::Input => {
if self.current_thinking.is_some() {
FocusedPanel::Thinking
} else {
FocusedPanel::Chat
}
}
};
}
/// Sync textarea content to input buffer
fn sync_textarea_to_buffer(&mut self) {
let text = self.textarea.lines().join("\n");
self.input_buffer_mut().set_text(text);
}
/// Sync input buffer content to textarea
fn sync_buffer_to_textarea(&mut self) {
let text = self.input_buffer().text().to_string();
let lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
self.textarea = TextArea::new(lines);
configure_textarea_defaults(&mut self.textarea);
}
pub async fn initialize_models(&mut self) -> Result<()> {
let config_model_name = self.controller.config().general.default_model.clone();
let config_model_provider = self.controller.config().general.default_provider.clone();
let all_models = self.controller.models(false).await?;
self.models = all_models;
// Populate available_providers
let providers = self
.models
.iter()
.map(|m| m.provider.clone())
.collect::<HashSet<_>>();
self.available_providers = providers.into_iter().collect();
self.available_providers.sort();
// Set selected_provider based on config, or default to "ollama" if not found
self.selected_provider = self
.available_providers
.iter()
.find(|&p| p == &config_model_provider)
.cloned()
.unwrap_or_else(|| "ollama".to_string());
self.selected_provider_index = self
.available_providers
.iter()
.position(|p| p == &self.selected_provider)
.unwrap_or(0);
self.sync_selected_model_index();
// Ensure the default model is set in the controller and config
self.controller.ensure_default_model(&self.models);
let current_model_name = self.controller.selected_model().to_string();
let current_model_provider = self.controller.config().general.default_provider.clone();
if config_model_name.as_deref() != Some(&current_model_name)
|| config_model_provider != current_model_provider
{
if let Err(err) = config::save_config(self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
} else {
self.error = None;
}
}
Ok(())
}
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {
use crossterm::event::{KeyCode, KeyModifiers};
match event {
Event::Tick => {
// Future: update streaming timers
}
Event::Paste(text) => {
// Handle paste events - insert text directly without triggering sends
if matches!(self.mode, InputMode::Editing) {
// In editing mode, insert the pasted text directly into textarea
let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
for ch in line.chars() {
self.textarea.insert_char(ch);
}
// Insert newline between lines (but not after the last line)
if i < lines.len() - 1 {
self.textarea.insert_newline();
}
}
self.sync_textarea_to_buffer();
}
// Ignore paste events in other modes
}
Event::Key(key) => match self.mode {
InputMode::Normal => {
// Handle multi-key sequences first
if let Some(pending) = self.pending_key {
self.pending_key = None;
match (pending, key.code) {
('g', KeyCode::Char('g')) => {
self.jump_to_top();
}
('d', KeyCode::Char('d')) => {
// Clear input buffer
self.input_buffer_mut().clear();
self.textarea = TextArea::default();
configure_textarea_defaults(&mut self.textarea);
self.status = "Input buffer cleared".to_string();
}
_ => {
// Invalid sequence, ignore
}
}
return Ok(AppState::Running);
}
match (key.code, key.modifiers) {
(KeyCode::Char('q'), KeyModifiers::NONE)
| (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
return Ok(AppState::Quit);
}
// Mode switches
(KeyCode::Char('v'), KeyModifiers::NONE) => {
self.mode = InputMode::Visual;
match self.focused_panel {
FocusedPanel::Input => {
// Sync buffer to textarea before entering visual mode
self.sync_buffer_to_textarea();
// Set a visible selection style
self.textarea.set_selection_style(
Style::default().bg(Color::LightBlue).fg(Color::Black),
);
// Start visual selection at current cursor position
self.textarea.start_selection();
self.visual_start = Some(self.textarea.cursor());
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// For scrollable panels, start selection at cursor position
let cursor = if matches!(self.focused_panel, FocusedPanel::Chat)
{
self.chat_cursor
} else {
self.thinking_cursor
};
self.visual_start = Some(cursor);
self.visual_end = Some(cursor);
}
}
self.status = "-- VISUAL -- (move with j/k, yank with y)".to_string();
}
(KeyCode::Char(':'), KeyModifiers::NONE) => {
self.mode = InputMode::Command;
self.command_buffer.clear();
self.status = ":".to_string();
}
// Enter editing mode
(KeyCode::Enter, KeyModifiers::NONE)
| (KeyCode::Char('i'), KeyModifiers::NONE) => {
self.mode = InputMode::Editing;
self.sync_buffer_to_textarea();
}
(KeyCode::Char('a'), KeyModifiers::NONE) => {
// Append - move right and enter insert mode
self.mode = InputMode::Editing;
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Forward);
}
(KeyCode::Char('A'), KeyModifiers::SHIFT) => {
// Append at end of line
self.mode = InputMode::Editing;
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::End);
}
(KeyCode::Char('I'), KeyModifiers::SHIFT) => {
// Insert at start of line
self.mode = InputMode::Editing;
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
}
(KeyCode::Char('o'), KeyModifiers::NONE) => {
// Insert newline below and enter edit mode
self.mode = InputMode::Editing;
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::End);
self.textarea.insert_newline();
}
(KeyCode::Char('O'), KeyModifiers::NONE) => {
// Insert newline above and enter edit mode
self.mode = InputMode::Editing;
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
self.textarea.insert_newline();
self.textarea.move_cursor(tui_textarea::CursorMove::Up);
}
// Basic scrolling and cursor movement
(KeyCode::Up, KeyModifiers::NONE)
| (KeyCode::Char('k'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Chat => {
if self.chat_cursor.0 > 0 {
self.chat_cursor.0 -= 1;
// Scroll if cursor moves above viewport
if self.chat_cursor.0 < self.auto_scroll.scroll {
self.on_scroll(-1);
}
}
}
FocusedPanel::Thinking => {
if self.thinking_cursor.0 > 0 {
self.thinking_cursor.0 -= 1;
if self.thinking_cursor.0 < self.thinking_scroll.scroll {
self.on_scroll(-1);
}
}
}
FocusedPanel::Input => {
self.on_scroll(-1);
}
}
}
(KeyCode::Down, KeyModifiers::NONE)
| (KeyCode::Char('j'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Chat => {
let max_lines = self.auto_scroll.content_len;
if self.chat_cursor.0 + 1 < max_lines {
self.chat_cursor.0 += 1;
// Scroll if cursor moves below viewport
let viewport_bottom =
self.auto_scroll.scroll + self.viewport_height;
if self.chat_cursor.0 >= viewport_bottom {
self.on_scroll(1);
}
}
}
FocusedPanel::Thinking => {
let max_lines = self.thinking_scroll.content_len;
if self.thinking_cursor.0 + 1 < max_lines {
self.thinking_cursor.0 += 1;
let viewport_bottom = self.thinking_scroll.scroll
+ self.thinking_viewport_height;
if self.thinking_cursor.0 >= viewport_bottom {
self.on_scroll(1);
}
}
}
FocusedPanel::Input => {
self.on_scroll(1);
}
}
}
// Horizontal cursor movement
(KeyCode::Left, KeyModifiers::NONE)
| (KeyCode::Char('h'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if self.chat_cursor.1 > 0 {
self.chat_cursor.1 -= 1;
}
}
FocusedPanel::Thinking => {
if self.thinking_cursor.1 > 0 {
self.thinking_cursor.1 -= 1;
}
}
_ => {}
},
(KeyCode::Right, KeyModifiers::NONE)
| (KeyCode::Char('l'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
let max_col = line.chars().count();
if self.chat_cursor.1 < max_col {
self.chat_cursor.1 += 1;
}
}
}
FocusedPanel::Thinking => {
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
let max_col = line.chars().count();
if self.thinking_cursor.1 < max_col {
self.thinking_cursor.1 += 1;
}
}
}
_ => {}
},
// Word movement
(KeyCode::Char('w'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(new_col) = self
.find_next_word_boundary(self.chat_cursor.0, self.chat_cursor.1)
{
self.chat_cursor.1 = new_col;
}
}
FocusedPanel::Thinking => {
if let Some(new_col) = self.find_next_word_boundary(
self.thinking_cursor.0,
self.thinking_cursor.1,
) {
self.thinking_cursor.1 = new_col;
}
}
_ => {}
},
(KeyCode::Char('e'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(new_col) =
self.find_word_end(self.chat_cursor.0, self.chat_cursor.1)
{
self.chat_cursor.1 = new_col;
}
}
FocusedPanel::Thinking => {
if let Some(new_col) = self
.find_word_end(self.thinking_cursor.0, self.thinking_cursor.1)
{
self.thinking_cursor.1 = new_col;
}
}
_ => {}
},
(KeyCode::Char('b'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(new_col) = self
.find_prev_word_boundary(self.chat_cursor.0, self.chat_cursor.1)
{
self.chat_cursor.1 = new_col;
}
}
FocusedPanel::Thinking => {
if let Some(new_col) = self.find_prev_word_boundary(
self.thinking_cursor.0,
self.thinking_cursor.1,
) {
self.thinking_cursor.1 = new_col;
}
}
_ => {}
},
(KeyCode::Char('^'), KeyModifiers::SHIFT) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
let first_non_blank =
line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
self.chat_cursor.1 = first_non_blank;
}
}
FocusedPanel::Thinking => {
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
let first_non_blank =
line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
self.thinking_cursor.1 = first_non_blank;
}
}
_ => {}
},
// Line start/end navigation
(KeyCode::Char('0'), KeyModifiers::NONE)
| (KeyCode::Home, KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
self.chat_cursor.1 = 0;
}
FocusedPanel::Thinking => {
self.thinking_cursor.1 = 0;
}
_ => {}
},
(KeyCode::Char('$'), KeyModifiers::NONE)
| (KeyCode::End, KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
self.chat_cursor.1 = line.chars().count();
}
}
FocusedPanel::Thinking => {
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
self.thinking_cursor.1 = line.chars().count();
}
}
_ => {}
},
// Half-page scrolling
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
self.scroll_half_page_down();
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
self.scroll_half_page_up();
}
// Full-page scrolling
(KeyCode::Char('f'), KeyModifiers::CONTROL)
| (KeyCode::PageDown, KeyModifiers::NONE) => {
self.scroll_full_page_down();
}
(KeyCode::Char('b'), KeyModifiers::CONTROL)
| (KeyCode::PageUp, KeyModifiers::NONE) => {
self.scroll_full_page_up();
}
// Jump to top/bottom
(KeyCode::Char('G'), KeyModifiers::SHIFT) => {
self.jump_to_bottom();
}
// Multi-key sequences
(KeyCode::Char('g'), KeyModifiers::NONE) => {
self.pending_key = Some('g');
self.status = "g".to_string();
}
(KeyCode::Char('d'), KeyModifiers::NONE) => {
self.pending_key = Some('d');
self.status = "d".to_string();
}
// Yank/paste (works from any panel)
(KeyCode::Char('p'), KeyModifiers::NONE) => {
if !self.clipboard.is_empty() {
// Always paste into Input panel
let current_lines = self.textarea.lines().to_vec();
let clipboard_lines: Vec<String> =
self.clipboard.lines().map(|s| s.to_string()).collect();
// Append clipboard content to current input
let mut new_lines = current_lines;
if new_lines.is_empty() || new_lines == vec![String::new()] {
new_lines = clipboard_lines;
} else {
// Add newline and append
new_lines.push(String::new());
new_lines.extend(clipboard_lines);
}
self.textarea = TextArea::new(new_lines);
configure_textarea_defaults(&mut self.textarea);
self.sync_textarea_to_buffer();
self.status = "Pasted into input".to_string();
}
}
// Panel switching
(KeyCode::Tab, KeyModifiers::NONE) => {
self.cycle_focus_forward();
let panel_name = match self.focused_panel {
FocusedPanel::Chat => "Chat",
FocusedPanel::Thinking => "Thinking",
FocusedPanel::Input => "Input",
};
self.status = format!("Focus: {}", panel_name);
}
(KeyCode::BackTab, KeyModifiers::SHIFT) => {
self.cycle_focus_backward();
let panel_name = match self.focused_panel {
FocusedPanel::Chat => "Chat",
FocusedPanel::Thinking => "Thinking",
FocusedPanel::Input => "Input",
};
self.status = format!("Focus: {}", panel_name);
}
(KeyCode::Esc, KeyModifiers::NONE) => {
self.pending_key = None;
self.mode = InputMode::Normal;
}
_ => {
self.pending_key = None;
}
}
}
InputMode::Editing => match (key.code, key.modifiers) {
(KeyCode::Esc, KeyModifiers::NONE) => {
// Sync textarea content to input buffer before leaving edit mode
self.sync_textarea_to_buffer();
self.mode = InputMode::Normal;
self.reset_status();
}
(KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.insert_newline();
}
(KeyCode::Enter, KeyModifiers::NONE) => {
// Send message and return to normal mode
self.sync_textarea_to_buffer();
self.send_user_message_and_request_response();
// Clear the textarea by setting it to empty
self.textarea = TextArea::default();
configure_textarea_defaults(&mut self.textarea);
self.mode = InputMode::Normal;
}
(KeyCode::Enter, _) => {
// Any Enter with modifiers keeps editing and inserts a newline via tui-textarea
self.textarea.input(Input::from(key));
}
// History navigation
(KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().history_previous();
self.sync_buffer_to_textarea();
}
(KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
}
// Vim-style navigation with Ctrl
(KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
}
(KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.move_cursor(tui_textarea::CursorMove::End);
}
(KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordForward);
}
(KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordBack);
}
(KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => {
// Redo - history next
self.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
}
_ => {
// Let tui-textarea handle all other input
self.textarea.input(Input::from(key));
}
},
InputMode::Visual => match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('v'), KeyModifiers::NONE) => {
// Cancel selection and return to normal mode
if matches!(self.focused_panel, FocusedPanel::Input) {
self.textarea.cancel_selection();
}
self.mode = InputMode::Normal;
self.visual_start = None;
self.visual_end = None;
self.reset_status();
}
(KeyCode::Char('y'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
// Yank selected text using tui-textarea's copy
self.textarea.copy();
// Get the yanked text from textarea's internal clipboard
let yanked = self.textarea.yank_text();
if !yanked.is_empty() {
self.clipboard = yanked;
self.status = format!("Yanked {} chars", self.clipboard.len());
} else {
// Fall back to yanking current line if no selection
let (row, _) = self.textarea.cursor();
if let Some(line) = self.textarea.lines().get(row) {
self.clipboard = line.clone();
self.status =
format!("Yanked line ({} chars)", self.clipboard.len());
}
}
self.textarea.cancel_selection();
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Yank selected lines from scrollable panels
if let Some(yanked) = self.yank_from_panel() {
self.clipboard = yanked;
self.status = format!("Yanked {} chars", self.clipboard.len());
} else {
self.status = "Nothing to yank".to_string();
}
}
}
self.mode = InputMode::Normal;
self.visual_start = None;
self.visual_end = None;
}
(KeyCode::Char('d'), KeyModifiers::NONE) | (KeyCode::Delete, _) => {
match self.focused_panel {
FocusedPanel::Input => {
// Cut (delete) selected text using tui-textarea's cut
if self.textarea.cut() {
// Get the cut text
let cut_text = self.textarea.yank_text();
self.clipboard = cut_text;
self.sync_textarea_to_buffer();
self.status = format!("Cut {} chars", self.clipboard.len());
} else {
self.status = "Nothing to cut".to_string();
}
self.textarea.cancel_selection();
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Can't delete from read-only panels, just yank
if let Some(yanked) = self.yank_from_panel() {
self.clipboard = yanked;
self.status = format!(
"Yanked {} chars (read-only panel)",
self.clipboard.len()
);
} else {
self.status = "Nothing to yank".to_string();
}
}
}
self.mode = InputMode::Normal;
self.visual_start = None;
self.visual_end = None;
}
// Movement keys to extend selection
(KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Back);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection left (decrease column)
if let Some((row, col)) = self.visual_end {
if col > 0 {
self.visual_end = Some((row, col - 1));
}
}
}
}
}
(KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Forward);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection right (increase column)
if let Some((row, col)) = self.visual_end {
self.visual_end = Some((row, col + 1));
}
}
}
}
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Up);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection up (decrease end row)
if let Some((row, col)) = self.visual_end {
if row > 0 {
self.visual_end = Some((row - 1, col));
// Scroll if needed to keep selection visible
self.on_scroll(-1);
}
}
}
}
}
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Down);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection down (increase end row)
if let Some((row, col)) = self.visual_end {
// Get max lines for the current panel
let max_lines =
if matches!(self.focused_panel, FocusedPanel::Chat) {
self.auto_scroll.content_len
} else {
self.thinking_scroll.content_len
};
if row + 1 < max_lines {
self.visual_end = Some((row + 1, col));
// Scroll if needed to keep selection visible
self.on_scroll(1);
}
}
}
}
}
(KeyCode::Char('w'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordForward);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection forward by word
if let Some((row, col)) = self.visual_end {
if let Some(new_col) = self.find_next_word_boundary(row, col) {
self.visual_end = Some((row, new_col));
}
}
}
}
}
(KeyCode::Char('b'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordBack);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection backward by word
if let Some((row, col)) = self.visual_end {
if let Some(new_col) = self.find_prev_word_boundary(row, col) {
self.visual_end = Some((row, new_col));
}
}
}
}
}
(KeyCode::Char('0'), KeyModifiers::NONE) | (KeyCode::Home, _) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection to start of line
if let Some((row, _)) = self.visual_end {
self.visual_end = Some((row, 0));
}
}
}
}
(KeyCode::Char('$'), KeyModifiers::NONE) | (KeyCode::End, _) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::End);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection to end of line
if let Some((row, _)) = self.visual_end {
if let Some(line) = self.get_line_at_row(row) {
let line_len = line.chars().count();
self.visual_end = Some((row, line_len));
}
}
}
}
}
_ => {
// Ignore all other input in visual mode (no typing allowed)
}
},
InputMode::Command => match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
self.mode = InputMode::Normal;
self.command_buffer.clear();
self.reset_status();
}
(KeyCode::Enter, _) => {
// Execute command
let cmd = self.command_buffer.trim();
match cmd {
"q" | "quit" => {
return Ok(AppState::Quit);
}
"c" | "clear" => {
self.controller.clear();
self.status = "Conversation cleared".to_string();
}
"w" | "write" => {
// Could implement saving conversation here
self.status = "Conversation saved".to_string();
}
"h" | "help" => {
self.mode = InputMode::Help;
self.command_buffer.clear();
return Ok(AppState::Running);
}
"m" | "model" => {
self.refresh_models().await?;
self.mode = InputMode::ProviderSelection;
self.command_buffer.clear();
return Ok(AppState::Running);
}
"n" | "new" => {
self.controller.start_new_conversation(None, None);
self.status = "Started new conversation".to_string();
}
_ => {
self.error = Some(format!("Unknown command: {}", cmd));
}
}
self.command_buffer.clear();
self.mode = InputMode::Normal;
}
(KeyCode::Char(c), KeyModifiers::NONE)
| (KeyCode::Char(c), KeyModifiers::SHIFT) => {
self.command_buffer.push(c);
self.status = format!(":{}", self.command_buffer);
}
(KeyCode::Backspace, _) => {
self.command_buffer.pop();
self.status = format!(":{}", self.command_buffer);
}
_ => {}
},
InputMode::ProviderSelection => match key.code {
KeyCode::Esc => {
self.mode = InputMode::Normal;
}
KeyCode::Enter => {
if let Some(provider) =
self.available_providers.get(self.selected_provider_index)
{
self.selected_provider = provider.clone();
self.sync_selected_model_index(); // Update model selection based on new provider
self.mode = InputMode::ModelSelection;
}
}
KeyCode::Up => {
if self.selected_provider_index > 0 {
self.selected_provider_index -= 1;
}
}
KeyCode::Down => {
if self.selected_provider_index + 1 < self.available_providers.len() {
self.selected_provider_index += 1;
}
}
_ => {}
},
InputMode::ModelSelection => match key.code {
KeyCode::Esc => {
self.mode = InputMode::Normal;
}
KeyCode::Enter => {
if let Some(selected_model_idx) = self.selected_model {
let filtered_models = self.models();
if let Some(model) = filtered_models.get(selected_model_idx) {
let model_id = model.id.clone();
let model_name = model.name.clone();
self.controller.set_model(model_id.clone());
self.status = format!("Using model: {}", model_name);
// Save the selected provider and model to config
self.controller.config_mut().general.default_model =
Some(model_id.clone());
self.controller.config_mut().general.default_provider =
self.selected_provider.clone();
match config::save_config(self.controller.config()) {
Ok(_) => self.error = None,
Err(err) => {
self.error =
Some(format!("Failed to save config: {}", err));
}
}
}
}
self.mode = InputMode::Normal;
}
KeyCode::Up => {
if let Some(selected_model_idx) = self.selected_model {
if selected_model_idx > 0 {
self.selected_model = Some(selected_model_idx - 1);
}
}
}
KeyCode::Down => {
if let Some(selected_model_idx) = self.selected_model {
if selected_model_idx + 1 < self.models().len() {
self.selected_model = Some(selected_model_idx + 1);
}
}
}
_ => {}
},
InputMode::Help => match key.code {
KeyCode::Esc | KeyCode::Enter => {
self.mode = InputMode::Normal;
}
_ => {}
},
},
_ => {}
}
Ok(AppState::Running)
}
/// Call this when processing scroll up/down keys
pub fn on_scroll(&mut self, delta: isize) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.on_user_scroll(delta, self.viewport_height);
}
FocusedPanel::Thinking => {
// Ensure we have a valid viewport height
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.on_user_scroll(delta, viewport_height);
}
FocusedPanel::Input => {
// Input panel doesn't scroll
}
}
}
/// Scroll down half page
pub fn scroll_half_page_down(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.scroll_half_page_down(self.viewport_height);
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.scroll_half_page_down(viewport_height);
}
FocusedPanel::Input => {}
}
}
/// Scroll up half page
pub fn scroll_half_page_up(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.scroll_half_page_up(self.viewport_height);
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.scroll_half_page_up(viewport_height);
}
FocusedPanel::Input => {}
}
}
/// Scroll down full page
pub fn scroll_full_page_down(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.scroll_full_page_down(self.viewport_height);
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.scroll_full_page_down(viewport_height);
}
FocusedPanel::Input => {}
}
}
/// Scroll up full page
pub fn scroll_full_page_up(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.scroll_full_page_up(self.viewport_height);
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.scroll_full_page_up(viewport_height);
}
FocusedPanel::Input => {}
}
}
/// Jump to top of focused panel
pub fn jump_to_top(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.jump_to_top();
}
FocusedPanel::Thinking => {
self.thinking_scroll.jump_to_top();
}
FocusedPanel::Input => {}
}
}
/// Jump to bottom of focused panel
pub fn jump_to_bottom(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.jump_to_bottom(self.viewport_height);
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.jump_to_bottom(viewport_height);
}
FocusedPanel::Input => {}
}
}
pub fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> {
match event {
SessionEvent::StreamChunk {
message_id,
response,
} => {
self.controller.apply_stream_chunk(message_id, &response)?;
// Update thinking content in real-time during streaming
self.update_thinking_from_last_message();
// Auto-scroll will handle this in the render loop
if response.is_final {
self.streaming.remove(&message_id);
self.stop_loading_animation();
self.status = "Ready".to_string();
}
}
SessionEvent::StreamError { message } => {
self.stop_loading_animation();
self.error = Some(message);
}
}
Ok(())
}
fn reset_status(&mut self) {
self.status = "Ready".to_string();
self.error = None;
}
async fn refresh_models(&mut self) -> Result<()> {
let config_model_name = self.controller.config().general.default_model.clone();
let config_model_provider = self.controller.config().general.default_provider.clone();
let all_models = self.controller.models(true).await?;
if all_models.is_empty() {
self.error = Some("No models available".to_string());
} else {
self.models = all_models;
// Populate available_providers
let providers = self
.models
.iter()
.map(|m| m.provider.clone())
.collect::<HashSet<_>>();
self.available_providers = providers.into_iter().collect();
self.available_providers.sort();
// Set selected_provider based on config, or default to "ollama" if not found
self.selected_provider = self
.available_providers
.iter()
.find(|&p| p == &config_model_provider)
.cloned()
.unwrap_or_else(|| "ollama".to_string());
self.selected_provider_index = self
.available_providers
.iter()
.position(|p| p == &self.selected_provider)
.unwrap_or(0);
self.controller.ensure_default_model(&self.models);
self.sync_selected_model_index();
let current_model_name = self.controller.selected_model().to_string();
let current_model_provider = self.controller.config().general.default_provider.clone();
if config_model_name.as_deref() != Some(&current_model_name)
|| config_model_provider != current_model_provider
{
if let Err(err) = config::save_config(self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
} else {
self.error = None;
}
}
self.status = format!("Loaded {} models", self.models.len());
}
Ok(())
}
fn send_user_message_and_request_response(&mut self) {
let content = self.controller.input_buffer().text().trim().to_string();
if content.is_empty() {
self.error = Some("Cannot send empty message".to_string());
return;
}
// Step 1: Add user message to conversation immediately (synchronous)
let message = self.controller.input_buffer_mut().commit_to_history();
self.controller
.conversation_mut()
.push_user_message(message.clone());
// Auto-scroll to bottom when sending a message
self.auto_scroll.stick_to_bottom = true;
// Step 2: Set flag to process LLM request on next event loop iteration
self.pending_llm_request = true;
self.status = "Message sent".to_string();
self.error = None;
}
pub async fn process_pending_llm_request(&mut self) -> Result<()> {
if !self.pending_llm_request {
return Ok(());
}
self.pending_llm_request = false;
// Step 1: Show loading model status and start animation
self.status = format!("Loading model '{}'...", self.controller.selected_model());
self.start_loading_animation();
let parameters = ChatParameters {
stream: self.controller.config().general.enable_streaming,
..Default::default()
};
// Step 2: Start the actual request
match self
.controller
.send_request_with_current_conversation(parameters)
.await
{
Ok(SessionOutcome::Complete(_response)) => {
self.stop_loading_animation();
self.status = "Ready".to_string();
self.error = None;
Ok(())
}
Ok(SessionOutcome::Streaming {
response_id,
stream,
}) => {
// Step 3: Model loaded, now generating response
self.status = "Generating response...".to_string();
self.spawn_stream(response_id, stream);
match self.controller.mark_stream_placeholder(response_id, "") {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!("Could not set response placeholder: {}", err));
}
}
Ok(())
}
Err(err) => {
let message = err.to_string();
if message.to_lowercase().contains("not found") {
self.error = Some(
"Model not available. Press 'm' to pick another installed model."
.to_string(),
);
self.status = "Model unavailable".to_string();
let _ = self.refresh_models().await;
self.mode = InputMode::ProviderSelection;
} else {
self.error = Some(message);
self.status = "Request failed".to_string();
}
self.stop_loading_animation();
Ok(())
}
}
}
fn sync_selected_model_index(&mut self) {
let current_model_id = self.controller.selected_model().to_string();
let filtered_models: Vec<&ModelInfo> = self
.models
.iter()
.filter(|m| m.provider == self.selected_provider)
.collect();
if filtered_models.is_empty() {
self.selected_model = None;
return;
}
if let Some(idx) = filtered_models
.iter()
.position(|m| m.id == current_model_id)
{
self.selected_model = Some(idx);
} else {
// If the current model is not in the filtered list, select the first one
self.selected_model = Some(0);
if let Some(model) = filtered_models.first() {
self.controller.set_model(model.id.clone());
// Also update the config with the new model and provider
self.controller.config_mut().general.default_model = Some(model.id.clone());
self.controller.config_mut().general.default_provider =
self.selected_provider.clone();
if let Err(err) = config::save_config(self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
}
}
}
}
pub fn set_viewport_dimensions(&mut self, height: usize, content_width: usize) {
self.viewport_height = height;
self.content_width = content_width;
}
pub fn set_thinking_viewport_height(&mut self, height: usize) {
self.thinking_viewport_height = height;
}
pub fn start_loading_animation(&mut self) {
self.is_loading = true;
self.loading_animation_frame = 0;
}
pub fn stop_loading_animation(&mut self) {
self.is_loading = false;
}
pub fn advance_loading_animation(&mut self) {
if self.is_loading {
self.loading_animation_frame = (self.loading_animation_frame + 1) % 8;
// 8-frame animation
}
}
pub fn get_loading_indicator(&self) -> &'static str {
if !self.is_loading {
return "";
}
match self.loading_animation_frame {
0 => "",
1 => "",
2 => "",
3 => "",
4 => "",
5 => "",
6 => "",
7 => "",
_ => "",
}
}
pub fn current_thinking(&self) -> Option<&String> {
self.current_thinking.as_ref()
}
pub fn get_rendered_lines(&self) -> Vec<String> {
match self.focused_panel {
FocusedPanel::Chat => {
// This should match exactly what render_messages produces
let conversation = self.conversation();
let formatter = self.formatter();
let mut lines = Vec::new();
for (message_index, message) in conversation.messages.iter().enumerate() {
let role = &message.role;
let (emoji, name) = match role {
Role::User => ("👤 ", "You: "),
Role::Assistant => ("🤖 ", "Assistant: "),
Role::System => ("⚙️ ", "System: "),
};
let content_to_display = if matches!(role, Role::Assistant) {
let (content_without_think, _) =
formatter.extract_thinking(&message.content);
content_without_think
} else {
message.content.clone()
};
// Add role label line
lines.push(format!("{}{}", emoji, name));
// Add content lines with indent
for line in content_to_display.trim().lines() {
lines.push(format!(" {}", line));
}
// Add separator except after last message
if message_index < conversation.messages.len() - 1 {
lines.push(String::new());
}
}
lines
}
FocusedPanel::Thinking => {
if let Some(thinking) = &self.current_thinking {
thinking.lines().map(|s| s.to_string()).collect()
} else {
Vec::new()
}
}
FocusedPanel::Input => Vec::new(),
}
}
fn get_line_at_row(&self, row: usize) -> Option<String> {
self.get_rendered_lines().get(row).cloned()
}
fn find_next_word_boundary(&self, row: usize, col: usize) -> Option<usize> {
let line = self.get_line_at_row(row)?;
owlen_core::ui::find_next_word_boundary(&line, col)
}
fn find_word_end(&self, row: usize, col: usize) -> Option<usize> {
let line = self.get_line_at_row(row)?;
owlen_core::ui::find_word_end(&line, col)
}
fn find_prev_word_boundary(&self, row: usize, col: usize) -> Option<usize> {
let line = self.get_line_at_row(row)?;
owlen_core::ui::find_prev_word_boundary(&line, col)
}
fn yank_from_panel(&self) -> Option<String> {
let (start_pos, end_pos) = if let (Some(s), Some(e)) = (self.visual_start, self.visual_end)
{
// Normalize selection
if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) {
(s, e)
} else {
(e, s)
}
} else {
return None;
};
let lines = self.get_rendered_lines();
owlen_core::ui::extract_text_from_selection(&lines, start_pos, end_pos)
}
pub fn update_thinking_from_last_message(&mut self) {
// Extract thinking from the last assistant message
if let Some(last_msg) = self
.conversation()
.messages
.iter()
.rev()
.find(|m| matches!(m.role, Role::Assistant))
{
let (_, thinking) = self.formatter().extract_thinking(&last_msg.content);
// Only set stick_to_bottom if content actually changed (to enable auto-scroll during streaming)
let content_changed = self.current_thinking != thinking;
self.current_thinking = thinking;
if content_changed {
// Auto-scroll thinking panel to bottom when content updates
self.thinking_scroll.stick_to_bottom = true;
}
} else {
self.current_thinking = None;
// If thinking panel was focused but thinking disappeared, switch to Chat
if matches!(self.focused_panel, FocusedPanel::Thinking) {
self.focused_panel = FocusedPanel::Chat;
}
}
}
fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) {
let sender = self.session_tx.clone();
self.streaming.insert(message_id);
tokio::spawn(async move {
use futures_util::StreamExt;
while let Some(item) = stream.next().await {
match item {
Ok(response) => {
if sender
.send(SessionEvent::StreamChunk {
message_id,
response,
})
.is_err()
{
break;
}
}
Err(e) => {
let _ = sender.send(SessionEvent::StreamError {
message: e.to_string(),
});
break;
}
}
}
});
}
}
fn configure_textarea_defaults(textarea: &mut TextArea<'static>) {
textarea.set_placeholder_text("Type your message here...");
textarea.set_tab_length(4);
textarea.set_style(
Style::default()
.remove_modifier(Modifier::UNDERLINED)
.remove_modifier(Modifier::ITALIC)
.remove_modifier(Modifier::BOLD),
);
textarea.set_cursor_style(Style::default());
textarea.set_cursor_line_style(Style::default());
}