@setoelkahfi / sigit / commits / 66617a1

Move TUI code into a Unix-only module for clarity

paydii committed Apr 26, 2026 at 13:15 UTC 66617a187ee002f5615c5453186964f61485f21d
1 file changed +1382 -1435
src/chat.rs
+1382 -1435
index 30efc13..ea23b4c 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -10,26 +10,6 @@ //! completion or failure. //! 2. **Chat phase** — normal interactive chat once `load_rx` resolves. -use std::future::pending; -use std::sync::Arc; -use std::sync::mpsc as std_mpsc; - -use anyhow::Result; -use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use futures::StreamExt; -use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult}; - -use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Position}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, -}; -use tokio::sync::mpsc; -use tokio::time::{Duration, Instant, interval}; - // ── Think-block stripping ───────────────────────────────────────────────────── /// Strip `<think>…</think>` blocks from a model response. @@ -68,145 +48,167 @@ pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) { (thinking, remainder.trim().to_string()) } -// ── Message types ───────────────────────────────────────────────────────────── +// ── Unix-only TUI ───────────────────────────────────────────────────────────── +// +// Everything below this point is compiled only on Unix (macOS + Linux). +// Windows supports ACP mode only; the interactive TUI is not available there. #[cfg(unix)] -#[derive(Clone, Copy, PartialEq, Eq)] -enum Role { - User, - Assistant, - System, - /// Banner art — each character gets its own color. - Banner, -} +mod tui { + use std::future::pending; + use std::sync::Arc; + use std::sync::mpsc as std_mpsc; + + use anyhow::Result; + use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + use futures::StreamExt; + use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult}; + + use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items}; + use ratatui::{ + Frame, + layout::{Constraint, Layout, Position}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + }; + use tokio::sync::mpsc; + use tokio::time::{Duration, Instant, interval}; + + // ── Message types ───────────────────────────────────────────────────────── + + #[derive(Clone, Copy, PartialEq, Eq)] + enum Role { + User, + Assistant, + System, + /// Banner art — each character gets its own color. + Banner, + } -#[cfg(unix)] -struct ChatMessage { - role: Role, - text: String, - /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning). - think_block: Option<String>, -} + struct ChatMessage { + role: Role, + text: String, + /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning). + think_block: Option<String>, + } -#[cfg(unix)] -impl ChatMessage { - fn user(text: impl Into<String>) -> Self { - Self { - role: Role::User, - text: text.into(), - think_block: None, + impl ChatMessage { + fn user(text: impl Into<String>) -> Self { + Self { + role: Role::User, + text: text.into(), + think_block: None, + } } - } - fn assistant(text: impl Into<String>) -> Self { - let raw = text.into(); - let (think, visible) = strip_think_blocks(&raw); - Self { - role: Role::Assistant, - text: visible, - think_block: if think.is_empty() { None } else { Some(think) }, + fn assistant(text: impl Into<String>) -> Self { + let raw = text.into(); + let (think, visible) = super::strip_think_blocks(&raw); + Self { + role: Role::Assistant, + text: visible, + think_block: if think.is_empty() { None } else { Some(think) }, + } } - } - fn system(text: impl Into<String>) -> Self { - Self { - role: Role::System, - text: text.into(), - think_block: None, + fn system(text: impl Into<String>) -> Self { + Self { + role: Role::System, + text: text.into(), + think_block: None, + } } - } - fn banner(text: impl Into<String>) -> Self { - Self { - role: Role::Banner, - text: text.into(), - think_block: None, + fn banner(text: impl Into<String>) -> Self { + Self { + role: Role::Banner, + text: text.into(), + think_block: None, + } } } -} -// ── Inference updates from background task ─────────────────────────────────── + // ── Inference updates from background task ──────────────────────────────── -/// Messages sent from the spawned inference task back to the event loop. -#[cfg(unix)] -enum InferenceUpdate { - /// The model is calling a tool — show its name in the chat. - ToolUse(String), - /// The model produced a final text response. - Response(String), - /// Something went wrong during inference. - Error(String), -} + /// Messages sent from the spawned inference task back to the event loop. + enum InferenceUpdate { + /// The model is calling a tool — show its name in the chat. + ToolUse(String), + /// The model produced a final text response. + Response(String), + /// Something went wrong during inference. + Error(String), + } -#[cfg(unix)] -enum ModelLoadUpdate { - Loaded(String), - Error(String), -} + enum ModelLoadUpdate { + Loaded(String), + Error(String), + } -// ── App state ───────────────────────────────────────────────────────────────── - -struct App { - messages: Vec<ChatMessage>, - input: String, - cursor: usize, - scroll_offset: u16, - stream_rx: Option<mpsc::Receiver<StreamChunk>>, - stream_buf: String, - /// Channel for receiving results from the background inference task. - inference_rx: Option<mpsc::Receiver<InferenceUpdate>>, - /// Channel for receiving results from a model switch. - model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>, - /// True while waiting for inference to finish. - thinking: bool, - /// Counter driving the thinking spinner animation. - thinking_tick: u8, - quit: bool, - /// Flips every few ticks while streaming to make the cursor blink. - blink_on: bool, - blink_counter: u8, - /// True while a model switch is in progress. - switching_model: bool, - /// Tool-calling flag for the model currently being loaded in the background. - /// Applied to `app.tool_calling` when `ModelLoadUpdate::Loaded` arrives. - pending_tool_calling: Option<bool>, - /// Set to true when the user cancels a model switch with Ctrl+C. - /// Suppresses the "loader task disconnected" error message that would - /// otherwise appear when we drop model_load_rx to abort the switch. - model_load_cancelled: bool, - - // ── Loading-phase state ─────────────────────────────────────────────────── - /// True while the model is still loading; switches to false on completion. - is_loading: bool, - /// Monotonic counter incremented on every animation tick. Drives the - /// braille spinner shown during loading. - load_tick: u32, - /// Set when model loading fails; keeps the loading view up with the error. - load_error: Option<String>, - /// When loading started — drives the elapsed-time counter. - load_start: Instant, - /// Display name of the model being loaded (shown in the spinner line). - load_model_name: String, - - // ── Model picker state ──────────────────────────────────────────────────── - show_model_picker: bool, - model_picker_index: usize, - model_picker_items: Vec<ModelPickerItem>, - current_model_name: String, - /// Whether the currently loaded model supports tool calling. - tool_calling: bool, - - // ── Model-switch download progress ──────────────────────────────────────── - /// The model_id of the model currently being downloaded/switched to. - /// `None` when no switch is in progress. - switching_model_id: Option<String>, - /// Bytes on disk / expected bytes for the in-progress download. - /// Updated every 100 ms tick while `switching_model` is true and the - /// selected model was not yet cached. - download_progress: Option<(u64, u64)>, -} + // ── App state ───────────────────────────────────────────────────────────── + + struct App { + messages: Vec<ChatMessage>, + input: String, + cursor: usize, + scroll_offset: u16, + stream_rx: Option<mpsc::Receiver<StreamChunk>>, + stream_buf: String, + /// Channel for receiving results from the background inference task. + inference_rx: Option<mpsc::Receiver<InferenceUpdate>>, + /// Channel for receiving results from a model switch. + model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>, + /// True while waiting for inference to finish. + thinking: bool, + /// Counter driving the thinking spinner animation. + thinking_tick: u8, + quit: bool, + /// Flips every few ticks while streaming to make the cursor blink. + blink_on: bool, + blink_counter: u8, + /// True while a model switch is in progress. + switching_model: bool, + /// Tool-calling flag for the model currently being loaded in the background. + /// Applied to `app.tool_calling` when `ModelLoadUpdate::Loaded` arrives. + pending_tool_calling: Option<bool>, + /// Set to true when the user cancels a model switch with Ctrl+C. + /// Suppresses the "loader task disconnected" error message that would + /// otherwise appear when we drop model_load_rx to abort the switch. + model_load_cancelled: bool, + + // ── Loading-phase state ─────────────────────────────────────────────── + /// True while the model is still loading; switches to false on completion. + is_loading: bool, + /// Monotonic counter incremented on every animation tick. Drives the + /// braille spinner shown during loading. + load_tick: u32, + /// Set when model loading fails; keeps the loading view up with the error. + load_error: Option<String>, + /// When loading started — drives the elapsed-time counter. + load_start: Instant, + /// Display name of the model being loaded (shown in the spinner line). + load_model_name: String, + + // ── Model picker state ──────────────────────────────────────────────── + show_model_picker: bool, + model_picker_index: usize, + model_picker_items: Vec<ModelPickerItem>, + current_model_name: String, + /// Whether the currently loaded model supports tool calling. + tool_calling: bool, + + // ── Model-switch download progress ──────────────────────────────────── + /// The model_id of the model currently being downloaded/switched to. + /// `None` when no switch is in progress. + switching_model_id: Option<String>, + /// Bytes on disk / expected bytes for the in-progress download. + /// Updated every 100 ms tick while `switching_model` is true and the + /// selected model was not yet cached. + download_progress: Option<(u64, u64)>, + } -const BANNER_ART: &str = "\ + const BANNER_ART: &str = "\ 77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777 77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777 1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111 @@ -221,491 +223,492 @@ const BANNER_ART: &str = "\ 55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888"; -/// Spinner frames for the "thinking" animation. -const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -impl App { - fn new(load_model_name: String) -> Self { - let items = build_model_picker_items(); - let tool_calling = items - .iter() - .find(|m| m.display_name == load_model_name) - .map(|m| m.tool_calling) - .unwrap_or(true); - Self { - messages: Vec::new(), - input: String::new(), - cursor: 0, - scroll_offset: 0, - stream_rx: None, - stream_buf: String::new(), - inference_rx: None, - model_load_rx: None, - thinking: false, - thinking_tick: 0, - quit: false, - blink_on: true, - blink_counter: 0, - switching_model: false, - pending_tool_calling: None, - model_load_cancelled: false, - switching_model_id: None, - download_progress: None, - is_loading: true, - load_tick: 0, - load_error: None, - load_start: Instant::now(), - load_model_name: load_model_name.clone(), - show_model_picker: false, - model_picker_index: 0, - model_picker_items: items, - current_model_name: crate::setup::load_selected_model_name() - .unwrap_or_else(|| load_model_name.clone()), - tool_calling, + /// Spinner frames for the "thinking" animation. + const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + impl App { + fn new(load_model_name: String) -> Self { + let items = build_model_picker_items(); + let tool_calling = items + .iter() + .find(|m| m.display_name == load_model_name) + .map(|m| m.tool_calling) + .unwrap_or(true); + Self { + messages: Vec::new(), + input: String::new(), + cursor: 0, + scroll_offset: 0, + stream_rx: None, + stream_buf: String::new(), + inference_rx: None, + model_load_rx: None, + thinking: false, + thinking_tick: 0, + quit: false, + blink_on: true, + blink_counter: 0, + switching_model: false, + pending_tool_calling: None, + model_load_cancelled: false, + switching_model_id: None, + download_progress: None, + is_loading: true, + load_tick: 0, + load_error: None, + load_start: Instant::now(), + load_model_name: load_model_name.clone(), + show_model_picker: false, + model_picker_index: 0, + model_picker_items: items, + current_model_name: crate::setup::load_selected_model_name() + .unwrap_or_else(|| load_model_name.clone()), + tool_calling, + } } - } - /// True when either streaming tokens or waiting for inference. - fn is_busy(&self) -> bool { - self.is_streaming() || self.thinking || self.switching_model - } + /// True when either streaming tokens or waiting for inference. + fn is_busy(&self) -> bool { + self.is_streaming() || self.thinking || self.switching_model + } - fn switching_frame(&self) -> &'static str { - let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len(); - THINKING_FRAMES[idx] - } + fn switching_frame(&self) -> &'static str { + let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len(); + THINKING_FRAMES[idx] + } - fn is_streaming(&self) -> bool { - self.stream_rx.is_some() - } + fn is_streaming(&self) -> bool { + self.stream_rx.is_some() + } - fn finalize_stream(&mut self) { - self.stream_rx = None; - if !self.stream_buf.is_empty() { - let text = std::mem::take(&mut self.stream_buf); - self.messages.push(ChatMessage::assistant(text)); + fn finalize_stream(&mut self) { + self.stream_rx = None; + if !self.stream_buf.is_empty() { + let text = std::mem::take(&mut self.stream_buf); + self.messages.push(ChatMessage::assistant(text)); + } + self.blink_on = false; } - self.blink_on = false; - } - fn push_stream_delta(&mut self, delta: &str) { - self.stream_buf.push_str(delta); - self.blink_counter = self.blink_counter.wrapping_add(1); - self.blink_on = self.blink_counter % 4 < 2; - } + fn push_stream_delta(&mut self, delta: &str) { + self.stream_buf.push_str(delta); + self.blink_counter = self.blink_counter.wrapping_add(1); + self.blink_on = self.blink_counter % 4 < 2; + } - fn start_thinking(&mut self) { - self.thinking = true; - self.thinking_tick = 0; - } + fn start_thinking(&mut self) { + self.thinking = true; + self.thinking_tick = 0; + } - fn stop_thinking(&mut self) { - self.thinking = false; - self.inference_rx = None; - } + fn stop_thinking(&mut self) { + self.thinking = false; + self.inference_rx = None; + } - fn tick_thinking(&mut self) { - self.thinking_tick = self.thinking_tick.wrapping_add(1); - } + fn tick_thinking(&mut self) { + self.thinking_tick = self.thinking_tick.wrapping_add(1); + } - fn thinking_frame(&self) -> &'static str { - let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len(); - THINKING_FRAMES[idx] - } + fn thinking_frame(&self) -> &'static str { + let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len(); + THINKING_FRAMES[idx] + } - /// Advance the spinner tick counter. - fn tick(&mut self) { - self.load_tick = self.load_tick.wrapping_add(1); - } + /// Advance the spinner tick counter. + fn tick(&mut self) { + self.load_tick = self.load_tick.wrapping_add(1); + } - /// Poll the HF cache directory for the model being switched to and update - /// `download_progress`. Called on every 100 ms tick while switching. - fn poll_download_progress(&mut self) { - let Some(ref model_id) = self.switching_model_id else { - return; - }; - let cache_path = onde::hf_cache::model_cache_path(model_id); - let downloaded = cache_path - .as_ref() - .filter(|p| p.exists()) - .map(|p| dir_size_recursive(p)) - .unwrap_or(0); - let expected = onde::inference::models::SUPPORTED_MODEL_INFO - .iter() - .find(|m| m.id == model_id.as_str()) - .map(|m| m.expected_size_bytes) - .unwrap_or(0); - self.download_progress = Some((downloaded, expected)); - } + /// Poll the HF cache directory for the model being switched to and update + /// `download_progress`. Called on every 100 ms tick while switching. + fn poll_download_progress(&mut self) { + let Some(ref model_id) = self.switching_model_id else { + return; + }; + let cache_path = onde::hf_cache::model_cache_path(model_id); + let downloaded = cache_path + .as_ref() + .filter(|p| p.exists()) + .map(|p| dir_size_recursive(p)) + .unwrap_or(0); + let expected = onde::inference::models::SUPPORTED_MODEL_INFO + .iter() + .find(|m| m.id == model_id.as_str()) + .map(|m| m.expected_size_bytes) + .unwrap_or(0); + self.download_progress = Some((downloaded, expected)); + } - /// Transition from loading phase to normal chat. - /// Adds the banner art and welcome messages to the message log. - fn finish_loading(&mut self) { - self.is_loading = false; - for line in BANNER_ART.lines() { - self.messages.push(ChatMessage::banner(line)); + /// Transition from loading phase to normal chat. + /// Adds the banner art and welcome messages to the message log. + fn finish_loading(&mut self) { + self.is_loading = false; + for line in BANNER_ART.lines() { + self.messages.push(ChatMessage::banner(line)); + } + self.messages.push(ChatMessage::system("")); + self.messages.push(ChatMessage::system( + "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit", + )); + self.messages.push(ChatMessage::system(format!( + "Current model: {}", + self.current_model_name + ))); + self.messages + .push(ChatMessage::system("Type /help for commands.")); } - self.messages.push(ChatMessage::system("")); - self.messages.push(ChatMessage::system( - "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit", - )); - self.messages.push(ChatMessage::system(format!( - "Current model: {}", - self.current_model_name - ))); - self.messages - .push(ChatMessage::system("Type /help for commands.")); - } - /// Record a loading error. The loading view stays visible so the user can - /// read the message before pressing Ctrl+C. - fn set_load_error(&mut self, error: String) { - self.load_error = Some(error); - // is_loading stays true so render_loading() keeps rendering. - } + /// Record a loading error. The loading view stays visible so the user can + /// read the message before pressing Ctrl+C. + fn set_load_error(&mut self, error: String) { + self.load_error = Some(error); + // is_loading stays true so render_loading() keeps rendering. + } - fn open_model_picker(&mut self, engine: &ChatEngine) { - let current = crate::setup::load_selected_model(); - let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| { - futures::executor::block_on(engine.info()) - .model_name - .unwrap_or_else(|| self.current_model_name.clone()) - }); + fn open_model_picker(&mut self, engine: &ChatEngine) { + let current = crate::setup::load_selected_model(); + let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| { + futures::executor::block_on(engine.info()) + .model_name + .unwrap_or_else(|| self.current_model_name.clone()) + }); - self.model_picker_items = build_model_picker_items(); - self.model_picker_index = current - .as_ref() - .and_then(|selected| { - self.model_picker_items.iter().position(|item| { - item.config.model_id == selected.model_id - && item - .config - .files - .iter() - .any(|file| file == &selected.gguf_file) + self.model_picker_items = build_model_picker_items(); + self.model_picker_index = current + .as_ref() + .and_then(|selected| { + self.model_picker_items.iter().position(|item| { + item.config.model_id == selected.model_id + && item + .config + .files + .iter() + .any(|file| file == &selected.gguf_file) + }) }) - }) - .or_else(|| { - self.model_picker_items - .iter() - .position(|item| item.display_name == current_name) - }) - .unwrap_or(0); - self.show_model_picker = true; - } - - fn close_model_picker(&mut self) { - self.show_model_picker = false; - } - - fn move_model_picker_up(&mut self) { - if self.model_picker_items.is_empty() { - return; - } - if self.model_picker_index == 0 { - self.model_picker_index = self.model_picker_items.len().saturating_sub(1); - } else { - self.model_picker_index -= 1; + .or_else(|| { + self.model_picker_items + .iter() + .position(|item| item.display_name == current_name) + }) + .unwrap_or(0); + self.show_model_picker = true; } - } - fn move_model_picker_down(&mut self) { - if self.model_picker_items.is_empty() { - return; + fn close_model_picker(&mut self) { + self.show_model_picker = false; } - self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len(); - } - /// Total lines the messages area would need (rough estimate for scrolling). - fn total_message_lines(&self, width: u16) -> u16 { - if width == 0 { - return 0; - } - let w = width.saturating_sub(2) as usize; // subtract border columns - let mut lines: u16 = 0; - for msg in &self.messages { - lines += wrapped_line_count(&msg.text, msg.role, w); + fn move_model_picker_up(&mut self) { + if self.model_picker_items.is_empty() { + return; + } + if self.model_picker_index == 0 { + self.model_picker_index = self.model_picker_items.len().saturating_sub(1); + } else { + self.model_picker_index -= 1; + } } - // count any in-progress streaming text too - if !self.stream_buf.is_empty() { - lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w); + + fn move_model_picker_down(&mut self) { + if self.model_picker_items.is_empty() { + return; + } + self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len(); } - // thinking / switching indicator - if self.thinking || self.switching_model { - lines += 1; + + /// Total lines the messages area would need (rough estimate for scrolling). + fn total_message_lines(&self, width: u16) -> u16 { + if width == 0 { + return 0; + } + let w = width.saturating_sub(2) as usize; // subtract border columns + let mut lines: u16 = 0; + for msg in &self.messages { + lines += wrapped_line_count(&msg.text, msg.role, w); + } + // count any in-progress streaming text too + if !self.stream_buf.is_empty() { + lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w); + } + // thinking / switching indicator + if self.thinking || self.switching_model { + lines += 1; + } + lines } - lines - } - fn auto_scroll(&mut self, visible_height: u16, width: u16) { - let total = self.total_message_lines(width); - if total > visible_height { - self.scroll_offset = total - visible_height; - } else { - self.scroll_offset = 0; + fn auto_scroll(&mut self, visible_height: u16, width: u16) { + let total = self.total_message_lines(width); + if total > visible_height { + self.scroll_offset = total - visible_height; + } else { + self.scroll_offset = 0; + } } } -} - -/// How many terminal rows a message takes up after line-wrapping. -fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { - let prefix_len = match role { - Role::User => 6, // "you > " - Role::Assistant => 8, // "siGit > " - Role::System | Role::Banner => 0, - }; - let effective = if width > prefix_len { - width - prefix_len - } else { - 1 - }; - let mut count: u16 = 0; - for line in text.split('\n') { - if line.is_empty() { - count += 1; + /// How many terminal rows a message takes up after line-wrapping. + fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { + let prefix_len = match role { + Role::User => 6, // "you > " + Role::Assistant => 8, // "siGit > " + Role::System | Role::Banner => 0, + }; + let effective = if width > prefix_len { + width - prefix_len } else { - count += ((line.len() as f64) / (effective as f64)).ceil() as u16; + 1 + }; + + let mut count: u16 = 0; + for line in text.split('\n') { + if line.is_empty() { + count += 1; + } else { + count += ((line.len() as f64) / (effective as f64)).ceil() as u16; + } } + count.max(1) } - count.max(1) -} -// ── Model table ────────────────────────────────────────────────────────────── + // ── Model table ────────────────────────────────────────────────────────── + // + // ModelSource, ModelPickerItem, and build_model_picker_items live in + // crate::models so they are available on all platforms (including Windows), + // not just unix where this chat module is compiled. -// ModelSource, ModelPickerItem, and build_model_picker_items live in -// crate::models so they are available on all platforms (including Windows), -// not just unix where this chat module is compiled. + fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let popup = centered_rect(82, 72, area); -fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let popup = centered_rect(82, 72, area); + // Erase whatever is behind the popup so the panel is fully readable. + frame.render_widget(Clear, popup); - // Erase whatever is behind the popup so the panel is fully readable. - frame.render_widget(Clear, popup); + let block = Block::default() + .title(" Select a model… ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .style(Style::default().bg(Color::Black)); - let block = Block::default() - .title(" Select a model… ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) - .style(Style::default().bg(Color::Black)); + let inner = block.inner(popup); + frame.render_widget(block, popup); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let mut lines = Vec::new(); + let mut last_section: Option<ModelSource> = None; - let mut lines = Vec::new(); - let mut last_section: Option<ModelSource> = None; + for (index, item) in app.model_picker_items.iter().enumerate() { + if last_section != Some(item.source) { + if last_section.is_some() { + lines.push(Line::from("").style(Style::default().bg(Color::Black))); + } - for (index, item) in app.model_picker_items.iter().enumerate() { - if last_section != Some(item.source) { - if last_section.is_some() { - lines.push(Line::from("").style(Style::default().bg(Color::Black))); + let (section_mark, section_name, section_style) = match item.source { + ModelSource::Onde => ( + "◉", + "Onde Inference", + Style::default() + .fg(Color::Green) + .bg(Color::Black) + .add_modifier(Modifier::BOLD), + ), + ModelSource::HuggingFace => ( + "○", + "Hugging Face cache", + Style::default() + .fg(Color::Cyan) + .bg(Color::Black) + .add_modifier(Modifier::BOLD), + ), + ModelSource::Available => ( + "↓", + "Available for download", + Style::default() + .fg(Color::Blue) + .bg(Color::Black) + .add_modifier(Modifier::BOLD), + ), + ModelSource::Fallback => ( + "◎", + "Fallback", + Style::default() + .fg(Color::Yellow) + .bg(Color::Black) + .add_modifier(Modifier::BOLD), + ), + }; + + lines.push( + Line::from(vec![ + Span::styled(format!("{section_mark} "), section_style), + Span::styled(section_name, section_style), + ]) + .style(Style::default().bg(Color::Black)), + ); + last_section = Some(item.source); } - let (section_mark, section_name, section_style) = match item.source { - ModelSource::Onde => ( - "◉", - "Onde Inference", - Style::default() - .fg(Color::Green) - .bg(Color::Black) - .add_modifier(Modifier::BOLD), - ), - ModelSource::HuggingFace => ( - "○", - "Hugging Face cache", - Style::default() - .fg(Color::Cyan) - .bg(Color::Black) - .add_modifier(Modifier::BOLD), - ), - ModelSource::Available => ( - "↓", - "Available for download", - Style::default() - .fg(Color::Blue) - .bg(Color::Black) - .add_modifier(Modifier::BOLD), - ), - ModelSource::Fallback => ( - "◎", - "Fallback", - Style::default() - .fg(Color::Yellow) - .bg(Color::Black) - .add_modifier(Modifier::BOLD), - ), + let selected = index == app.model_picker_index; + let current = item.display_name == app.current_model_name; + let marker = if selected { "› " } else { " " }; + let tool_badge = if item.tool_calling { + " ✓ tool calling" + } else { + "" }; + let health_badge = match item.cache_health { + ModelCacheHealth::Complete => "", + ModelCacheHealth::Incomplete => " ! incomplete cache", + ModelCacheHealth::NotDownloaded => " ↓ download", + }; + let current_badge = if current { " ← current" } else { "" }; + let disabled_badge = match item.cache_health { + ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "", + ModelCacheHealth::Incomplete => " (unselectable)", + }; + let brand_mark = match item.source { + ModelSource::Onde => "◉", + ModelSource::HuggingFace => "○", + ModelSource::Available => "↓", + ModelSource::Fallback => "◎", + }; + let source = format!(" [{} {}]", brand_mark, item.source_label); - lines.push( - Line::from(vec![ - Span::styled(format!("{section_mark} "), section_style), - Span::styled(section_name, section_style), - ]) - .style(Style::default().bg(Color::Black)), - ); - last_section = Some(item.source); - } - - let selected = index == app.model_picker_index; - let current = item.display_name == app.current_model_name; - let marker = if selected { "› " } else { " " }; - let tool_badge = if item.tool_calling { - " ✓ tool calling" - } else { - "" - }; - let health_badge = match item.cache_health { - ModelCacheHealth::Complete => "", - ModelCacheHealth::Incomplete => " ! incomplete cache", - ModelCacheHealth::NotDownloaded => " ↓ download", - }; - let current_badge = if current { " ← current" } else { "" }; - let disabled_badge = match item.cache_health { - ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "", - ModelCacheHealth::Incomplete => " (unselectable)", - }; - let brand_mark = match item.source { - ModelSource::Onde => "◉", - ModelSource::HuggingFace => "○", - ModelSource::Available => "↓", - ModelSource::Fallback => "◎", - }; - let source = format!(" [{} {}]", brand_mark, item.source_label); + let base_style = if selected { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + Style::default().fg(Color::White).bg(Color::Black) + }; - let base_style = if selected { - Style::default().fg(Color::Black).bg(Color::Green) - } else { - Style::default().fg(Color::White).bg(Color::Black) - }; + let source_style = if selected { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + match item.source { + ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black), + ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black), + ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black), + ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black), + } + }; - let source_style = if selected { - Style::default().fg(Color::Black).bg(Color::Green) - } else { - match item.source { - ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black), - ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black), - ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black), - ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black), - } - }; + let health_style = if selected { + Style::default().fg(Color::Red).bg(Color::Green) + } else { + Style::default().fg(Color::Red).bg(Color::Black) + }; - let health_style = if selected { - Style::default().fg(Color::Red).bg(Color::Green) - } else { - Style::default().fg(Color::Red).bg(Color::Black) - }; + lines.push(Line::from(vec![ + Span::styled( + format!("{marker}{} {}", item.display_name, item.description), + base_style, + ), + Span::styled( + tool_badge.to_string(), + if selected { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + Style::default().fg(Color::Green).bg(Color::Black) + }, + ), + Span::styled(health_badge.to_string(), health_style), + Span::styled( + disabled_badge.to_string(), + if selected { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + Style::default().fg(Color::DarkGray).bg(Color::Black) + }, + ), + Span::styled( + current_badge.to_string(), + if selected { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + Style::default().fg(Color::Cyan).bg(Color::Black) + }, + ), + Span::styled(source, source_style), + ])); + } - lines.push(Line::from(vec![ - Span::styled( - format!("{marker}{} {}", item.display_name, item.description), - base_style, - ), - Span::styled( - tool_badge.to_string(), - if selected { - Style::default().fg(Color::Black).bg(Color::Green) - } else { - Style::default().fg(Color::Green).bg(Color::Black) - }, - ), - Span::styled(health_badge.to_string(), health_style), - Span::styled( - disabled_badge.to_string(), - if selected { - Style::default().fg(Color::Black).bg(Color::Green) - } else { - Style::default().fg(Color::DarkGray).bg(Color::Black) - }, - ), - Span::styled( - current_badge.to_string(), - if selected { - Style::default().fg(Color::Black).bg(Color::Green) - } else { - Style::default().fg(Color::Cyan).bg(Color::Black) - }, - ), - Span::styled(source, source_style), - ])); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .style(Style::default().bg(Color::Black)), + inner, + ); } - frame.render_widget( - Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .style(Style::default().bg(Color::Black)), - inner, - ); -} + fn centered_rect( + percent_x: u16, + percent_y: u16, + area: ratatui::layout::Rect, + ) -> ratatui::layout::Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); -fn centered_rect( - percent_x: u16, - percent_y: u16, - area: ratatui::layout::Rect, -) -> ratatui::layout::Rect { - let vertical = Layout::vertical([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - - Layout::horizontal([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vertical[1])[1] -} + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] + } -// ── Slash commands ──────────────────────────────────────────────────────────── + // ── Slash commands ──────────────────────────────────────────────────────── -enum SlashCommand { - Help, - Clear, - Status, - /// `/models` opens the model picker. `/models N` still works as a shortcut. - Models(Option<usize>), - Exit, - Unknown(String), -} + enum SlashCommand { + Help, + Clear, + Status, + /// `/models` opens the model picker. `/models N` still works as a shortcut. + Models(Option<usize>), + Exit, + Unknown(String), + } -fn parse_slash(input: &str) -> Option<SlashCommand> { - let trimmed = input.trim(); - if !trimmed.starts_with('/') { - return None; + fn parse_slash(input: &str) -> Option<SlashCommand> { + let trimmed = input.trim(); + if !trimmed.starts_with('/') { + return None; + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let cmd = parts.next().unwrap_or(""); + let arg = parts.next().map(|s| s.trim()); + Some(match cmd { + "/help" => SlashCommand::Help, + "/clear" => SlashCommand::Clear, + "/status" => SlashCommand::Status, + "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())), + "/exit" | "/quit" | "/q" => SlashCommand::Exit, + other => SlashCommand::Unknown(other.to_string()), + }) } - let mut parts = trimmed.splitn(2, char::is_whitespace); - let cmd = parts.next().unwrap_or(""); - let arg = parts.next().map(|s| s.trim()); - Some(match cmd { - "/help" => SlashCommand::Help, - "/clear" => SlashCommand::Clear, - "/status" => SlashCommand::Status, - "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())), - "/exit" | "/quit" | "/q" => SlashCommand::Exit, - other => SlashCommand::Unknown(other.to_string()), - }) -} -// ── Rendering ───────────────────────────────────────────────────────────────── + // ── Rendering ───────────────────────────────────────────────────────────── -fn render(frame: &mut Frame, app: &mut App) { - let area = frame.area(); + fn render(frame: &mut Frame, app: &mut App) { + let area = frame.area(); - if app.is_loading { - // Loading phase: title bar with spinner | loading info | footer hint. - let zones = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ]) - .split(area); + if app.is_loading { + // Loading phase: title bar with spinner | loading info | footer hint. + let zones = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + render_loading_title(frame, app, zones[0]); + render_loading(frame, app, zones[1]); + render_loading_footer(frame, zones[2]); + return; + } - render_loading_title(frame, app, zones[0]); - render_loading(frame, app, zones[1]); - render_loading_footer(frame, zones[2]); - } else { - // Chat phase: title | messages | input | footer. + // Normal chat phase: title | messages | input | footer. let zones = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), @@ -714,7 +717,7 @@ fn render(frame: &mut Frame, app: &mut App) { ]) .split(area); - render_title(frame, zones[0]); + render_title(frame, app, zones[0]); render_messages(frame, app, zones[1]); render_input(frame, app, zones[2]); render_footer(frame, app, zones[3]); @@ -723,1015 +726,959 @@ fn render(frame: &mut Frame, app: &mut App) { render_model_picker(frame, app, area); } } -} - -fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) { - let title = Line::from(vec![ - Span::styled( - "siGit", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" Code", Style::default().fg(Color::White)), - Span::styled( - format!(" v{}", env!("CARGO_PKG_VERSION")), - Style::default().fg(Color::DarkGray), - ), - ]); - frame.render_widget( - Paragraph::new(title).style(Style::default().bg(Color::Black)), - area, - ); -} - -/// Title bar during loading: `⠹ siGit Code v0.1.1` -fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { - const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let spinner = SPINNER[(app.load_tick as usize) % SPINNER.len()]; - let title = Line::from(vec![ - Span::styled( - format!("{spinner} "), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "siGit", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" Code", Style::default().fg(Color::White)), - Span::styled( - format!(" v{}", env!("CARGO_PKG_VERSION")), - Style::default().fg(Color::DarkGray), - ), - ]); - frame.render_widget( - Paragraph::new(title).style(Style::default().bg(Color::Black)), - area, - ); -} - -/// Loading body — model name, elapsed time, or error message. -fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let elapsed = app.load_start.elapsed(); - let elapsed_str = if elapsed.as_secs() >= 60 { - format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60) - } else { - format!("{}s", elapsed.as_secs()) - }; - - let mut lines: Vec<Line<'_>> = Vec::new(); - - if let Some(ref err) = app.load_error { - lines.push(Line::from(vec![ - Span::styled( - " ✘ ", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), - Span::styled(err.clone(), Style::default().fg(Color::Red)), - ])); - } else { - lines.push(Line::from(vec![ - Span::styled(" Loading ", Style::default().fg(Color::DarkGray)), + fn render_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let model_label = format!(" siGit — {} ", app.current_model_name); + let tool_label = if app.tool_calling { + " [tools on] " + } else { + " [tools off] " + }; + let line = Line::from(vec![ Span::styled( - app.load_model_name.clone(), + model_label, Style::default() - .fg(Color::White) + .fg(Color::Black) + .bg(Color::Green) .add_modifier(Modifier::BOLD), ), Span::styled( - format!(" {elapsed_str}"), - Style::default().fg(Color::DarkGray), + tool_label, + Style::default().fg(Color::Black).bg(Color::DarkGray), ), - ])); + ]); + frame.render_widget(Paragraph::new(line), area); } - frame.render_widget(Paragraph::new(lines), area); -} - -/// One-line footer shown only during the loading phase. -fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) { - let spans = vec![ - Span::styled( - " Ctrl+C ", + fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]; + let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()]; + let label = format!(" siGit {} loading {}… ", spin, app.load_model_name); + let line = Line::from(Span::styled( + label, Style::default() .fg(Color::Black) - .bg(Color::DarkGray) + .bg(Color::Green) .add_modifier(Modifier::BOLD), - ), - Span::styled(" quit", Style::default().fg(Color::DarkGray)), - ]; - frame.render_widget( - Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)), - area, - ); -} - -fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { - let mut lines: Vec<Line<'_>> = Vec::new(); - - for msg in &app.messages { - render_chat_message(&mut lines, msg); + )); + frame.render_widget(Paragraph::new(line), area); } - // Streaming partial response. - if !app.stream_buf.is_empty() || app.is_streaming() { - let mut spans = vec![Span::styled( - "siGit > ", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )]; - - // Split on newlines so multi-line streaming renders correctly. - let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect(); - for (i, segment) in buf_lines.iter().enumerate() { - if i > 0 { - lines.push(Line::from(std::mem::take(&mut spans))); - // Continuation lines get no prefix. - } - spans.push(Span::raw(segment.to_string())); - } - - // Blinking block cursor while streaming. - if app.is_streaming() && app.blink_on { - spans.push(Span::styled("█", Style::default().fg(Color::Green))); - } + fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let elapsed = app.load_start.elapsed().as_secs(); + let elapsed_str = if elapsed < 60 { + format!("{}s", elapsed) + } else { + format!("{}m {}s", elapsed / 60, elapsed % 60) + }; - lines.push(Line::from(spans)); - } + let content = if let Some(ref err) = app.load_error { + format!( + "\n\n ✗ Failed to load model after {}.\n\n {}\n\n Press Ctrl+C to exit.", + elapsed_str, err + ) + } else { + format!( + "\n\n Loading model, please wait… ({})\n\n The model is being initialised. This may take a moment on first run.", + elapsed_str + ) + }; - // switching-model indicator (animated spinner + optional download progress) - if app.switching_model { - let frame_char = app.switching_frame(); - - let status_text = match app.download_progress { - Some((downloaded, expected)) if expected > 0 => { - let pct = ((downloaded as f64 / expected as f64) * 100.0).min(99.0) as u8; - let bar_width: usize = 16; - let filled = (pct as usize * bar_width) / 100; - let empty = bar_width.saturating_sub(filled); - let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(empty)); - format!( - "{frame_char} downloading… {bar} {pct}% ({} / {})", - format_size_human(downloaded), - format_size_human(expected), - ) - } - Some((downloaded, 0)) if downloaded > 0 => { - format!( - "{frame_char} downloading… {} received", - format_size_human(downloaded) - ) - } - _ => format!("{frame_char} loading model…"), + let style = if app.load_error.is_some() { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::White) }; - lines.push(Line::from(vec![ - Span::styled( - "siGit > ", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - status_text, - Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM), - ), - ])); + frame.render_widget( + Paragraph::new(content) + .style(style) + .wrap(Wrap { trim: false }), + area, + ); } - // thinking indicator (animated spinner) - if app.thinking { - let frame_char = app.thinking_frame(); - lines.push(Line::from(vec![ - Span::styled( - "siGit > ", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{frame_char} thinking…"), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::DIM), - ), - ])); + fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) { + let line = Line::from(vec![ + Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)), + Span::styled(" quit", Style::default().fg(Color::DarkGray)), + ]); + frame.render_widget(Paragraph::new(line), area); } - // auto-scroll - app.auto_scroll(area.height, area.width); + fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + let inner_width = area.width.saturating_sub(2); + let inner_height = area.height.saturating_sub(2); - let paragraph = Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .scroll((app.scroll_offset, 0)) - .style(Style::default()); + app.auto_scroll(inner_height, area.width); - frame.render_widget(paragraph, area); -} + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); -fn render_chat_message<'a>(lines: &mut Vec<Line<'a>>, msg: &ChatMessage) { - let text_lines: Vec<&str> = msg.text.split('\n').collect(); + let inner = block.inner(area); + frame.render_widget(block, area); - match msg.role { - Role::User => { - for (i, segment) in text_lines.iter().enumerate() { - let mut spans = Vec::new(); - if i == 0 { - spans.push(Span::styled( - "you > ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )); - } - spans.push(Span::styled( - segment.to_string(), - Style::default().fg(Color::White), - )); - lines.push(Line::from(spans)); + let mut lines: Vec<Line> = Vec::new(); + + for msg in &app.messages { + render_chat_message(&mut lines, msg, inner_width as usize); + } + + // In-progress streaming token buffer. + if !app.stream_buf.is_empty() { + let fake = ChatMessage { + role: Role::Assistant, + text: app.stream_buf.clone(), + think_block: None, + }; + render_chat_message(&mut lines, &fake, inner_width as usize); + // blinking cursor at end + if app.blink_on + && let Some(last) = lines.last_mut() + { + last.spans + .push(Span::styled("▋", Style::default().fg(Color::Green))); } } - Role::Assistant => { - // Show thinking block dimmed if present. - if let Some(ref think) = msg.think_block { - let think_summary = if think.len() > 120 { - format!("{}…", &think[..120]) + + // Thinking / switching spinner. + if app.thinking { + lines.push(Line::from(Span::styled( + format!(" {} thinking…", app.thinking_frame()), + Style::default().fg(Color::DarkGray), + ))); + } else if app.switching_model { + let frame_str = app.switching_frame(); + let progress_str = if let Some((downloaded, expected)) = app.download_progress { + if expected > 0 { + let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8; + let dl_str = format_size_human(downloaded); + let ex_str = format_size_human(expected); + format!(" — {dl_str} / {ex_str} ({pct}%)") + } else if downloaded > 0 { + format!(" — {} downloaded", format_size_human(downloaded)) } else { - think.clone() - }; - lines.push(Line::from(vec![ - Span::styled("💭 ", Style::default().fg(Color::DarkGray)), - Span::styled( - think_summary, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - ), - ])); - } + String::new() + } + } else { + String::new() + }; + lines.push(Line::from(Span::styled( + format!(" {frame_str} switching model{progress_str}…"), + Style::default().fg(Color::DarkGray), + ))); + } + + let total_lines = lines.len() as u16; + let scroll = if total_lines > inner_height { + app.scroll_offset.min(total_lines - inner_height) + } else { + 0 + }; + + frame.render_widget( + Paragraph::new(lines) + .scroll((scroll, 0)) + .wrap(Wrap { trim: false }), + inner, + ); + } - for (i, segment) in text_lines.iter().enumerate() { + fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) { + match msg.role { + Role::Banner => { + // Each character in banner art gets its own rainbow colour. + let palette = [ + Color::Red, + Color::Yellow, + Color::Green, + Color::Cyan, + Color::Blue, + Color::Magenta, + ]; let mut spans = Vec::new(); - if i == 0 { - spans.push(Span::styled( - "siGit > ", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )); + for (i, ch) in msg.text.chars().enumerate() { + let color = palette[i % palette.len()]; + spans.push(Span::styled(ch.to_string(), Style::default().fg(color))); } - spans.push(Span::styled( - segment.to_string(), - Style::default().fg(Color::White), - )); lines.push(Line::from(spans)); } - } - Role::System => { - for segment in &text_lines { - lines.push(Line::from(Span::styled( - segment.to_string(), - Style::default().fg(Color::DarkGray), - ))); + Role::System => { + for text_line in msg.text.split('\n') { + lines.push(Line::from(Span::styled( + text_line.to_string(), + Style::default().fg(Color::DarkGray), + ))); + } } - } - Role::Banner => { - for segment in &text_lines { - lines.push(Line::from(Span::styled( - segment.to_string(), - Style::default().fg(Color::White), - ))); + Role::User => { + let prefix = Span::styled( + "you > ".to_string(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ); + let mut first = true; + for text_line in msg.text.split('\n') { + if first { + lines.push(Line::from(vec![ + prefix.clone(), + Span::raw(text_line.to_string()), + ])); + first = false; + } else { + lines.push(Line::from(Span::raw(format!(" {text_line}")))); + } + } + } + Role::Assistant => { + // If there is a think block, render it first, dimmed. + if let Some(ref think) = msg.think_block { + lines.push(Line::from(Span::styled( + " ┌ thinking ".to_string(), + Style::default().fg(Color::DarkGray), + ))); + for think_line in think.split('\n') { + lines.push(Line::from(Span::styled( + format!(" │ {think_line}"), + Style::default().fg(Color::DarkGray), + ))); + } + lines.push(Line::from(Span::styled( + " └─────────".to_string(), + Style::default().fg(Color::DarkGray), + ))); + } + + let prefix = Span::styled( + "siGit > ".to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + let mut first = true; + for text_line in msg.text.split('\n') { + if first { + lines.push(Line::from(vec![ + prefix.clone(), + Span::raw(text_line.to_string()), + ])); + first = false; + } else { + lines.push(Line::from(Span::raw(format!(" {text_line}")))); + } + } } } } -} -fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let block = Block::default() - .borders(Borders::TOP) - .border_style(Style::default().fg(Color::DarkGray)) - .title(if app.thinking { - " thinking… " - } else if app.is_streaming() { - " streaming… " - } else { - " message " - }) - .title_style(Style::default().fg(if app.is_busy() { - Color::Yellow - } else { - Color::DarkGray - })); - - let input_text = Paragraph::new(app.input.as_str()) - .style(Style::default().fg(if app.is_busy() { - Color::DarkGray - } else { - Color::White - })) - .block(block); - - frame.render_widget(input_text, area); - - // Place cursor inside the input block (offset by 1 for the border). - if !app.is_busy() { - let x = area.x + app.cursor as u16 + 1; - let y = area.y + 1; - frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), y)); + fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" message "); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let display = app.input.clone(); + frame.render_widget( + Paragraph::new(display.clone()).wrap(Wrap { trim: false }), + inner, + ); + + // Position the real terminal cursor inside the input box. + let col = (app.cursor as u16) % inner.width; + let row = (app.cursor as u16) / inner.width; + frame.set_cursor_position(Position { + x: inner.x + col, + y: inner.y + row, + }); } -} - -fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let hints: &[(&str, &str)] = if app.show_model_picker { - &[("↑↓", "select"), ("Enter", "load"), ("Esc", "close")] - } else if app.is_busy() { - &[("Ctrl+C", "cancel")] - } else { - &[ - ("Enter", "send"), - ("/help", "commands"), - ("/models", "models"), - ("Ctrl+C", "quit"), - ] - }; - let mut spans: Vec<Span<'_>> = Vec::new(); - for (i, (key, label)) in hints.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" ", Style::default())); + fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let mut spans = vec![ + Span::styled( + " Enter ", + Style::default().fg(Color::Black).bg(Color::Green), + ), + Span::styled(" send ", Style::default().fg(Color::DarkGray)), + Span::styled( + " /help ", + Style::default().fg(Color::Black).bg(Color::DarkGray), + ), + Span::styled(" commands ", Style::default().fg(Color::DarkGray)), + Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)), + Span::styled(" quit", Style::default().fg(Color::DarkGray)), + ]; + + if app.thinking || app.switching_model || app.is_streaming() { + spans.push(Span::styled( + " (busy — Ctrl+C to cancel)", + Style::default().fg(Color::Yellow), + )); } - spans.push(Span::styled( - format!(" {key} "), - Style::default() - .fg(Color::White) - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - format!(" {label}"), - Style::default().fg(Color::Gray), - )); - } - frame.render_widget( - Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)), - area, - ); -} + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } -// ── Input handling ──────────────────────────────────────────────────────────── + fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> { + if key.kind != KeyEventKind::Press { + return None; + } -fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> { - if key.kind != KeyEventKind::Press { - return None; - } + if app.show_model_picker { + match key.code { + KeyCode::Esc => { + app.close_model_picker(); + return None; + } + KeyCode::Up => { + app.move_model_picker_up(); + return None; + } + KeyCode::Down => { + app.move_model_picker_down(); + return None; + } + KeyCode::Enter => { + return Some(format!("/models {}", app.model_picker_index + 1)); + } + _ => return None, + } + } - if app.show_model_picker { match key.code { - KeyCode::Esc => { - app.close_model_picker(); - return None; + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.quit = true; + None } - KeyCode::Up => { - app.move_model_picker_up(); - return None; - } - KeyCode::Down => { - app.move_model_picker_down(); - return None; + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.quit = true; + None } KeyCode::Enter => { - return Some(format!("/models {}", app.model_picker_index + 1)); + if app.input.trim().is_empty() { + return None; + } + let text = app.input.drain(..).collect::<String>(); + app.cursor = 0; + Some(text) } - _ => return None, - } - } - - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.quit = true; - None - } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.quit = true; - None - } - KeyCode::Enter => { - if app.input.trim().is_empty() { - return None; + KeyCode::Backspace => { + if app.cursor > 0 { + app.cursor -= 1; + app.input.remove(app.cursor); + } + None } - let text = app.input.drain(..).collect::<String>(); - app.cursor = 0; - Some(text) - } - KeyCode::Backspace => { - if app.cursor > 0 { - app.cursor -= 1; - app.input.remove(app.cursor); + KeyCode::Delete => { + if app.cursor < app.input.len() { + app.input.remove(app.cursor); + } + None } - None - } - KeyCode::Delete => { - if app.cursor < app.input.len() { - app.input.remove(app.cursor); + KeyCode::Left => { + app.cursor = app.cursor.saturating_sub(1); + None } - None - } - KeyCode::Left => { - app.cursor = app.cursor.saturating_sub(1); - None - } - KeyCode::Right => { - if app.cursor < app.input.len() { + KeyCode::Right => { + if app.cursor < app.input.len() { + app.cursor += 1; + } + None + } + KeyCode::Home => { + app.cursor = 0; + None + } + KeyCode::End => { + app.cursor = app.input.len(); + None + } + KeyCode::Char(ch) => { + app.input.insert(app.cursor, ch); app.cursor += 1; + None } - None - } - KeyCode::Home => { - app.cursor = 0; - None - } - KeyCode::End => { - app.cursor = app.input.len(); - None + _ => None, } - KeyCode::Char(ch) => { - app.input.insert(app.cursor, ch); - app.cursor += 1; - None - } - _ => None, } -} -// ── Slash command execution ─────────────────────────────────────────────────── - -async fn exec_slash<B: ratatui::backend::Backend>( - app: &mut App, - cmd: SlashCommand, - engine: Arc<ChatEngine>, - terminal: &mut ratatui::Terminal<B>, -) { - match cmd { - SlashCommand::Help => { - app.messages.push(ChatMessage::system( - "/help — show this message\n\ - /models — open the model picker\n\ - /models N — switch to model N\n\ - /clear — wipe conversation history\n\ - /status — show engine status\n\ - /exit — quit chat", - )); - } - SlashCommand::Clear => { - let cleared = engine.clear_history().await; - app.messages.clear(); - app.scroll_offset = 0; - app.messages.push(ChatMessage::system(format!( - "Cleared {cleared} turn(s). History is empty.", - ))); - } - SlashCommand::Status => { - let info = engine.as_ref().info().await; - let model = info.model_name.as_deref().unwrap_or("(none)"); - let mem = info.approx_memory.as_deref().unwrap_or("unknown"); - app.messages.push(ChatMessage::system(format!( - "status: {:?} model: {} memory: {} history: {} turns", - info.status, model, mem, info.history_length, - ))); - } - SlashCommand::Models(selection) => match selection { - None => { - app.open_model_picker(&engine); + // ── Slash command execution ─────────────────────────────────────────────── + + async fn exec_slash<B: ratatui::backend::Backend>( + app: &mut App, + cmd: SlashCommand, + engine: Arc<ChatEngine>, + terminal: &mut ratatui::Terminal<B>, + ) { + match cmd { + SlashCommand::Help => { + app.messages.push(ChatMessage::system( + "/help — show this message\n\ + /models — open the model picker\n\ + /models N — switch to model N\n\ + /clear — wipe conversation history\n\ + /status — show engine status\n\ + /exit — quit chat", + )); } - Some(n) => { - let idx = n.saturating_sub(1); - match app.model_picker_items.get(idx).cloned() { - None => { - app.messages.push(ChatMessage::system(format!( - "error: no model #{n} — type /models to see the list." - ))); - } - Some(model) => { - if model.cache_health == ModelCacheHealth::Incomplete { - app.close_model_picker(); + SlashCommand::Clear => { + let cleared = engine.clear_history().await; + app.messages.clear(); + app.scroll_offset = 0; + app.messages.push(ChatMessage::system(format!( + "Cleared {cleared} turn(s). History is empty.", + ))); + } + SlashCommand::Status => { + let info = engine.as_ref().info().await; + let model = info.model_name.as_deref().unwrap_or("(none)"); + let mem = info.approx_memory.as_deref().unwrap_or("unknown"); + app.messages.push(ChatMessage::system(format!( + "status: {:?} model: {} memory: {} history: {} turns", + info.status, model, mem, info.history_length, + ))); + } + SlashCommand::Models(selection) => match selection { + None => { + app.open_model_picker(&engine); + } + Some(n) => { + let idx = n.saturating_sub(1); + match app.model_picker_items.get(idx).cloned() { + None => { app.messages.push(ChatMessage::system(format!( - "error: {} has an incomplete local cache and cannot be selected yet.", - model.display_name + "error: no model #{n} — type /models to see the list." ))); - return; } + Some(model) => { + if model.cache_health == ModelCacheHealth::Incomplete { + app.close_model_picker(); + app.messages.push(ChatMessage::system(format!( + "error: {} has an incomplete local cache and cannot be selected yet.", + model.display_name + ))); + return; + } - let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded { - format!( - "Downloading and loading {} ({})… this may take a few minutes.", - model.display_name, model.description - ) - } else { - format!("Loading {}…", model.display_name) - }; - - app.close_model_picker(); - app.messages.push(ChatMessage::system(loading_msg)); - terminal.draw(|frame| render(frame, app)).ok(); - - let (tx, rx) = mpsc::channel(1); - app.model_load_rx = Some(rx); - app.switching_model = true; - app.switching_model_id = Some(model.config.model_id.clone()); - // Only show download progress for models not yet cached. - app.download_progress = - if model.cache_health == ModelCacheHealth::NotDownloaded { - Some((0, 0)) + let loading_msg = if model.cache_health + == ModelCacheHealth::NotDownloaded + { + format!( + "Downloading and loading {} ({})… this may take a few minutes.", + model.display_name, model.description + ) } else { - None + format!("Loading {}…", model.display_name) }; - let sampling = SamplingConfig { - max_tokens: Some(model.max_tokens), - ..SamplingConfig::default() - }; - - // Use a dedicated OS thread with its own tokio Runtime - // so that load_gguf_model's internal block_in_place - // cannot steal the main runtime's worker threads and - // freeze the TUI draw loop. This mirrors the pattern - // used at startup in run_interactive / run_acp_server. - let system_prompt = crate::system_prompt_for_model(model.tool_calling); - let engine_handle = Arc::clone(&engine); - let tool_calling = model.tool_calling; - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new() - .expect("failed to create model-loader runtime"); - let update = rt.block_on(async move { - match engine_handle - .load_gguf_model( - model.config.clone(), - Some(system_prompt.to_string()), - Some(sampling), - ) - .await - { - Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()), - Err(err) => ModelLoadUpdate::Error(err.to_string()), - } + app.close_model_picker(); + app.messages.push(ChatMessage::system(loading_msg)); + terminal.draw(|frame| render(frame, app)).ok(); + + let (tx, rx) = mpsc::channel(1); + app.model_load_rx = Some(rx); + app.switching_model = true; + app.switching_model_id = Some(model.config.model_id.clone()); + // Only show download progress for models not yet cached. + app.download_progress = + if model.cache_health == ModelCacheHealth::NotDownloaded { + Some((0, 0)) + } else { + None + }; + + let sampling = SamplingConfig { + max_tokens: Some(model.max_tokens), + ..SamplingConfig::default() + }; + + // Use a dedicated OS thread with its own tokio Runtime + // so that load_gguf_model's internal block_in_place + // cannot steal the main runtime's worker threads and + // freeze the TUI draw loop. This mirrors the pattern + // used at startup in run_interactive / run_acp_server. + let system_prompt = crate::system_prompt_for_model(model.tool_calling); + let engine_handle = Arc::clone(&engine); + let tool_calling = model.tool_calling; + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new() + .expect("failed to create model-loader runtime"); + let update = rt.block_on(async move { + match engine_handle + .load_gguf_model( + model.config.clone(), + Some(system_prompt.to_string()), + Some(sampling), + ) + .await + { + Ok(_) => { + ModelLoadUpdate::Loaded(model.display_name.clone()) + } + Err(err) => ModelLoadUpdate::Error(err.to_string()), + } + }); + // blocking_send is fine here — the channel has + // capacity 1 and the receiver is always alive while + // switching_model is true. + let _ = tx.blocking_send(update); }); - // blocking_send is fine here — the channel has - // capacity 1 and the receiver is always alive while - // switching_model is true. - let _ = tx.blocking_send(update); - }); - // tool_calling is applied when ModelLoadUpdate::Loaded - // arrives in the event loop (see model_load_rx handler). - app.pending_tool_calling = Some(tool_calling); + // tool_calling is applied when ModelLoadUpdate::Loaded + // arrives in the event loop (see model_load_rx handler). + app.pending_tool_calling = Some(tool_calling); + } } } + }, + SlashCommand::Exit => { + app.quit = true; + } + SlashCommand::Unknown(cmd) => { + app.messages + .push(ChatMessage::system(format!("unknown command: {cmd}"))); } - }, - SlashCommand::Exit => { - app.quit = true; - } - SlashCommand::Unknown(cmd) => { - app.messages - .push(ChatMessage::system(format!("unknown command: {cmd}"))); } } -} -// ── Background inference task ──────────────────────────────────────────────── + // ── Background inference task ───────────────────────────────────────────── -/// Maximum number of tool-calling rounds before forcing a text response. -const MAX_TOOL_ROUNDS: usize = 10; + /// Maximum number of tool-calling rounds before forcing a text response. + const MAX_TOOL_ROUNDS: usize = 10; -/// Build onde `ToolDefinition`s from our agent tools. -fn build_onde_tools() -> Vec<ToolDefinition> { - crate::tools::all_tools() - .into_iter() - .map(|t| ToolDefinition { - name: t.name.to_string(), - description: t.description.to_string(), - parameters_schema: t.parameters_schema.to_string(), - }) - .collect() -} + /// Build onde `ToolDefinition`s from our agent tools. + fn build_onde_tools() -> Vec<ToolDefinition> { + crate::tools::all_tools() + .into_iter() + .map(|t| ToolDefinition { + name: t.name.to_string(), + description: t.description.to_string(), + parameters_schema: t.parameters_schema.to_string(), + }) + .collect() + } -/// Runs the agentic tool-calling loop on a background task and sends -/// progress updates back through `tx`. -/// -/// The sender is dropped when the task finishes, which the event loop -/// detects as `None` from `rx.recv()`. -async fn run_inference_task( - engine: Arc<ChatEngine>, - text: String, - tx: mpsc::Sender<InferenceUpdate>, - tools_enabled: bool, -) { - let onde_tools = if tools_enabled { - build_onde_tools() - } else { - vec![] - }; + /// Runs the agentic tool-calling loop on a background task and sends + /// progress updates back through `tx`. + /// + /// The sender is dropped when the task finishes, which the event loop + /// detects as `None` from `rx.recv()`. + async fn run_inference_task( + engine: Arc<ChatEngine>, + text: String, + tx: mpsc::Sender<InferenceUpdate>, + tools_enabled: bool, + ) { + let onde_tools = if tools_enabled { + build_onde_tools() + } else { + vec![] + }; - let mut result = match engine.send_message_with_tools(&text, &onde_tools).await { - Ok(r) => r, - Err(err) => { - let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; - return; - } - }; + let mut result = match engine.send_message_with_tools(&text, &onde_tools).await { + Ok(r) => r, + Err(err) => { + let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; + return; + } + }; - let mut round = 0; + let mut round = 0; - while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS { - round += 1; - log::info!("tool round {} — {} call(s)", round, result.tool_calls.len()); + while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS { + round += 1; + log::info!("tool round {} — {} call(s)", round, result.tool_calls.len()); - let mut tool_results = Vec::new(); + let mut tool_results = Vec::new(); - for tc in &result.tool_calls { - log::info!( - " → {}({})", - tc.function_name, - tc.arguments.chars().take(120).collect::<String>() - ); + for tc in &result.tool_calls { + log::info!( + " → {}({})", + tc.function_name, + tc.arguments.chars().take(120).collect::<String>() + ); - // Notify the UI about the tool call. - let _ = tx - .send(InferenceUpdate::ToolUse(tc.function_name.clone())) - .await; + // Notify the UI about the tool call. + let _ = tx + .send(InferenceUpdate::ToolUse(tc.function_name.clone())) + .await; - // Execute the tool (async — read_website uses spawn_blocking internally). - let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await; - log::info!(" ← {} chars", output.len()); + // Execute the tool. + let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await; + log::info!(" ← {} chars", output.len()); - tool_results.push(ToolResult { - tool_call_id: tc.id.clone(), - content: output, - }); - } + tool_results.push(ToolResult { + tool_call_id: tc.id.clone(), + content: output, + }); + } - // Allow further tool calls unless we've hit the limit. - let next_tools = if round < MAX_TOOL_ROUNDS { - Some(onde_tools.as_slice()) - } else { - None // force a text response on the last round - }; + // Allow further tool calls unless we've hit the limit. + let next_tools = if round < MAX_TOOL_ROUNDS { + Some(onde_tools.as_slice()) + } else { + None // force a text response on the last round + }; - match engine.send_tool_results(tool_results, next_tools).await { - Ok(r) => result = r, - Err(err) => { - let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; - return; + match engine.send_tool_results(tool_results, next_tools).await { + Ok(r) => result = r, + Err(err) => { + let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; + return; + } } } - } - // Send the final text response, or a fallback if the model returned nothing. - if result.tool_calls.is_empty() { - if result.text.is_empty() { - log::warn!("model returned empty reply — may have exhausted max_tokens on thinking"); - let _ = tx - .send(InferenceUpdate::Error( - "(empty response — the model may have used all tokens on internal reasoning. \ - Try a shorter or simpler prompt.)" - .to_string(), - )) - .await; - } else { - let _ = tx.send(InferenceUpdate::Response(result.text)).await; + // Send the final text response, or a fallback if the model returned nothing. + if result.tool_calls.is_empty() { + if result.text.is_empty() { + log::warn!( + "model returned empty reply — may have exhausted max_tokens on thinking" + ); + let _ = tx + .send(InferenceUpdate::Error( + "(empty response — the model may have used all tokens on internal reasoning. \ + Try a shorter or simpler prompt.)" + .to_string(), + )) + .await; + } else { + let _ = tx.send(InferenceUpdate::Response(result.text)).await; + } } - } - - log::info!("inference complete — {} tool round(s)", round); - // Sender drops here → event loop sees `None`. -} -// ── Main loop ───────────────────────────────────────────────────────────────── + log::info!("inference complete — {} tool round(s)", round); + // Sender drops here → event loop sees `None`. + } -/// Run the interactive chat UI. Blocks until the user quits. -/// -/// Accepts a terminal that has already been initialised by the caller — -/// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility. -/// -/// `load_rx` is the receiving end of a [`std::sync::mpsc`] channel. A -/// dedicated OS thread loads the model and sends `Ok(())` or `Err(msg)` when -/// done. The event loop polls `try_recv()` on every tick — non-blocking, -/// zero contention with the tokio runtime. -#[cfg(unix)] -pub async fn run_with<B: ratatui::backend::Backend>( - terminal: &mut ratatui::Terminal<B>, - engine: Arc<ChatEngine>, - load_rx: std_mpsc::Receiver<Result<(), String>>, - load_model_name: String, -) -> Result<()> { - event_loop(terminal, engine, load_rx, load_model_name).await -} + // ── Main loop ───────────────────────────────────────────────────────────── + + /// Run the interactive chat UI. Blocks until the user quits. + /// + /// Accepts a terminal that has already been initialised by the caller — + /// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility. + /// + /// `load_rx` is the receiving end of a [`std::sync::mpsc`] channel. A + /// dedicated OS thread loads the model and sends `Ok(())` or `Err(msg)` when + /// done. The event loop polls `try_recv()` on every tick — non-blocking, + /// zero contention with the tokio runtime. + pub async fn run_with<B: ratatui::backend::Backend>( + terminal: &mut ratatui::Terminal<B>, + engine: Arc<ChatEngine>, + load_rx: std_mpsc::Receiver<Result<(), String>>, + load_model_name: String, + ) -> Result<()> { + event_loop(terminal, engine, load_rx, load_model_name).await + } -#[cfg(unix)] -async fn event_loop<B: ratatui::backend::Backend>( - terminal: &mut ratatui::Terminal<B>, - engine: Arc<ChatEngine>, - load_rx: std_mpsc::Receiver<Result<(), String>>, - load_model_name: String, -) -> Result<()> { - let mut app = App::new(load_model_name); - let mut event_stream = EventStream::new(); - - // 100 ms per tick ≈ 10 fps — enough for a smooth spinner. - let mut ticker = interval(Duration::from_millis(100)); - - loop { - // ── Poll the loader channel (non-blocking) ──────────────────────── - if app.is_loading { - match load_rx.try_recv() { - Ok(Ok(())) => app.finish_loading(), - Ok(Err(e)) => app.set_load_error(e), - Err(std_mpsc::TryRecvError::Empty) => {} - Err(std_mpsc::TryRecvError::Disconnected) => { - app.set_load_error("Model loader thread crashed.".to_string()); + async fn event_loop<B: ratatui::backend::Backend>( + terminal: &mut ratatui::Terminal<B>, + engine: Arc<ChatEngine>, + load_rx: std_mpsc::Receiver<Result<(), String>>, + load_model_name: String, + ) -> Result<()> { + let mut app = App::new(load_model_name); + let mut event_stream = EventStream::new(); + + // 100 ms per tick ≈ 10 fps — enough for a smooth spinner. + let mut ticker = interval(Duration::from_millis(100)); + + loop { + // ── Poll the loader channel (non-blocking) ──────────────────────── + if app.is_loading { + match load_rx.try_recv() { + Ok(Ok(())) => app.finish_loading(), + Ok(Err(e)) => app.set_load_error(e), + Err(std_mpsc::TryRecvError::Empty) => {} + Err(std_mpsc::TryRecvError::Disconnected) => { + app.set_load_error("Model loader thread crashed.".to_string()); + } } } - } - // redraw every iteration - terminal.draw(|frame| render(frame, &mut app))?; + // redraw every iteration + terminal.draw(|frame| render(frame, &mut app))?; - if let Some(rx) = app.model_load_rx.as_mut() { - match rx.try_recv() { - Ok(ModelLoadUpdate::Loaded(model_name)) => { - engine.clear_history().await; - if let Some(tc) = app.pending_tool_calling.take() { - app.tool_calling = tc; + if let Some(rx) = app.model_load_rx.as_mut() { + match rx.try_recv() { + Ok(ModelLoadUpdate::Loaded(model_name)) => { + engine.clear_history().await; + if let Some(tc) = app.pending_tool_calling.take() { + app.tool_calling = tc; + } + app.switching_model = false; + app.switching_model_id = None; + app.download_progress = None; + app.model_load_cancelled = false; + app.model_load_rx = None; + app.current_model_name = model_name.clone(); + + let save_result = app + .model_picker_items + .iter() + .find(|item| item.display_name == model_name) + .map(|item| crate::setup::SelectedModel { + model_id: item.config.model_id.clone(), + gguf_file: item + .config + .files + .first() + .cloned() + .unwrap_or_else(String::new), + }) + .filter(|selected| !selected.gguf_file.is_empty()) + .map(|selected| crate::setup::save_selected_model(&selected)) + .unwrap_or_else(|| { + Err(format!( + "could not determine a stable identifier for {}", + model_name + )) + }); + + if let Err(error) = save_result { + app.messages.push(ChatMessage::system(format!( + "warning: switched to {} but could not save the selection: {}", + model_name, error + ))); + } else { + app.messages + .push(ChatMessage::system(format!("✓ Switched to {}", model_name))); + } } - app.switching_model = false; - app.switching_model_id = None; - app.download_progress = None; - app.model_load_cancelled = false; - app.model_load_rx = None; - app.current_model_name = model_name.clone(); - - let save_result = app - .model_picker_items - .iter() - .find(|item| item.display_name == model_name) - .map(|item| crate::setup::SelectedModel { - model_id: item.config.model_id.clone(), - gguf_file: item - .config - .files - .first() - .cloned() - .unwrap_or_else(String::new), - }) - .filter(|selected| !selected.gguf_file.is_empty()) - .map(|selected| crate::setup::save_selected_model(&selected)) - .unwrap_or_else(|| { - Err(format!( - "could not determine a stable identifier for {}", - model_name - )) - }); - - if let Err(error) = save_result { - app.messages.push(ChatMessage::system(format!( - "warning: switched to {} but could not save the selection: {}", - model_name, error - ))); - } else { + Ok(ModelLoadUpdate::Error(error)) => { + app.switching_model = false; + app.switching_model_id = None; + app.download_progress = None; + app.model_load_cancelled = false; + app.model_load_rx = None; app.messages - .push(ChatMessage::system(format!("✓ Switched to {}", model_name))); + .push(ChatMessage::system(format!("error loading model: {error}"))); } - } - Ok(ModelLoadUpdate::Error(error)) => { - app.switching_model = false; - app.switching_model_id = None; - app.download_progress = None; - app.model_load_cancelled = false; - app.model_load_rx = None; - app.messages - .push(ChatMessage::system(format!("error loading model: {error}"))); - } - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { - let was_cancelled = app.model_load_cancelled; - app.switching_model = false; - app.switching_model_id = None; - app.download_progress = None; - app.model_load_cancelled = false; - app.model_load_rx = None; - if !was_cancelled { - app.messages.push(ChatMessage::system( - "error loading model: loader task disconnected".to_string(), - )); + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + let was_cancelled = app.model_load_cancelled; + app.switching_model = false; + app.switching_model_id = None; + app.download_progress = None; + app.model_load_cancelled = false; + app.model_load_rx = None; + if !was_cancelled { + app.messages.push(ChatMessage::system( + "error loading model: loader task disconnected".to_string(), + )); + } } } } - } - - if app.quit { - break; - } - // multiplex terminal events, streaming tokens, inference updates, - // and the thinking-spinner timer. - tokio::select! { - biased; - - // ── Spinner tick (loading phase only) ───────────────────────── - _ = ticker.tick(), if app.is_loading => { - app.tick(); + if app.quit { + break; } - // ── Streaming LLM tokens ────────────────────────────────────── - chunk = async { - match app.stream_rx.as_mut() { - Some(rx) => rx.recv().await, - None => pending().await, + // multiplex terminal events, streaming tokens, inference updates, + // and the thinking-spinner timer. + tokio::select! { + biased; + + // ── Spinner tick (loading phase only) ───────────────────────── + _ = ticker.tick(), if app.is_loading => { + app.tick(); } - } => { - match chunk { - Some(chunk) => { - if !chunk.delta.is_empty() { - app.push_stream_delta(&chunk.delta); + + // ── Streaming LLM tokens ────────────────────────────────────── + chunk = async { + match app.stream_rx.as_mut() { + Some(rx) => rx.recv().await, + None => pending().await, + } + } => { + match chunk { + Some(chunk) => { + if !chunk.delta.is_empty() { + app.push_stream_delta(&chunk.delta); + } + if chunk.done { + app.finalize_stream(); + } } - if chunk.done { + // Sender dropped without sending done=true. + None => { app.finalize_stream(); } } - // Sender dropped without sending done=true. - None => { - app.finalize_stream(); - } } - } - // ── inference updates from background task ─────────────────── - update = async { - match app.inference_rx.as_mut() { - Some(rx) => rx.recv().await, - None => pending().await, - } - } => { - match update { - Some(InferenceUpdate::ToolUse(name)) => { - app.messages.push(ChatMessage::system(format!("🔧 {name}"))); - } - Some(InferenceUpdate::Response(text)) => { - app.stop_thinking(); - app.messages.push(ChatMessage::assistant(text)); + // ── inference updates from background task ─────────────────── + update = async { + match app.inference_rx.as_mut() { + Some(rx) => rx.recv().await, + None => pending().await, } - Some(InferenceUpdate::Error(msg)) => { - app.stop_thinking(); - app.messages.push(ChatMessage::system(format!("error: {msg}"))); - } - None => { - // Sender dropped — task finished (possibly with no - // text response, e.g. all tool calls with empty final). - app.stop_thinking(); + } => { + match update { + Some(InferenceUpdate::ToolUse(name)) => { + app.messages.push(ChatMessage::system(format!("🔧 {name}"))); + } + Some(InferenceUpdate::Response(text)) => { + app.stop_thinking(); + app.messages.push(ChatMessage::assistant(text)); + } + Some(InferenceUpdate::Error(msg)) => { + app.stop_thinking(); + app.messages.push(ChatMessage::system(format!("error: {msg}"))); + } + None => { + // Sender dropped — task finished (possibly with no + // text response, e.g. all tool calls with empty final). + app.stop_thinking(); + } } } - } - // ── thinking / switching spinner tick (100ms) ──────────────── - _ = async { - if app.thinking || app.switching_model { - tokio::time::sleep(Duration::from_millis(100)).await - } else { - pending().await - } - } => { - app.tick_thinking(); - // Refresh download-progress bytes from the HF cache dir so - // the progress bar in render_messages stays current. - if app.switching_model { - app.poll_download_progress(); + // ── thinking / switching spinner tick (100ms) ──────────────── + _ = async { + if app.thinking || app.switching_model { + tokio::time::sleep(Duration::from_millis(100)).await + } else { + pending().await + } + } => { + app.tick_thinking(); + // Refresh download-progress bytes from the HF cache dir so + // the progress bar in render_messages stays current. + if app.switching_model { + app.poll_download_progress(); + } } - } - - // ── Terminal events ─────────────────────────────────────────── - maybe_event = event_stream.next() => { - let Some(Ok(event)) = maybe_event else { - break; - }; - if let Event::Key(key) = event { - // During loading, only Ctrl+C / Ctrl+D are accepted. - if app.is_loading { - if key.kind == KeyEventKind::Press { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if ctrl - && (key.code == KeyCode::Char('c') - || key.code == KeyCode::Char('d')) - { - app.quit = true; + // ── Terminal events ─────────────────────────────────────────── + maybe_event = event_stream.next() => { + let Some(Ok(event)) = maybe_event else { + break; + }; + + if let Event::Key(key) = event { + // During loading, only Ctrl+C / Ctrl+D are accepted. + if app.is_loading { + if key.kind == KeyEventKind::Press { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl + && (key.code == KeyCode::Char('c') + || key.code == KeyCode::Char('d')) + { + app.quit = true; + } } + continue; } - continue; - } - // While busy (streaming or thinking), only Ctrl+C/D work. - if app.is_busy() { - if key.kind == KeyEventKind::Press { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) { - if app.is_streaming() { - app.finalize_stream(); - app.messages.push(ChatMessage::system("(cancelled)")); - } - if app.thinking { - // Drop the receiver — the background task - // will see a closed channel and stop. - app.stop_thinking(); - app.messages.push(ChatMessage::system("(cancelled)")); - } - if app.switching_model { - // Mark as cancelled before dropping the - // receiver so the Disconnected arm in the - // model_load_rx handler stays silent. - app.model_load_cancelled = true; - app.switching_model = false; - app.switching_model_id = None; - app.download_progress = None; - app.model_load_rx = None; - app.messages - .push(ChatMessage::system("(download cancelled — model switch aborted)")); + // While busy (streaming or thinking), only Ctrl+C/D work. + if app.is_busy() { + if key.kind == KeyEventKind::Press { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) { + if app.is_streaming() { + app.finalize_stream(); + app.messages.push(ChatMessage::system("(cancelled)")); + } + if app.thinking { + // Drop the receiver — the background task + // will see a closed channel and stop. + app.stop_thinking(); + app.messages.push(ChatMessage::system("(cancelled)")); + } + if app.switching_model { + // Mark as cancelled before dropping the + // receiver so the Disconnected arm in the + // model_load_rx handler stays silent. + app.model_load_cancelled = true; + app.switching_model = false; + app.switching_model_id = None; + app.download_progress = None; + app.model_load_rx = None; + app.messages + .push(ChatMessage::system("(download cancelled — model switch aborted)")); + } } } - } - continue; - } - - if let Some(text) = handle_key(&mut app, key) { - if let Some(cmd) = parse_slash(&text) { - exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await; continue; } - // ── Spawn inference on a background task ───────── - app.messages.push(ChatMessage::user(&text)); - app.start_thinking(); + if let Some(text) = handle_key(&mut app, key) { + if let Some(cmd) = parse_slash(&text) { + exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await; + continue; + } + + // ── Spawn inference on a background task ───────── + app.messages.push(ChatMessage::user(&text)); + app.start_thinking(); - let (tx, rx) = mpsc::channel::<InferenceUpdate>(64); - app.inference_rx = Some(rx); + let (tx, rx) = mpsc::channel::<InferenceUpdate>(64); + app.inference_rx = Some(rx); - let engine_handle = Arc::clone(&engine); - let user_text = text.clone(); - let tools_enabled = app.tool_calling; - tokio::spawn(async move { - run_inference_task(engine_handle, user_text, tx, tools_enabled).await; - }); + let engine_handle = Arc::clone(&engine); + let user_text = text.clone(); + let tools_enabled = app.tool_calling; + tokio::spawn(async move { + run_inference_task(engine_handle, user_text, tx, tools_enabled).await; + }); + } } } } } + + Ok(()) } - Ok(()) -} + // ── Download progress helpers (TUI) ────────────────────────────────────── -// ── Download progress helpers (TUI) ────────────────────────────────────────── + /// Recursively sum the on-disk size of all files under `path`, following + /// symlinks so hf-hub's blob layout is counted correctly. + fn dir_size_recursive(path: &std::path::Path) -> u64 { + let mut total: u64 = 0; + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_dir() { + total += dir_size_recursive(&entry_path); + } else if let Ok(meta) = entry_path.metadata() { + total += meta.len(); + } + } + total + } -/// Recursively sum the on-disk size of all files under `path`, following -/// symlinks so hf-hub's blob layout is counted correctly. -#[cfg(unix)] -fn dir_size_recursive(path: &std::path::Path) -> u64 { - let mut total: u64 = 0; - let Ok(entries) = std::fs::read_dir(path) else { - return 0; - }; - for entry in entries.flatten() { - let entry_path = entry.path(); - if entry_path.is_dir() { - total += dir_size_recursive(&entry_path); - } else if let Ok(meta) = entry_path.metadata() { - total += meta.len(); + /// Format a byte count as a terse human-readable string. + fn format_size_human(bytes: u64) -> String { + const GB: u64 = 1_073_741_824; + const MB: u64 = 1_048_576; + const KB: u64 = 1_024; + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.0} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") } } - total -} +} // end #[cfg(unix)] mod tui -/// Format a byte count as a terse human-readable string. +// Re-export the Unix-only public entry point so callers can write +// `chat::run_with(...)` on all platforms and get a clean "not available" +// compile error on Windows rather than a missing-item error. #[cfg(unix)] -fn format_size_human(bytes: u64) -> String { - const GB: u64 = 1_073_741_824; - const MB: u64 = 1_048_576; - const KB: u64 = 1_024; - if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.1} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.0} KB", bytes as f64 / KB as f64) - } else { - format!("{bytes} B") - } -} +pub use tui::run_with; + +// ── Tests (platform-agnostic) ───────────────────────────────────────────────── #[cfg(test)] mod tests {