@setoelkahfi / sigit / commits / fa904e1

Refactor loading phase to use centered spinner and std mpsc

- Replace animated banner art with a centered spinner during model loading - Switch from tokio oneshot to std::sync::mpsc for loader signaling - Show model name and elapsed time while loading - Refactor loader to run on a dedicated OS thread, decoupled from tokio - Simplify loading state and related UI rendering

Seto Elkahfi committed Apr 23, 2026 at 23:49 UTC fa904e1903b58b62c0b65b368e5e7d5c162ee740
2 files changed +119 -124
src/chat.rs
+100 -108
index 72d442c..115f892 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -5,12 +5,13 @@ //! //! The UI has two phases: //! -//! 1. **Loading phase** — banner art lines are revealed one by one while the -//! model loads in the background. A braille spinner follows once all lines -//! are visible. +//! 1. **Loading phase** — a centered spinner is shown while the model loads +//! in the background. The oneshot channel from the caller signals +//! completion or failure. //! 2. **Chat phase** — normal interactive chat once `load_rx` resolves. use std::future::pending; +use std::sync::mpsc as std_mpsc; use anyhow::Result; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -23,8 +24,8 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; -use tokio::sync::{mpsc, oneshot}; -use tokio::time::{Duration, interval}; +use tokio::sync::mpsc; +use tokio::time::{Duration, Instant, interval}; // ── Message types ───────────────────────────────────────────────────────────── @@ -89,13 +90,15 @@ struct App { // ── Loading-phase state ─────────────────────────────────────────────────── /// True while the model is still loading; switches to false on completion. is_loading: bool, - /// How many banner art lines have been revealed so far. - banner_reveal: usize, - /// Monotonic counter incremented on every animation tick. - /// Drives the braille spinner and the trailing-dot animation. + /// 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, } const BANNER_ART: &str = "\ @@ -114,9 +117,8 @@ const BANNER_ART: &str = "\ 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888"; impl App { - fn new() -> Self { + fn new(load_model_name: String) -> Self { Self { - // Messages start empty; banner lines are added in finish_loading(). messages: Vec::new(), input: String::new(), cursor: 0, @@ -127,9 +129,10 @@ impl App { blink_on: true, blink_counter: 0, is_loading: true, - banner_reveal: 0, load_tick: 0, load_error: None, + load_start: Instant::now(), + load_model_name, } } @@ -152,30 +155,19 @@ impl App { self.blink_on = self.blink_counter % 4 < 2; } - /// Reveal the next banner line and advance the animation tick counter. - /// Called on every ticker firing during the loading phase. - fn advance_banner(&mut self) { - let total = BANNER_ART.lines().count(); - if self.banner_reveal < total { - self.banner_reveal += 1; - } - // Keep ticking even after all lines are revealed so the spinner moves. + /// Advance the spinner tick counter. + fn tick(&mut self) { self.load_tick = self.load_tick.wrapping_add(1); } /// Transition from loading phase to normal chat. - /// Adds the banner lines and welcome messages to the message log so they - /// appear naturally in the chat scroll buffer. + /// 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(format!( - "siGit Code v{}", - env!("CARGO_PKG_VERSION"), - ))); self.messages.push(ChatMessage::system( "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit", )); @@ -217,9 +209,7 @@ impl App { } } -fn banner_char_color(_ch: char) -> Color { - Color::White -} + /// How many terminal rows a message takes up after line-wrapping. fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { @@ -319,7 +309,7 @@ fn render(frame: &mut Frame, app: &mut App) { let area = frame.area(); if app.is_loading { - // Loading phase: title | animated banner area | slim footer (no input). + // Loading phase: title bar with spinner | loading info | footer hint. let zones = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), @@ -327,7 +317,7 @@ fn render(frame: &mut Frame, app: &mut App) { ]) .split(area); - render_title(frame, zones[0]); + render_loading_title(frame, app, zones[0]); render_loading(frame, app, zones[1]); render_loading_footer(frame, zones[2]); } else { @@ -357,7 +347,7 @@ fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) { ), Span::styled(" Code", Style::default().fg(Color::White)), Span::styled( - " — maybe deploy later?", + format!(" v{}", env!("CARGO_PKG_VERSION")), Style::default().fg(Color::DarkGray), ), ]); @@ -367,59 +357,72 @@ fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) { ); } -/// Animated loading screen. -/// -/// Reveals banner art lines one at a time (`banner_reveal` grows on each tick). -/// Once every line is visible a braille spinner and trailing dots indicate that -/// the model is still being pulled into memory. If loading fails, a red error -/// message replaces the spinner. +/// 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) { - const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - const DOTS: &[&str] = &["", ".", "..", "..."]; + 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 banner_lines: Vec<&str> = BANNER_ART.lines().collect(); - let total = banner_lines.len(); let mut lines: Vec<Line<'_>> = Vec::new(); - // Reveal lines up to the current animation frame. - for line in banner_lines.iter().take(app.banner_reveal) { - lines.push(Line::from(Span::styled( - *line, - Style::default().fg(Color::DarkGray), - ))); - } - if let Some(ref err) = app.load_error { - // Error state — show message and prompt the user to quit. 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)), ])); - lines.push(Line::from(Span::styled( - " Press Ctrl+C to quit.", - Style::default().fg(Color::DarkGray), - ))); - } else if app.banner_reveal >= total { - // All lines visible — show spinner while the model finishes loading. - let spinner = SPINNER[(app.load_tick as usize) % SPINNER.len()]; - let dots = DOTS[(app.load_tick as usize / 3) % DOTS.len()]; - + } else { lines.push(Line::from(vec![ + Span::styled(" Loading ", Style::default().fg(Color::DarkGray)), Span::styled( - format!(" {spinner} "), + app.load_model_name.clone(), Style::default() - .fg(Color::Yellow) + .fg(Color::White) .add_modifier(Modifier::BOLD), ), - Span::styled("Loading model", Style::default().fg(Color::White)), - Span::styled(dots, Style::default().fg(Color::DarkGray)), + Span::styled( + format!(" {elapsed_str}"), + Style::default().fg(Color::DarkGray), + ), ])); } - frame.render_widget(Paragraph::new(lines).style(Style::default()), area); + frame.render_widget(Paragraph::new(lines), area); } /// One-line footer shown only during the loading phase. @@ -534,13 +537,10 @@ fn render_chat_message<'a>(lines: &mut Vec<Line<'a>>, msg: &ChatMessage) { } Role::Banner => { for segment in &text_lines { - let spans: Vec<Span<'_>> = segment - .chars() - .map(|ch| { - Span::styled(ch.to_string(), Style::default().fg(banner_char_color(ch))) - }) - .collect(); - lines.push(Line::from(spans)); + lines.push(Line::from(Span::styled( + segment.to_string(), + Style::default().fg(Color::White), + ))); } } } @@ -797,36 +797,46 @@ async fn exec_slash<B: ratatui::backend::Backend>( /// /// Accepts a terminal that has already been initialised by the caller — /// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility. -/// Initialising the terminal *before* starting concurrent model loading -/// guarantees the alternate screen is active before any log line can fire. /// -/// `load_rx` resolves to `Ok(())` when the model finishes loading, or to -/// `Err(message)` if loading failed. The TUI animates the banner art while -/// waiting for this signal, then transitions to the normal chat view. +/// `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: &ChatEngine, - load_rx: oneshot::Receiver<Result<(), String>>, + load_rx: std_mpsc::Receiver<Result<(), String>>, ) -> Result<()> { - event_loop(terminal, engine, load_rx).await + let config = GgufModelConfig::platform_default(); + let model_name = config.display_name.clone(); + event_loop(terminal, engine, load_rx, model_name).await } async fn event_loop<B: ratatui::backend::Backend>( terminal: &mut ratatui::Terminal<B>, engine: &ChatEngine, - load_rx: oneshot::Receiver<Result<(), String>>, + load_rx: std_mpsc::Receiver<Result<(), String>>, + load_model_name: String, ) -> Result<()> { - let mut app = App::new(); + let mut app = App::new(load_model_name); let mut event_stream = EventStream::new(); - // 80 ms per tick ≈ 12.5 fps — snappy enough for the banner reveal without - // burning the CPU. - let mut ticker = interval(Duration::from_millis(80)); - - // Wrap in Option so we can "disarm" it once the oneshot resolves. - let mut load_rx = Some(load_rx); + // 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))?; @@ -837,27 +847,9 @@ async fn event_loop<B: ratatui::backend::Backend>( tokio::select! { biased; - // ── Model load signal ───────────────────────────────────────── - // Polls the oneshot receiver until it fires, then disarms it. - result = async { - match load_rx.as_mut() { - Some(rx) => match rx.await { - Ok(r) => r, - Err(_) => Err("Model load task was dropped unexpectedly.".to_string()), - }, - None => pending::<Result<(), String>>().await, - } - }, if load_rx.is_some() => { - load_rx = None; - match result { - Ok(()) => app.finish_loading(), - Err(e) => app.set_load_error(e), - } - } - - // ── Animation tick (loading phase only) ─────────────────────── + // ── Spinner tick (loading phase only) ───────────────────────── _ = ticker.tick(), if app.is_loading => { - app.advance_banner(); + app.tick(); } // ── Streaming LLM tokens ──────────────────────────────────────
src/main.rs
+19 -16
index dfaceb7..a3dc5eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,7 @@ use agent_client_protocol::{ }; use futures::future::LocalBoxFuture; use onde::inference::{ChatEngine, GgufModelConfig}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::mpsc; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tracing_subscriber::{EnvFilter, fmt as tracing_fmt}; @@ -265,9 +265,22 @@ fn init_logging(is_tty: bool) { /// (we cannot access the backend's writer because `writer_mut()` is private /// in ratatui 0.29). async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> { - let engine = ChatEngine::new(); + let engine = Arc::new(ChatEngine::new()); let config = GgufModelConfig::platform_default(); - let (load_tx, load_rx) = oneshot::channel::<Result<(), String>>(); + + // std::sync::mpsc — the loader runs on a dedicated OS thread, completely + // decoupled from the tokio runtime so it can't starve the TUI draw loop. + let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>(); + + let loader_engine = Arc::clone(&engine); + let system_prompt = SYSTEM_PROMPT.to_string(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime"); + let result = rt.block_on( + loader_engine.load_gguf_model(config, Some(system_prompt), None), + ); + let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); + }); // Set up the terminal manually on the real tty fd. crossterm::terminal::enable_raw_mode()?; @@ -276,19 +289,9 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> let backend = ratatui::backend::CrosstermBackend::new(tty); let mut terminal = ratatui::Terminal::new(backend)?; - // Drive model loading and the TUI concurrently. Both futures hold a - // shared `&engine` reference, which is valid because ChatEngine is Sync - // and all its methods take `&self`. - let (_, chat_result) = tokio::join!( - async { - let result = engine - .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None) - .await; - // Ignore send errors — the TUI may have already quit (Ctrl+C). - let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); - }, - chat::run_with(&mut terminal, &engine, load_rx), - ); + // The TUI runs here on the main tokio runtime. It polls load_rx via + // try_recv() on every tick — non-blocking, zero contention. + let chat_result = chat::run_with(&mut terminal, &engine, load_rx).await; // Restore the terminal before exiting. // Use the separate cleanup fd — the backend's writer is private.