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.