@hej / 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
@@ -5,12 +5,13 @@
5 //!
6 //! The UI has two phases:
7 //!
8 -//! 1. **Loading phase** — banner art lines are revealed one by one while the
9 -//! model loads in the background. A braille spinner follows once all lines
10 -//! are visible.
8 +//! 1. **Loading phase** — a centered spinner is shown while the model loads
9 +//! in the background. The oneshot channel from the caller signals
10 +//! completion or failure.
11 //! 2. **Chat phase** — normal interactive chat once `load_rx` resolves.
12
13 use std::future::pending;
14 +use std::sync::mpsc as std_mpsc;
15
16 use anyhow::Result;
17 use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
@@ -23,8 +24,8 @@ use ratatui::{
24 text::{Line, Span},
25 widgets::{Block, Borders, Paragraph, Wrap},
26 };
26 -use tokio::sync::{mpsc, oneshot};
27 -use tokio::time::{Duration, interval};
27 +use tokio::sync::mpsc;
28 +use tokio::time::{Duration, Instant, interval};
29
30 // ── Message types ─────────────────────────────────────────────────────────────
31
@@ -89,13 +90,15 @@ struct App {
90 // ── Loading-phase state ───────────────────────────────────────────────────
91 /// True while the model is still loading; switches to false on completion.
92 is_loading: bool,
92 - /// How many banner art lines have been revealed so far.
93 - banner_reveal: usize,
94 - /// Monotonic counter incremented on every animation tick.
95 - /// Drives the braille spinner and the trailing-dot animation.
93 + /// Monotonic counter incremented on every animation tick. Drives the
94 + /// braille spinner shown during loading.
95 load_tick: u32,
96 /// Set when model loading fails; keeps the loading view up with the error.
97 load_error: Option<String>,
98 + /// When loading started — drives the elapsed-time counter.
99 + load_start: Instant,
100 + /// Display name of the model being loaded (shown in the spinner line).
101 + load_model_name: String,
102 }
103
104 const BANNER_ART: &str = "\
@@ -114,9 +117,8 @@ const BANNER_ART: &str = "\
117 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
118
119 impl App {
117 - fn new() -> Self {
120 + fn new(load_model_name: String) -> Self {
121 Self {
119 - // Messages start empty; banner lines are added in finish_loading().
122 messages: Vec::new(),
123 input: String::new(),
124 cursor: 0,
@@ -127,9 +129,10 @@ impl App {
129 blink_on: true,
130 blink_counter: 0,
131 is_loading: true,
130 - banner_reveal: 0,
132 load_tick: 0,
133 load_error: None,
134 + load_start: Instant::now(),
135 + load_model_name,
136 }
137 }
138
@@ -152,30 +155,19 @@ impl App {
155 self.blink_on = self.blink_counter % 4 < 2;
156 }
157
155 - /// Reveal the next banner line and advance the animation tick counter.
156 - /// Called on every ticker firing during the loading phase.
157 - fn advance_banner(&mut self) {
158 - let total = BANNER_ART.lines().count();
159 - if self.banner_reveal < total {
160 - self.banner_reveal += 1;
161 - }
162 - // Keep ticking even after all lines are revealed so the spinner moves.
158 + /// Advance the spinner tick counter.
159 + fn tick(&mut self) {
160 self.load_tick = self.load_tick.wrapping_add(1);
161 }
162
163 /// Transition from loading phase to normal chat.
167 - /// Adds the banner lines and welcome messages to the message log so they
168 - /// appear naturally in the chat scroll buffer.
164 + /// Adds the banner art and welcome messages to the message log.
165 fn finish_loading(&mut self) {
166 self.is_loading = false;
167 for line in BANNER_ART.lines() {
168 self.messages.push(ChatMessage::banner(line));
169 }
170 self.messages.push(ChatMessage::system(""));
175 - self.messages.push(ChatMessage::system(format!(
176 - "siGit Code v{}",
177 - env!("CARGO_PKG_VERSION"),
178 - )));
171 self.messages.push(ChatMessage::system(
172 "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
173 ));
@@ -217,9 +209,7 @@ impl App {
209 }
210 }
211
220 -fn banner_char_color(_ch: char) -> Color {
221 - Color::White
222 -}
212 +
213
214 /// How many terminal rows a message takes up after line-wrapping.
215 fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
@@ -319,7 +309,7 @@ fn render(frame: &mut Frame, app: &mut App) {
309 let area = frame.area();
310
311 if app.is_loading {
322 - // Loading phase: title | animated banner area | slim footer (no input).
312 + // Loading phase: title bar with spinner | loading info | footer hint.
313 let zones = Layout::vertical([
314 Constraint::Length(1),
315 Constraint::Min(1),
@@ -327,7 +317,7 @@ fn render(frame: &mut Frame, app: &mut App) {
317 ])
318 .split(area);
319
330 - render_title(frame, zones[0]);
320 + render_loading_title(frame, app, zones[0]);
321 render_loading(frame, app, zones[1]);
322 render_loading_footer(frame, zones[2]);
323 } else {
@@ -357,7 +347,7 @@ fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) {
347 ),
348 Span::styled(" Code", Style::default().fg(Color::White)),
349 Span::styled(
360 - " — maybe deploy later?",
350 + format!(" v{}", env!("CARGO_PKG_VERSION")),
351 Style::default().fg(Color::DarkGray),
352 ),
353 ]);
@@ -367,59 +357,72 @@ fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) {
357 );
358 }
359
370 -/// Animated loading screen.
371 -///
372 -/// Reveals banner art lines one at a time (`banner_reveal` grows on each tick).
373 -/// Once every line is visible a braille spinner and trailing dots indicate that
374 -/// the model is still being pulled into memory. If loading fails, a red error
375 -/// message replaces the spinner.
360 +/// Title bar during loading: `⠹ siGit Code v0.1.1`
361 +fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
362 + const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
363 + let spinner = SPINNER[(app.load_tick as usize) % SPINNER.len()];
364 +
365 + let title = Line::from(vec![
366 + Span::styled(
367 + format!("{spinner} "),
368 + Style::default()
369 + .fg(Color::Yellow)
370 + .add_modifier(Modifier::BOLD),
371 + ),
372 + Span::styled(
373 + "siGit",
374 + Style::default()
375 + .fg(Color::Green)
376 + .add_modifier(Modifier::BOLD),
377 + ),
378 + Span::styled(" Code", Style::default().fg(Color::White)),
379 + Span::styled(
380 + format!(" v{}", env!("CARGO_PKG_VERSION")),
381 + Style::default().fg(Color::DarkGray),
382 + ),
383 + ]);
384 + frame.render_widget(
385 + Paragraph::new(title).style(Style::default().bg(Color::Black)),
386 + area,
387 + );
388 +}
389 +
390 +/// Loading body — model name, elapsed time, or error message.
391 fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
377 - const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
378 - const DOTS: &[&str] = &["", ".", "..", "..."];
392 + let elapsed = app.load_start.elapsed();
393 + let elapsed_str = if elapsed.as_secs() >= 60 {
394 + format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
395 + } else {
396 + format!("{}s", elapsed.as_secs())
397 + };
398
380 - let banner_lines: Vec<&str> = BANNER_ART.lines().collect();
381 - let total = banner_lines.len();
399 let mut lines: Vec<Line<'_>> = Vec::new();
400
384 - // Reveal lines up to the current animation frame.
385 - for line in banner_lines.iter().take(app.banner_reveal) {
386 - lines.push(Line::from(Span::styled(
387 - *line,
388 - Style::default().fg(Color::DarkGray),
389 - )));
390 - }
391 -
401 if let Some(ref err) = app.load_error {
393 - // Error state — show message and prompt the user to quit.
402 lines.push(Line::from(vec![
403 Span::styled(
396 - " ✘ ",
404 + " ✘ ",
405 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
406 ),
407 Span::styled(err.clone(), Style::default().fg(Color::Red)),
408 ]));
401 - lines.push(Line::from(Span::styled(
402 - " Press Ctrl+C to quit.",
403 - Style::default().fg(Color::DarkGray),
404 - )));
405 - } else if app.banner_reveal >= total {
406 - // All lines visible — show spinner while the model finishes loading.
407 - let spinner = SPINNER[(app.load_tick as usize) % SPINNER.len()];
408 - let dots = DOTS[(app.load_tick as usize / 3) % DOTS.len()];
409 -
409 + } else {
410 lines.push(Line::from(vec![
411 + Span::styled(" Loading ", Style::default().fg(Color::DarkGray)),
412 Span::styled(
412 - format!(" {spinner} "),
413 + app.load_model_name.clone(),
414 Style::default()
414 - .fg(Color::Yellow)
415 + .fg(Color::White)
416 .add_modifier(Modifier::BOLD),
417 ),
417 - Span::styled("Loading model", Style::default().fg(Color::White)),
418 - Span::styled(dots, Style::default().fg(Color::DarkGray)),
418 + Span::styled(
419 + format!(" {elapsed_str}"),
420 + Style::default().fg(Color::DarkGray),
421 + ),
422 ]));
423 }
424
422 - frame.render_widget(Paragraph::new(lines).style(Style::default()), area);
425 + frame.render_widget(Paragraph::new(lines), area);
426 }
427
428 /// 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) {
537 }
538 Role::Banner => {
539 for segment in &text_lines {
537 - let spans: Vec<Span<'_>> = segment
538 - .chars()
539 - .map(|ch| {
540 - Span::styled(ch.to_string(), Style::default().fg(banner_char_color(ch)))
541 - })
542 - .collect();
543 - lines.push(Line::from(spans));
540 + lines.push(Line::from(Span::styled(
541 + segment.to_string(),
542 + Style::default().fg(Color::White),
543 + )));
544 }
545 }
546 }
@@ -797,36 +797,46 @@ async fn exec_slash<B: ratatui::backend::Backend>(
797 ///
798 /// Accepts a terminal that has already been initialised by the caller —
799 /// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility.
800 -/// Initialising the terminal *before* starting concurrent model loading
801 -/// guarantees the alternate screen is active before any log line can fire.
800 ///
803 -/// `load_rx` resolves to `Ok(())` when the model finishes loading, or to
804 -/// `Err(message)` if loading failed. The TUI animates the banner art while
805 -/// waiting for this signal, then transitions to the normal chat view.
801 +/// `load_rx` is the receiving end of a [`std::sync::mpsc`] channel. A
802 +/// dedicated OS thread loads the model and sends `Ok(())` or `Err(msg)` when
803 +/// done. The event loop polls `try_recv()` on every tick — non-blocking,
804 +/// zero contention with the tokio runtime.
805 pub async fn run_with<B: ratatui::backend::Backend>(
806 terminal: &mut ratatui::Terminal<B>,
807 engine: &ChatEngine,
809 - load_rx: oneshot::Receiver<Result<(), String>>,
808 + load_rx: std_mpsc::Receiver<Result<(), String>>,
809 ) -> Result<()> {
811 - event_loop(terminal, engine, load_rx).await
810 + let config = GgufModelConfig::platform_default();
811 + let model_name = config.display_name.clone();
812 + event_loop(terminal, engine, load_rx, model_name).await
813 }
814
815 async fn event_loop<B: ratatui::backend::Backend>(
816 terminal: &mut ratatui::Terminal<B>,
817 engine: &ChatEngine,
817 - load_rx: oneshot::Receiver<Result<(), String>>,
818 + load_rx: std_mpsc::Receiver<Result<(), String>>,
819 + load_model_name: String,
820 ) -> Result<()> {
819 - let mut app = App::new();
821 + let mut app = App::new(load_model_name);
822 let mut event_stream = EventStream::new();
823
822 - // 80 ms per tick ≈ 12.5 fps — snappy enough for the banner reveal without
823 - // burning the CPU.
824 - let mut ticker = interval(Duration::from_millis(80));
825 -
826 - // Wrap in Option so we can "disarm" it once the oneshot resolves.
827 - let mut load_rx = Some(load_rx);
824 + // 100 ms per tick ≈ 10 fps — enough for a smooth spinner.
825 + let mut ticker = interval(Duration::from_millis(100));
826
827 loop {
828 + // ── Poll the loader channel (non-blocking) ────────────────────────
829 + if app.is_loading {
830 + match load_rx.try_recv() {
831 + Ok(Ok(())) => app.finish_loading(),
832 + Ok(Err(e)) => app.set_load_error(e),
833 + Err(std_mpsc::TryRecvError::Empty) => {}
834 + Err(std_mpsc::TryRecvError::Disconnected) => {
835 + app.set_load_error("Model loader thread crashed.".to_string());
836 + }
837 + }
838 + }
839 +
840 // redraw every iteration
841 terminal.draw(|frame| render(frame, &mut app))?;
842
@@ -837,27 +847,9 @@ async fn event_loop<B: ratatui::backend::Backend>(
847 tokio::select! {
848 biased;
849
840 - // ── Model load signal ─────────────────────────────────────────
841 - // Polls the oneshot receiver until it fires, then disarms it.
842 - result = async {
843 - match load_rx.as_mut() {
844 - Some(rx) => match rx.await {
845 - Ok(r) => r,
846 - Err(_) => Err("Model load task was dropped unexpectedly.".to_string()),
847 - },
848 - None => pending::<Result<(), String>>().await,
849 - }
850 - }, if load_rx.is_some() => {
851 - load_rx = None;
852 - match result {
853 - Ok(()) => app.finish_loading(),
854 - Err(e) => app.set_load_error(e),
855 - }
856 - }
857 -
858 - // ── Animation tick (loading phase only) ───────────────────────
850 + // ── Spinner tick (loading phase only) ─────────────────────────
851 _ = ticker.tick(), if app.is_loading => {
860 - app.advance_banner();
852 + app.tick();
853 }
854
855 // ── Streaming LLM tokens ──────────────────────────────────────
src/main.rs
+19 -16
@@ -45,7 +45,7 @@ use agent_client_protocol::{
45 };
46 use futures::future::LocalBoxFuture;
47 use onde::inference::{ChatEngine, GgufModelConfig};
48 -use tokio::sync::{mpsc, oneshot};
48 +use tokio::sync::mpsc;
49 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
50 use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
51
@@ -265,9 +265,22 @@ fn init_logging(is_tty: bool) {
265 /// (we cannot access the backend's writer because `writer_mut()` is private
266 /// in ratatui 0.29).
267 async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
268 - let engine = ChatEngine::new();
268 + let engine = Arc::new(ChatEngine::new());
269 let config = GgufModelConfig::platform_default();
270 - let (load_tx, load_rx) = oneshot::channel::<Result<(), String>>();
270 +
271 + // std::sync::mpsc — the loader runs on a dedicated OS thread, completely
272 + // decoupled from the tokio runtime so it can't starve the TUI draw loop.
273 + let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
274 +
275 + let loader_engine = Arc::clone(&engine);
276 + let system_prompt = SYSTEM_PROMPT.to_string();
277 + std::thread::spawn(move || {
278 + let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
279 + let result = rt.block_on(
280 + loader_engine.load_gguf_model(config, Some(system_prompt), None),
281 + );
282 + let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
283 + });
284
285 // Set up the terminal manually on the real tty fd.
286 crossterm::terminal::enable_raw_mode()?;
@@ -276,19 +289,9 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
289 let backend = ratatui::backend::CrosstermBackend::new(tty);
290 let mut terminal = ratatui::Terminal::new(backend)?;
291
279 - // Drive model loading and the TUI concurrently. Both futures hold a
280 - // shared `&engine` reference, which is valid because ChatEngine is Sync
281 - // and all its methods take `&self`.
282 - let (_, chat_result) = tokio::join!(
283 - async {
284 - let result = engine
285 - .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
286 - .await;
287 - // Ignore send errors — the TUI may have already quit (Ctrl+C).
288 - let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
289 - },
290 - chat::run_with(&mut terminal, &engine, load_rx),
291 - );
292 + // The TUI runs here on the main tokio runtime. It polls load_rx via
293 + // try_recv() on every tick — non-blocking, zero contention.
294 + let chat_result = chat::run_with(&mut terminal, &engine, load_rx).await;
295
296 // Restore the terminal before exiting.
297 // Use the separate cleanup fd — the backend's writer is private.