@hej / 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
@@ -10,26 +10,6 @@
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::Arc;
15 -use std::sync::mpsc as std_mpsc;
16 -
17 -use anyhow::Result;
18 -use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
19 -use futures::StreamExt;
20 -use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult};
21 -
22 -use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items};
23 -use ratatui::{
24 - Frame,
25 - layout::{Constraint, Layout, Position},
26 - style::{Color, Modifier, Style},
27 - text::{Line, Span},
28 - widgets::{Block, Borders, Clear, Paragraph, Wrap},
29 -};
30 -use tokio::sync::mpsc;
31 -use tokio::time::{Duration, Instant, interval};
32 -
13 // ── Think-block stripping ─────────────────────────────────────────────────────
14
15 /// Strip `<think>…</think>` blocks from a model response.
@@ -68,145 +48,167 @@ pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
48 (thinking, remainder.trim().to_string())
49 }
50
71 -// ── Message types ─────────────────────────────────────────────────────────────
51 +// ── Unix-only TUI ─────────────────────────────────────────────────────────────
52 +//
53 +// Everything below this point is compiled only on Unix (macOS + Linux).
54 +// Windows supports ACP mode only; the interactive TUI is not available there.
55
56 #[cfg(unix)]
74 -#[derive(Clone, Copy, PartialEq, Eq)]
75 -enum Role {
76 - User,
77 - Assistant,
78 - System,
79 - /// Banner art — each character gets its own color.
80 - Banner,
81 -}
57 +mod tui {
58 + use std::future::pending;
59 + use std::sync::Arc;
60 + use std::sync::mpsc as std_mpsc;
61 +
62 + use anyhow::Result;
63 + use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
64 + use futures::StreamExt;
65 + use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult};
66 +
67 + use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items};
68 + use ratatui::{
69 + Frame,
70 + layout::{Constraint, Layout, Position},
71 + style::{Color, Modifier, Style},
72 + text::{Line, Span},
73 + widgets::{Block, Borders, Clear, Paragraph, Wrap},
74 + };
75 + use tokio::sync::mpsc;
76 + use tokio::time::{Duration, Instant, interval};
77 +
78 + // ── Message types ─────────────────────────────────────────────────────────
79 +
80 + #[derive(Clone, Copy, PartialEq, Eq)]
81 + enum Role {
82 + User,
83 + Assistant,
84 + System,
85 + /// Banner art — each character gets its own color.
86 + Banner,
87 + }
88
83 -#[cfg(unix)]
84 -struct ChatMessage {
85 - role: Role,
86 - text: String,
87 - /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning).
88 - think_block: Option<String>,
89 -}
89 + struct ChatMessage {
90 + role: Role,
91 + text: String,
92 + /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning).
93 + think_block: Option<String>,
94 + }
95
91 -#[cfg(unix)]
92 -impl ChatMessage {
93 - fn user(text: impl Into<String>) -> Self {
94 - Self {
95 - role: Role::User,
96 - text: text.into(),
97 - think_block: None,
96 + impl ChatMessage {
97 + fn user(text: impl Into<String>) -> Self {
98 + Self {
99 + role: Role::User,
100 + text: text.into(),
101 + think_block: None,
102 + }
103 }
99 - }
104
101 - fn assistant(text: impl Into<String>) -> Self {
102 - let raw = text.into();
103 - let (think, visible) = strip_think_blocks(&raw);
104 - Self {
105 - role: Role::Assistant,
106 - text: visible,
107 - think_block: if think.is_empty() { None } else { Some(think) },
105 + fn assistant(text: impl Into<String>) -> Self {
106 + let raw = text.into();
107 + let (think, visible) = super::strip_think_blocks(&raw);
108 + Self {
109 + role: Role::Assistant,
110 + text: visible,
111 + think_block: if think.is_empty() { None } else { Some(think) },
112 + }
113 }
109 - }
114
111 - fn system(text: impl Into<String>) -> Self {
112 - Self {
113 - role: Role::System,
114 - text: text.into(),
115 - think_block: None,
115 + fn system(text: impl Into<String>) -> Self {
116 + Self {
117 + role: Role::System,
118 + text: text.into(),
119 + think_block: None,
120 + }
121 }
117 - }
122
119 - fn banner(text: impl Into<String>) -> Self {
120 - Self {
121 - role: Role::Banner,
122 - text: text.into(),
123 - think_block: None,
123 + fn banner(text: impl Into<String>) -> Self {
124 + Self {
125 + role: Role::Banner,
126 + text: text.into(),
127 + think_block: None,
128 + }
129 }
130 }
126 -}
131
128 -// ── Inference updates from background task ───────────────────────────────────
132 + // ── Inference updates from background task ────────────────────────────────
133
130 -/// Messages sent from the spawned inference task back to the event loop.
131 -#[cfg(unix)]
132 -enum InferenceUpdate {
133 - /// The model is calling a tool — show its name in the chat.
134 - ToolUse(String),
135 - /// The model produced a final text response.
136 - Response(String),
137 - /// Something went wrong during inference.
138 - Error(String),
139 -}
134 + /// Messages sent from the spawned inference task back to the event loop.
135 + enum InferenceUpdate {
136 + /// The model is calling a tool — show its name in the chat.
137 + ToolUse(String),
138 + /// The model produced a final text response.
139 + Response(String),
140 + /// Something went wrong during inference.
141 + Error(String),
142 + }
143
141 -#[cfg(unix)]
142 -enum ModelLoadUpdate {
143 - Loaded(String),
144 - Error(String),
145 -}
144 + enum ModelLoadUpdate {
145 + Loaded(String),
146 + Error(String),
147 + }
148
147 -// ── App state ─────────────────────────────────────────────────────────────────
148 -
149 -struct App {
150 - messages: Vec<ChatMessage>,
151 - input: String,
152 - cursor: usize,
153 - scroll_offset: u16,
154 - stream_rx: Option<mpsc::Receiver<StreamChunk>>,
155 - stream_buf: String,
156 - /// Channel for receiving results from the background inference task.
157 - inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
158 - /// Channel for receiving results from a model switch.
159 - model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
160 - /// True while waiting for inference to finish.
161 - thinking: bool,
162 - /// Counter driving the thinking spinner animation.
163 - thinking_tick: u8,
164 - quit: bool,
165 - /// Flips every few ticks while streaming to make the cursor blink.
166 - blink_on: bool,
167 - blink_counter: u8,
168 - /// True while a model switch is in progress.
169 - switching_model: bool,
170 - /// Tool-calling flag for the model currently being loaded in the background.
171 - /// Applied to `app.tool_calling` when `ModelLoadUpdate::Loaded` arrives.
172 - pending_tool_calling: Option<bool>,
173 - /// Set to true when the user cancels a model switch with Ctrl+C.
174 - /// Suppresses the "loader task disconnected" error message that would
175 - /// otherwise appear when we drop model_load_rx to abort the switch.
176 - model_load_cancelled: bool,
177 -
178 - // ── Loading-phase state ───────────────────────────────────────────────────
179 - /// True while the model is still loading; switches to false on completion.
180 - is_loading: bool,
181 - /// Monotonic counter incremented on every animation tick. Drives the
182 - /// braille spinner shown during loading.
183 - load_tick: u32,
184 - /// Set when model loading fails; keeps the loading view up with the error.
185 - load_error: Option<String>,
186 - /// When loading started — drives the elapsed-time counter.
187 - load_start: Instant,
188 - /// Display name of the model being loaded (shown in the spinner line).
189 - load_model_name: String,
190 -
191 - // ── Model picker state ────────────────────────────────────────────────────
192 - show_model_picker: bool,
193 - model_picker_index: usize,
194 - model_picker_items: Vec<ModelPickerItem>,
195 - current_model_name: String,
196 - /// Whether the currently loaded model supports tool calling.
197 - tool_calling: bool,
198 -
199 - // ── Model-switch download progress ────────────────────────────────────────
200 - /// The model_id of the model currently being downloaded/switched to.
201 - /// `None` when no switch is in progress.
202 - switching_model_id: Option<String>,
203 - /// Bytes on disk / expected bytes for the in-progress download.
204 - /// Updated every 100 ms tick while `switching_model` is true and the
205 - /// selected model was not yet cached.
206 - download_progress: Option<(u64, u64)>,
207 -}
149 + // ── App state ─────────────────────────────────────────────────────────────
150 +
151 + struct App {
152 + messages: Vec<ChatMessage>,
153 + input: String,
154 + cursor: usize,
155 + scroll_offset: u16,
156 + stream_rx: Option<mpsc::Receiver<StreamChunk>>,
157 + stream_buf: String,
158 + /// Channel for receiving results from the background inference task.
159 + inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
160 + /// Channel for receiving results from a model switch.
161 + model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
162 + /// True while waiting for inference to finish.
163 + thinking: bool,
164 + /// Counter driving the thinking spinner animation.
165 + thinking_tick: u8,
166 + quit: bool,
167 + /// Flips every few ticks while streaming to make the cursor blink.
168 + blink_on: bool,
169 + blink_counter: u8,
170 + /// True while a model switch is in progress.
171 + switching_model: bool,
172 + /// Tool-calling flag for the model currently being loaded in the background.
173 + /// Applied to `app.tool_calling` when `ModelLoadUpdate::Loaded` arrives.
174 + pending_tool_calling: Option<bool>,
175 + /// Set to true when the user cancels a model switch with Ctrl+C.
176 + /// Suppresses the "loader task disconnected" error message that would
177 + /// otherwise appear when we drop model_load_rx to abort the switch.
178 + model_load_cancelled: bool,
179 +
180 + // ── Loading-phase state ───────────────────────────────────────────────
181 + /// True while the model is still loading; switches to false on completion.
182 + is_loading: bool,
183 + /// Monotonic counter incremented on every animation tick. Drives the
184 + /// braille spinner shown during loading.
185 + load_tick: u32,
186 + /// Set when model loading fails; keeps the loading view up with the error.
187 + load_error: Option<String>,
188 + /// When loading started — drives the elapsed-time counter.
189 + load_start: Instant,
190 + /// Display name of the model being loaded (shown in the spinner line).
191 + load_model_name: String,
192 +
193 + // ── Model picker state ────────────────────────────────────────────────
194 + show_model_picker: bool,
195 + model_picker_index: usize,
196 + model_picker_items: Vec<ModelPickerItem>,
197 + current_model_name: String,
198 + /// Whether the currently loaded model supports tool calling.
199 + tool_calling: bool,
200 +
201 + // ── Model-switch download progress ────────────────────────────────────
202 + /// The model_id of the model currently being downloaded/switched to.
203 + /// `None` when no switch is in progress.
204 + switching_model_id: Option<String>,
205 + /// Bytes on disk / expected bytes for the in-progress download.
206 + /// Updated every 100 ms tick while `switching_model` is true and the
207 + /// selected model was not yet cached.
208 + download_progress: Option<(u64, u64)>,
209 + }
210
209 -const BANNER_ART: &str = "\
211 + const BANNER_ART: &str = "\
212 77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777
213 77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777
214 1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111
@@ -221,491 +223,492 @@ const BANNER_ART: &str = "\
223 55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
224 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
225
224 -/// Spinner frames for the "thinking" animation.
225 -const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
226 -
227 -impl App {
228 - fn new(load_model_name: String) -> Self {
229 - let items = build_model_picker_items();
230 - let tool_calling = items
231 - .iter()
232 - .find(|m| m.display_name == load_model_name)
233 - .map(|m| m.tool_calling)
234 - .unwrap_or(true);
235 - Self {
236 - messages: Vec::new(),
237 - input: String::new(),
238 - cursor: 0,
239 - scroll_offset: 0,
240 - stream_rx: None,
241 - stream_buf: String::new(),
242 - inference_rx: None,
243 - model_load_rx: None,
244 - thinking: false,
245 - thinking_tick: 0,
246 - quit: false,
247 - blink_on: true,
248 - blink_counter: 0,
249 - switching_model: false,
250 - pending_tool_calling: None,
251 - model_load_cancelled: false,
252 - switching_model_id: None,
253 - download_progress: None,
254 - is_loading: true,
255 - load_tick: 0,
256 - load_error: None,
257 - load_start: Instant::now(),
258 - load_model_name: load_model_name.clone(),
259 - show_model_picker: false,
260 - model_picker_index: 0,
261 - model_picker_items: items,
262 - current_model_name: crate::setup::load_selected_model_name()
263 - .unwrap_or_else(|| load_model_name.clone()),
264 - tool_calling,
226 + /// Spinner frames for the "thinking" animation.
227 + const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
228 +
229 + impl App {
230 + fn new(load_model_name: String) -> Self {
231 + let items = build_model_picker_items();
232 + let tool_calling = items
233 + .iter()
234 + .find(|m| m.display_name == load_model_name)
235 + .map(|m| m.tool_calling)
236 + .unwrap_or(true);
237 + Self {
238 + messages: Vec::new(),
239 + input: String::new(),
240 + cursor: 0,
241 + scroll_offset: 0,
242 + stream_rx: None,
243 + stream_buf: String::new(),
244 + inference_rx: None,
245 + model_load_rx: None,
246 + thinking: false,
247 + thinking_tick: 0,
248 + quit: false,
249 + blink_on: true,
250 + blink_counter: 0,
251 + switching_model: false,
252 + pending_tool_calling: None,
253 + model_load_cancelled: false,
254 + switching_model_id: None,
255 + download_progress: None,
256 + is_loading: true,
257 + load_tick: 0,
258 + load_error: None,
259 + load_start: Instant::now(),
260 + load_model_name: load_model_name.clone(),
261 + show_model_picker: false,
262 + model_picker_index: 0,
263 + model_picker_items: items,
264 + current_model_name: crate::setup::load_selected_model_name()
265 + .unwrap_or_else(|| load_model_name.clone()),
266 + tool_calling,
267 + }
268 }
266 - }
269
268 - /// True when either streaming tokens or waiting for inference.
269 - fn is_busy(&self) -> bool {
270 - self.is_streaming() || self.thinking || self.switching_model
271 - }
270 + /// True when either streaming tokens or waiting for inference.
271 + fn is_busy(&self) -> bool {
272 + self.is_streaming() || self.thinking || self.switching_model
273 + }
274
273 - fn switching_frame(&self) -> &'static str {
274 - let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
275 - THINKING_FRAMES[idx]
276 - }
275 + fn switching_frame(&self) -> &'static str {
276 + let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
277 + THINKING_FRAMES[idx]
278 + }
279
278 - fn is_streaming(&self) -> bool {
279 - self.stream_rx.is_some()
280 - }
280 + fn is_streaming(&self) -> bool {
281 + self.stream_rx.is_some()
282 + }
283
282 - fn finalize_stream(&mut self) {
283 - self.stream_rx = None;
284 - if !self.stream_buf.is_empty() {
285 - let text = std::mem::take(&mut self.stream_buf);
286 - self.messages.push(ChatMessage::assistant(text));
284 + fn finalize_stream(&mut self) {
285 + self.stream_rx = None;
286 + if !self.stream_buf.is_empty() {
287 + let text = std::mem::take(&mut self.stream_buf);
288 + self.messages.push(ChatMessage::assistant(text));
289 + }
290 + self.blink_on = false;
291 }
288 - self.blink_on = false;
289 - }
292
291 - fn push_stream_delta(&mut self, delta: &str) {
292 - self.stream_buf.push_str(delta);
293 - self.blink_counter = self.blink_counter.wrapping_add(1);
294 - self.blink_on = self.blink_counter % 4 < 2;
295 - }
293 + fn push_stream_delta(&mut self, delta: &str) {
294 + self.stream_buf.push_str(delta);
295 + self.blink_counter = self.blink_counter.wrapping_add(1);
296 + self.blink_on = self.blink_counter % 4 < 2;
297 + }
298
297 - fn start_thinking(&mut self) {
298 - self.thinking = true;
299 - self.thinking_tick = 0;
300 - }
299 + fn start_thinking(&mut self) {
300 + self.thinking = true;
301 + self.thinking_tick = 0;
302 + }
303
302 - fn stop_thinking(&mut self) {
303 - self.thinking = false;
304 - self.inference_rx = None;
305 - }
304 + fn stop_thinking(&mut self) {
305 + self.thinking = false;
306 + self.inference_rx = None;
307 + }
308
307 - fn tick_thinking(&mut self) {
308 - self.thinking_tick = self.thinking_tick.wrapping_add(1);
309 - }
309 + fn tick_thinking(&mut self) {
310 + self.thinking_tick = self.thinking_tick.wrapping_add(1);
311 + }
312
311 - fn thinking_frame(&self) -> &'static str {
312 - let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
313 - THINKING_FRAMES[idx]
314 - }
313 + fn thinking_frame(&self) -> &'static str {
314 + let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
315 + THINKING_FRAMES[idx]
316 + }
317
316 - /// Advance the spinner tick counter.
317 - fn tick(&mut self) {
318 - self.load_tick = self.load_tick.wrapping_add(1);
319 - }
318 + /// Advance the spinner tick counter.
319 + fn tick(&mut self) {
320 + self.load_tick = self.load_tick.wrapping_add(1);
321 + }
322
321 - /// Poll the HF cache directory for the model being switched to and update
322 - /// `download_progress`. Called on every 100 ms tick while switching.
323 - fn poll_download_progress(&mut self) {
324 - let Some(ref model_id) = self.switching_model_id else {
325 - return;
326 - };
327 - let cache_path = onde::hf_cache::model_cache_path(model_id);
328 - let downloaded = cache_path
329 - .as_ref()
330 - .filter(|p| p.exists())
331 - .map(|p| dir_size_recursive(p))
332 - .unwrap_or(0);
333 - let expected = onde::inference::models::SUPPORTED_MODEL_INFO
334 - .iter()
335 - .find(|m| m.id == model_id.as_str())
336 - .map(|m| m.expected_size_bytes)
337 - .unwrap_or(0);
338 - self.download_progress = Some((downloaded, expected));
339 - }
323 + /// Poll the HF cache directory for the model being switched to and update
324 + /// `download_progress`. Called on every 100 ms tick while switching.
325 + fn poll_download_progress(&mut self) {
326 + let Some(ref model_id) = self.switching_model_id else {
327 + return;
328 + };
329 + let cache_path = onde::hf_cache::model_cache_path(model_id);
330 + let downloaded = cache_path
331 + .as_ref()
332 + .filter(|p| p.exists())
333 + .map(|p| dir_size_recursive(p))
334 + .unwrap_or(0);
335 + let expected = onde::inference::models::SUPPORTED_MODEL_INFO
336 + .iter()
337 + .find(|m| m.id == model_id.as_str())
338 + .map(|m| m.expected_size_bytes)
339 + .unwrap_or(0);
340 + self.download_progress = Some((downloaded, expected));
341 + }
342
341 - /// Transition from loading phase to normal chat.
342 - /// Adds the banner art and welcome messages to the message log.
343 - fn finish_loading(&mut self) {
344 - self.is_loading = false;
345 - for line in BANNER_ART.lines() {
346 - self.messages.push(ChatMessage::banner(line));
343 + /// Transition from loading phase to normal chat.
344 + /// Adds the banner art and welcome messages to the message log.
345 + fn finish_loading(&mut self) {
346 + self.is_loading = false;
347 + for line in BANNER_ART.lines() {
348 + self.messages.push(ChatMessage::banner(line));
349 + }
350 + self.messages.push(ChatMessage::system(""));
351 + self.messages.push(ChatMessage::system(
352 + "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
353 + ));
354 + self.messages.push(ChatMessage::system(format!(
355 + "Current model: {}",
356 + self.current_model_name
357 + )));
358 + self.messages
359 + .push(ChatMessage::system("Type /help for commands."));
360 }
348 - self.messages.push(ChatMessage::system(""));
349 - self.messages.push(ChatMessage::system(
350 - "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
351 - ));
352 - self.messages.push(ChatMessage::system(format!(
353 - "Current model: {}",
354 - self.current_model_name
355 - )));
356 - self.messages
357 - .push(ChatMessage::system("Type /help for commands."));
358 - }
361
360 - /// Record a loading error. The loading view stays visible so the user can
361 - /// read the message before pressing Ctrl+C.
362 - fn set_load_error(&mut self, error: String) {
363 - self.load_error = Some(error);
364 - // is_loading stays true so render_loading() keeps rendering.
365 - }
362 + /// Record a loading error. The loading view stays visible so the user can
363 + /// read the message before pressing Ctrl+C.
364 + fn set_load_error(&mut self, error: String) {
365 + self.load_error = Some(error);
366 + // is_loading stays true so render_loading() keeps rendering.
367 + }
368
367 - fn open_model_picker(&mut self, engine: &ChatEngine) {
368 - let current = crate::setup::load_selected_model();
369 - let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| {
370 - futures::executor::block_on(engine.info())
371 - .model_name
372 - .unwrap_or_else(|| self.current_model_name.clone())
373 - });
369 + fn open_model_picker(&mut self, engine: &ChatEngine) {
370 + let current = crate::setup::load_selected_model();
371 + let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| {
372 + futures::executor::block_on(engine.info())
373 + .model_name
374 + .unwrap_or_else(|| self.current_model_name.clone())
375 + });
376
375 - self.model_picker_items = build_model_picker_items();
376 - self.model_picker_index = current
377 - .as_ref()
378 - .and_then(|selected| {
379 - self.model_picker_items.iter().position(|item| {
380 - item.config.model_id == selected.model_id
381 - && item
382 - .config
383 - .files
384 - .iter()
385 - .any(|file| file == &selected.gguf_file)
377 + self.model_picker_items = build_model_picker_items();
378 + self.model_picker_index = current
379 + .as_ref()
380 + .and_then(|selected| {
381 + self.model_picker_items.iter().position(|item| {
382 + item.config.model_id == selected.model_id
383 + && item
384 + .config
385 + .files
386 + .iter()
387 + .any(|file| file == &selected.gguf_file)
388 + })
389 })
387 - })
388 - .or_else(|| {
389 - self.model_picker_items
390 - .iter()
391 - .position(|item| item.display_name == current_name)
392 - })
393 - .unwrap_or(0);
394 - self.show_model_picker = true;
395 - }
396 -
397 - fn close_model_picker(&mut self) {
398 - self.show_model_picker = false;
399 - }
400 -
401 - fn move_model_picker_up(&mut self) {
402 - if self.model_picker_items.is_empty() {
403 - return;
404 - }
405 - if self.model_picker_index == 0 {
406 - self.model_picker_index = self.model_picker_items.len().saturating_sub(1);
407 - } else {
408 - self.model_picker_index -= 1;
390 + .or_else(|| {
391 + self.model_picker_items
392 + .iter()
393 + .position(|item| item.display_name == current_name)
394 + })
395 + .unwrap_or(0);
396 + self.show_model_picker = true;
397 }
410 - }
398
412 - fn move_model_picker_down(&mut self) {
413 - if self.model_picker_items.is_empty() {
414 - return;
399 + fn close_model_picker(&mut self) {
400 + self.show_model_picker = false;
401 }
416 - self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
417 - }
402
419 - /// Total lines the messages area would need (rough estimate for scrolling).
420 - fn total_message_lines(&self, width: u16) -> u16 {
421 - if width == 0 {
422 - return 0;
423 - }
424 - let w = width.saturating_sub(2) as usize; // subtract border columns
425 - let mut lines: u16 = 0;
426 - for msg in &self.messages {
427 - lines += wrapped_line_count(&msg.text, msg.role, w);
403 + fn move_model_picker_up(&mut self) {
404 + if self.model_picker_items.is_empty() {
405 + return;
406 + }
407 + if self.model_picker_index == 0 {
408 + self.model_picker_index = self.model_picker_items.len().saturating_sub(1);
409 + } else {
410 + self.model_picker_index -= 1;
411 + }
412 }
429 - // count any in-progress streaming text too
430 - if !self.stream_buf.is_empty() {
431 - lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w);
413 +
414 + fn move_model_picker_down(&mut self) {
415 + if self.model_picker_items.is_empty() {
416 + return;
417 + }
418 + self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
419 }
433 - // thinking / switching indicator
434 - if self.thinking || self.switching_model {
435 - lines += 1;
420 +
421 + /// Total lines the messages area would need (rough estimate for scrolling).
422 + fn total_message_lines(&self, width: u16) -> u16 {
423 + if width == 0 {
424 + return 0;
425 + }
426 + let w = width.saturating_sub(2) as usize; // subtract border columns
427 + let mut lines: u16 = 0;
428 + for msg in &self.messages {
429 + lines += wrapped_line_count(&msg.text, msg.role, w);
430 + }
431 + // count any in-progress streaming text too
432 + if !self.stream_buf.is_empty() {
433 + lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w);
434 + }
435 + // thinking / switching indicator
436 + if self.thinking || self.switching_model {
437 + lines += 1;
438 + }
439 + lines
440 }
437 - lines
438 - }
441
440 - fn auto_scroll(&mut self, visible_height: u16, width: u16) {
441 - let total = self.total_message_lines(width);
442 - if total > visible_height {
443 - self.scroll_offset = total - visible_height;
444 - } else {
445 - self.scroll_offset = 0;
442 + fn auto_scroll(&mut self, visible_height: u16, width: u16) {
443 + let total = self.total_message_lines(width);
444 + if total > visible_height {
445 + self.scroll_offset = total - visible_height;
446 + } else {
447 + self.scroll_offset = 0;
448 + }
449 }
450 }
448 -}
449 -
450 -/// How many terminal rows a message takes up after line-wrapping.
451 -fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
452 - let prefix_len = match role {
453 - Role::User => 6, // "you > "
454 - Role::Assistant => 8, // "siGit > "
455 - Role::System | Role::Banner => 0,
456 - };
457 - let effective = if width > prefix_len {
458 - width - prefix_len
459 - } else {
460 - 1
461 - };
451
463 - let mut count: u16 = 0;
464 - for line in text.split('\n') {
465 - if line.is_empty() {
466 - count += 1;
452 + /// How many terminal rows a message takes up after line-wrapping.
453 + fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
454 + let prefix_len = match role {
455 + Role::User => 6, // "you > "
456 + Role::Assistant => 8, // "siGit > "
457 + Role::System | Role::Banner => 0,
458 + };
459 + let effective = if width > prefix_len {
460 + width - prefix_len
461 } else {
468 - count += ((line.len() as f64) / (effective as f64)).ceil() as u16;
462 + 1
463 + };
464 +
465 + let mut count: u16 = 0;
466 + for line in text.split('\n') {
467 + if line.is_empty() {
468 + count += 1;
469 + } else {
470 + count += ((line.len() as f64) / (effective as f64)).ceil() as u16;
471 + }
472 }
473 + count.max(1)
474 }
471 - count.max(1)
472 -}
475
474 -// ── Model table ──────────────────────────────────────────────────────────────
476 + // ── Model table ──────────────────────────────────────────────────────────
477 + //
478 + // ModelSource, ModelPickerItem, and build_model_picker_items live in
479 + // crate::models so they are available on all platforms (including Windows),
480 + // not just unix where this chat module is compiled.
481
476 -// ModelSource, ModelPickerItem, and build_model_picker_items live in
477 -// crate::models so they are available on all platforms (including Windows),
478 -// not just unix where this chat module is compiled.
482 + fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
483 + let popup = centered_rect(82, 72, area);
484
480 -fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
481 - let popup = centered_rect(82, 72, area);
485 + // Erase whatever is behind the popup so the panel is fully readable.
486 + frame.render_widget(Clear, popup);
487
483 - // Erase whatever is behind the popup so the panel is fully readable.
484 - frame.render_widget(Clear, popup);
488 + let block = Block::default()
489 + .title(" Select a model… ")
490 + .borders(Borders::ALL)
491 + .border_style(Style::default().fg(Color::DarkGray))
492 + .style(Style::default().bg(Color::Black));
493
486 - let block = Block::default()
487 - .title(" Select a model… ")
488 - .borders(Borders::ALL)
489 - .border_style(Style::default().fg(Color::DarkGray))
490 - .style(Style::default().bg(Color::Black));
494 + let inner = block.inner(popup);
495 + frame.render_widget(block, popup);
496
492 - let inner = block.inner(popup);
493 - frame.render_widget(block, popup);
497 + let mut lines = Vec::new();
498 + let mut last_section: Option<ModelSource> = None;
499
495 - let mut lines = Vec::new();
496 - let mut last_section: Option<ModelSource> = None;
500 + for (index, item) in app.model_picker_items.iter().enumerate() {
501 + if last_section != Some(item.source) {
502 + if last_section.is_some() {
503 + lines.push(Line::from("").style(Style::default().bg(Color::Black)));
504 + }
505
498 - for (index, item) in app.model_picker_items.iter().enumerate() {
499 - if last_section != Some(item.source) {
500 - if last_section.is_some() {
501 - lines.push(Line::from("").style(Style::default().bg(Color::Black)));
506 + let (section_mark, section_name, section_style) = match item.source {
507 + ModelSource::Onde => (
508 + "◉",
509 + "Onde Inference",
510 + Style::default()
511 + .fg(Color::Green)
512 + .bg(Color::Black)
513 + .add_modifier(Modifier::BOLD),
514 + ),
515 + ModelSource::HuggingFace => (
516 + "○",
517 + "Hugging Face cache",
518 + Style::default()
519 + .fg(Color::Cyan)
520 + .bg(Color::Black)
521 + .add_modifier(Modifier::BOLD),
522 + ),
523 + ModelSource::Available => (
524 + "↓",
525 + "Available for download",
526 + Style::default()
527 + .fg(Color::Blue)
528 + .bg(Color::Black)
529 + .add_modifier(Modifier::BOLD),
530 + ),
531 + ModelSource::Fallback => (
532 + "◎",
533 + "Fallback",
534 + Style::default()
535 + .fg(Color::Yellow)
536 + .bg(Color::Black)
537 + .add_modifier(Modifier::BOLD),
538 + ),
539 + };
540 +
541 + lines.push(
542 + Line::from(vec![
543 + Span::styled(format!("{section_mark} "), section_style),
544 + Span::styled(section_name, section_style),
545 + ])
546 + .style(Style::default().bg(Color::Black)),
547 + );
548 + last_section = Some(item.source);
549 }
550
504 - let (section_mark, section_name, section_style) = match item.source {
505 - ModelSource::Onde => (
506 - "◉",
507 - "Onde Inference",
508 - Style::default()
509 - .fg(Color::Green)
510 - .bg(Color::Black)
511 - .add_modifier(Modifier::BOLD),
512 - ),
513 - ModelSource::HuggingFace => (
514 - "○",
515 - "Hugging Face cache",
516 - Style::default()
517 - .fg(Color::Cyan)
518 - .bg(Color::Black)
519 - .add_modifier(Modifier::BOLD),
520 - ),
521 - ModelSource::Available => (
522 - "↓",
523 - "Available for download",
524 - Style::default()
525 - .fg(Color::Blue)
526 - .bg(Color::Black)
527 - .add_modifier(Modifier::BOLD),
528 - ),
529 - ModelSource::Fallback => (
530 - "◎",
531 - "Fallback",
532 - Style::default()
533 - .fg(Color::Yellow)
534 - .bg(Color::Black)
535 - .add_modifier(Modifier::BOLD),
536 - ),
551 + let selected = index == app.model_picker_index;
552 + let current = item.display_name == app.current_model_name;
553 + let marker = if selected { "› " } else { " " };
554 + let tool_badge = if item.tool_calling {
555 + " ✓ tool calling"
556 + } else {
557 + ""
558 };
559 + let health_badge = match item.cache_health {
560 + ModelCacheHealth::Complete => "",
561 + ModelCacheHealth::Incomplete => " ! incomplete cache",
562 + ModelCacheHealth::NotDownloaded => " ↓ download",
563 + };
564 + let current_badge = if current { " ← current" } else { "" };
565 + let disabled_badge = match item.cache_health {
566 + ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "",
567 + ModelCacheHealth::Incomplete => " (unselectable)",
568 + };
569 + let brand_mark = match item.source {
570 + ModelSource::Onde => "◉",
571 + ModelSource::HuggingFace => "○",
572 + ModelSource::Available => "↓",
573 + ModelSource::Fallback => "◎",
574 + };
575 + let source = format!(" [{} {}]", brand_mark, item.source_label);
576
539 - lines.push(
540 - Line::from(vec![
541 - Span::styled(format!("{section_mark} "), section_style),
542 - Span::styled(section_name, section_style),
543 - ])
544 - .style(Style::default().bg(Color::Black)),
545 - );
546 - last_section = Some(item.source);
547 - }
548 -
549 - let selected = index == app.model_picker_index;
550 - let current = item.display_name == app.current_model_name;
551 - let marker = if selected { "› " } else { " " };
552 - let tool_badge = if item.tool_calling {
553 - " ✓ tool calling"
554 - } else {
555 - ""
556 - };
557 - let health_badge = match item.cache_health {
558 - ModelCacheHealth::Complete => "",
559 - ModelCacheHealth::Incomplete => " ! incomplete cache",
560 - ModelCacheHealth::NotDownloaded => " ↓ download",
561 - };
562 - let current_badge = if current { " ← current" } else { "" };
563 - let disabled_badge = match item.cache_health {
564 - ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "",
565 - ModelCacheHealth::Incomplete => " (unselectable)",
566 - };
567 - let brand_mark = match item.source {
568 - ModelSource::Onde => "◉",
569 - ModelSource::HuggingFace => "○",
570 - ModelSource::Available => "↓",
571 - ModelSource::Fallback => "◎",
572 - };
573 - let source = format!(" [{} {}]", brand_mark, item.source_label);
577 + let base_style = if selected {
578 + Style::default().fg(Color::Black).bg(Color::Green)
579 + } else {
580 + Style::default().fg(Color::White).bg(Color::Black)
581 + };
582
575 - let base_style = if selected {
576 - Style::default().fg(Color::Black).bg(Color::Green)
577 - } else {
578 - Style::default().fg(Color::White).bg(Color::Black)
579 - };
583 + let source_style = if selected {
584 + Style::default().fg(Color::Black).bg(Color::Green)
585 + } else {
586 + match item.source {
587 + ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black),
588 + ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
589 + ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black),
590 + ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
591 + }
592 + };
593
581 - let source_style = if selected {
582 - Style::default().fg(Color::Black).bg(Color::Green)
583 - } else {
584 - match item.source {
585 - ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black),
586 - ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
587 - ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black),
588 - ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
589 - }
590 - };
594 + let health_style = if selected {
595 + Style::default().fg(Color::Red).bg(Color::Green)
596 + } else {
597 + Style::default().fg(Color::Red).bg(Color::Black)
598 + };
599
592 - let health_style = if selected {
593 - Style::default().fg(Color::Red).bg(Color::Green)
594 - } else {
595 - Style::default().fg(Color::Red).bg(Color::Black)
596 - };
600 + lines.push(Line::from(vec![
601 + Span::styled(
602 + format!("{marker}{} {}", item.display_name, item.description),
603 + base_style,
604 + ),
605 + Span::styled(
606 + tool_badge.to_string(),
607 + if selected {
608 + Style::default().fg(Color::Black).bg(Color::Green)
609 + } else {
610 + Style::default().fg(Color::Green).bg(Color::Black)
611 + },
612 + ),
613 + Span::styled(health_badge.to_string(), health_style),
614 + Span::styled(
615 + disabled_badge.to_string(),
616 + if selected {
617 + Style::default().fg(Color::Black).bg(Color::Green)
618 + } else {
619 + Style::default().fg(Color::DarkGray).bg(Color::Black)
620 + },
621 + ),
622 + Span::styled(
623 + current_badge.to_string(),
624 + if selected {
625 + Style::default().fg(Color::Black).bg(Color::Green)
626 + } else {
627 + Style::default().fg(Color::Cyan).bg(Color::Black)
628 + },
629 + ),
630 + Span::styled(source, source_style),
631 + ]));
632 + }
633
598 - lines.push(Line::from(vec![
599 - Span::styled(
600 - format!("{marker}{} {}", item.display_name, item.description),
601 - base_style,
602 - ),
603 - Span::styled(
604 - tool_badge.to_string(),
605 - if selected {
606 - Style::default().fg(Color::Black).bg(Color::Green)
607 - } else {
608 - Style::default().fg(Color::Green).bg(Color::Black)
609 - },
610 - ),
611 - Span::styled(health_badge.to_string(), health_style),
612 - Span::styled(
613 - disabled_badge.to_string(),
614 - if selected {
615 - Style::default().fg(Color::Black).bg(Color::Green)
616 - } else {
617 - Style::default().fg(Color::DarkGray).bg(Color::Black)
618 - },
619 - ),
620 - Span::styled(
621 - current_badge.to_string(),
622 - if selected {
623 - Style::default().fg(Color::Black).bg(Color::Green)
624 - } else {
625 - Style::default().fg(Color::Cyan).bg(Color::Black)
626 - },
627 - ),
628 - Span::styled(source, source_style),
629 - ]));
634 + frame.render_widget(
635 + Paragraph::new(lines)
636 + .wrap(Wrap { trim: false })
637 + .style(Style::default().bg(Color::Black)),
638 + inner,
639 + );
640 }
641
632 - frame.render_widget(
633 - Paragraph::new(lines)
634 - .wrap(Wrap { trim: false })
635 - .style(Style::default().bg(Color::Black)),
636 - inner,
637 - );
638 -}
642 + fn centered_rect(
643 + percent_x: u16,
644 + percent_y: u16,
645 + area: ratatui::layout::Rect,
646 + ) -> ratatui::layout::Rect {
647 + let vertical = Layout::vertical([
648 + Constraint::Percentage((100 - percent_y) / 2),
649 + Constraint::Percentage(percent_y),
650 + Constraint::Percentage((100 - percent_y) / 2),
651 + ])
652 + .split(area);
653
640 -fn centered_rect(
641 - percent_x: u16,
642 - percent_y: u16,
643 - area: ratatui::layout::Rect,
644 -) -> ratatui::layout::Rect {
645 - let vertical = Layout::vertical([
646 - Constraint::Percentage((100 - percent_y) / 2),
647 - Constraint::Percentage(percent_y),
648 - Constraint::Percentage((100 - percent_y) / 2),
649 - ])
650 - .split(area);
651 -
652 - Layout::horizontal([
653 - Constraint::Percentage((100 - percent_x) / 2),
654 - Constraint::Percentage(percent_x),
655 - Constraint::Percentage((100 - percent_x) / 2),
656 - ])
657 - .split(vertical[1])[1]
658 -}
654 + Layout::horizontal([
655 + Constraint::Percentage((100 - percent_x) / 2),
656 + Constraint::Percentage(percent_x),
657 + Constraint::Percentage((100 - percent_x) / 2),
658 + ])
659 + .split(vertical[1])[1]
660 + }
661
660 -// ── Slash commands ────────────────────────────────────────────────────────────
662 + // ── Slash commands ────────────────────────────────────────────────────────
663
662 -enum SlashCommand {
663 - Help,
664 - Clear,
665 - Status,
666 - /// `/models` opens the model picker. `/models N` still works as a shortcut.
667 - Models(Option<usize>),
668 - Exit,
669 - Unknown(String),
670 -}
664 + enum SlashCommand {
665 + Help,
666 + Clear,
667 + Status,
668 + /// `/models` opens the model picker. `/models N` still works as a shortcut.
669 + Models(Option<usize>),
670 + Exit,
671 + Unknown(String),
672 + }
673
672 -fn parse_slash(input: &str) -> Option<SlashCommand> {
673 - let trimmed = input.trim();
674 - if !trimmed.starts_with('/') {
675 - return None;
674 + fn parse_slash(input: &str) -> Option<SlashCommand> {
675 + let trimmed = input.trim();
676 + if !trimmed.starts_with('/') {
677 + return None;
678 + }
679 + let mut parts = trimmed.splitn(2, char::is_whitespace);
680 + let cmd = parts.next().unwrap_or("");
681 + let arg = parts.next().map(|s| s.trim());
682 + Some(match cmd {
683 + "/help" => SlashCommand::Help,
684 + "/clear" => SlashCommand::Clear,
685 + "/status" => SlashCommand::Status,
686 + "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
687 + "/exit" | "/quit" | "/q" => SlashCommand::Exit,
688 + other => SlashCommand::Unknown(other.to_string()),
689 + })
690 }
677 - let mut parts = trimmed.splitn(2, char::is_whitespace);
678 - let cmd = parts.next().unwrap_or("");
679 - let arg = parts.next().map(|s| s.trim());
680 - Some(match cmd {
681 - "/help" => SlashCommand::Help,
682 - "/clear" => SlashCommand::Clear,
683 - "/status" => SlashCommand::Status,
684 - "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
685 - "/exit" | "/quit" | "/q" => SlashCommand::Exit,
686 - other => SlashCommand::Unknown(other.to_string()),
687 - })
688 -}
691
690 -// ── Rendering ─────────────────────────────────────────────────────────────────
692 + // ── Rendering ─────────────────────────────────────────────────────────────
693
692 -fn render(frame: &mut Frame, app: &mut App) {
693 - let area = frame.area();
694 + fn render(frame: &mut Frame, app: &mut App) {
695 + let area = frame.area();
696
695 - if app.is_loading {
696 - // Loading phase: title bar with spinner | loading info | footer hint.
697 - let zones = Layout::vertical([
698 - Constraint::Length(1),
699 - Constraint::Min(1),
700 - Constraint::Length(1),
701 - ])
702 - .split(area);
697 + if app.is_loading {
698 + // Loading phase: title bar with spinner | loading info | footer hint.
699 + let zones = Layout::vertical([
700 + Constraint::Length(1),
701 + Constraint::Min(1),
702 + Constraint::Length(1),
703 + ])
704 + .split(area);
705 + render_loading_title(frame, app, zones[0]);
706 + render_loading(frame, app, zones[1]);
707 + render_loading_footer(frame, zones[2]);
708 + return;
709 + }
710
704 - render_loading_title(frame, app, zones[0]);
705 - render_loading(frame, app, zones[1]);
706 - render_loading_footer(frame, zones[2]);
707 - } else {
708 - // Chat phase: title | messages | input | footer.
711 + // Normal chat phase: title | messages | input | footer.
712 let zones = Layout::vertical([
713 Constraint::Length(1),
714 Constraint::Min(1),
@@ -714,7 +717,7 @@ fn render(frame: &mut Frame, app: &mut App) {
717 ])
718 .split(area);
719
717 - render_title(frame, zones[0]);
720 + render_title(frame, app, zones[0]);
721 render_messages(frame, app, zones[1]);
722 render_input(frame, app, zones[2]);
723 render_footer(frame, app, zones[3]);
@@ -723,1015 +726,959 @@ fn render(frame: &mut Frame, app: &mut App) {
726 render_model_picker(frame, app, area);
727 }
728 }
726 -}
727 -
728 -fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) {
729 - let title = Line::from(vec![
730 - Span::styled(
731 - "siGit",
732 - Style::default()
733 - .fg(Color::Green)
734 - .add_modifier(Modifier::BOLD),
735 - ),
736 - Span::styled(" Code", Style::default().fg(Color::White)),
737 - Span::styled(
738 - format!(" v{}", env!("CARGO_PKG_VERSION")),
739 - Style::default().fg(Color::DarkGray),
740 - ),
741 - ]);
742 - frame.render_widget(
743 - Paragraph::new(title).style(Style::default().bg(Color::Black)),
744 - area,
745 - );
746 -}
747 -
748 -/// Title bar during loading: `⠹ siGit Code v0.1.1`
749 -fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
750 - const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
751 - let spinner = SPINNER[(app.load_tick as usize) % SPINNER.len()];
729
753 - let title = Line::from(vec![
754 - Span::styled(
755 - format!("{spinner} "),
756 - Style::default()
757 - .fg(Color::Yellow)
758 - .add_modifier(Modifier::BOLD),
759 - ),
760 - Span::styled(
761 - "siGit",
762 - Style::default()
763 - .fg(Color::Green)
764 - .add_modifier(Modifier::BOLD),
765 - ),
766 - Span::styled(" Code", Style::default().fg(Color::White)),
767 - Span::styled(
768 - format!(" v{}", env!("CARGO_PKG_VERSION")),
769 - Style::default().fg(Color::DarkGray),
770 - ),
771 - ]);
772 - frame.render_widget(
773 - Paragraph::new(title).style(Style::default().bg(Color::Black)),
774 - area,
775 - );
776 -}
777 -
778 -/// Loading body — model name, elapsed time, or error message.
779 -fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
780 - let elapsed = app.load_start.elapsed();
781 - let elapsed_str = if elapsed.as_secs() >= 60 {
782 - format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
783 - } else {
784 - format!("{}s", elapsed.as_secs())
785 - };
786 -
787 - let mut lines: Vec<Line<'_>> = Vec::new();
788 -
789 - if let Some(ref err) = app.load_error {
790 - lines.push(Line::from(vec![
791 - Span::styled(
792 - " ✘ ",
793 - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
794 - ),
795 - Span::styled(err.clone(), Style::default().fg(Color::Red)),
796 - ]));
797 - } else {
798 - lines.push(Line::from(vec![
799 - Span::styled(" Loading ", Style::default().fg(Color::DarkGray)),
730 + fn render_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
731 + let model_label = format!(" siGit — {} ", app.current_model_name);
732 + let tool_label = if app.tool_calling {
733 + " [tools on] "
734 + } else {
735 + " [tools off] "
736 + };
737 + let line = Line::from(vec![
738 Span::styled(
801 - app.load_model_name.clone(),
739 + model_label,
740 Style::default()
803 - .fg(Color::White)
741 + .fg(Color::Black)
742 + .bg(Color::Green)
743 .add_modifier(Modifier::BOLD),
744 ),
745 Span::styled(
807 - format!(" {elapsed_str}"),
808 - Style::default().fg(Color::DarkGray),
746 + tool_label,
747 + Style::default().fg(Color::Black).bg(Color::DarkGray),
748 ),
810 - ]));
749 + ]);
750 + frame.render_widget(Paragraph::new(line), area);
751 }
752
813 - frame.render_widget(Paragraph::new(lines), area);
814 -}
815 -
816 -/// One-line footer shown only during the loading phase.
817 -fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) {
818 - let spans = vec![
819 - Span::styled(
820 - " Ctrl+C ",
753 + fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
754 + const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
755 + let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
756 + let label = format!(" siGit {} loading {}… ", spin, app.load_model_name);
757 + let line = Line::from(Span::styled(
758 + label,
759 Style::default()
760 .fg(Color::Black)
823 - .bg(Color::DarkGray)
761 + .bg(Color::Green)
762 .add_modifier(Modifier::BOLD),
825 - ),
826 - Span::styled(" quit", Style::default().fg(Color::DarkGray)),
827 - ];
828 - frame.render_widget(
829 - Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)),
830 - area,
831 - );
832 -}
833 -
834 -fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
835 - let mut lines: Vec<Line<'_>> = Vec::new();
836 -
837 - for msg in &app.messages {
838 - render_chat_message(&mut lines, msg);
763 + ));
764 + frame.render_widget(Paragraph::new(line), area);
765 }
766
841 - // Streaming partial response.
842 - if !app.stream_buf.is_empty() || app.is_streaming() {
843 - let mut spans = vec![Span::styled(
844 - "siGit > ",
845 - Style::default()
846 - .fg(Color::Green)
847 - .add_modifier(Modifier::BOLD),
848 - )];
849 -
850 - // Split on newlines so multi-line streaming renders correctly.
851 - let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect();
852 - for (i, segment) in buf_lines.iter().enumerate() {
853 - if i > 0 {
854 - lines.push(Line::from(std::mem::take(&mut spans)));
855 - // Continuation lines get no prefix.
856 - }
857 - spans.push(Span::raw(segment.to_string()));
858 - }
859 -
860 - // Blinking block cursor while streaming.
861 - if app.is_streaming() && app.blink_on {
862 - spans.push(Span::styled("█", Style::default().fg(Color::Green)));
863 - }
767 + fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
768 + let elapsed = app.load_start.elapsed().as_secs();
769 + let elapsed_str = if elapsed < 60 {
770 + format!("{}s", elapsed)
771 + } else {
772 + format!("{}m {}s", elapsed / 60, elapsed % 60)
773 + };
774
865 - lines.push(Line::from(spans));
866 - }
775 + let content = if let Some(ref err) = app.load_error {
776 + format!(
777 + "\n\n ✗ Failed to load model after {}.\n\n {}\n\n Press Ctrl+C to exit.",
778 + elapsed_str, err
779 + )
780 + } else {
781 + format!(
782 + "\n\n Loading model, please wait… ({})\n\n The model is being initialised. This may take a moment on first run.",
783 + elapsed_str
784 + )
785 + };
786
868 - // switching-model indicator (animated spinner + optional download progress)
869 - if app.switching_model {
870 - let frame_char = app.switching_frame();
871 -
872 - let status_text = match app.download_progress {
873 - Some((downloaded, expected)) if expected > 0 => {
874 - let pct = ((downloaded as f64 / expected as f64) * 100.0).min(99.0) as u8;
875 - let bar_width: usize = 16;
876 - let filled = (pct as usize * bar_width) / 100;
877 - let empty = bar_width.saturating_sub(filled);
878 - let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(empty));
879 - format!(
880 - "{frame_char} downloading… {bar} {pct}% ({} / {})",
881 - format_size_human(downloaded),
882 - format_size_human(expected),
883 - )
884 - }
885 - Some((downloaded, 0)) if downloaded > 0 => {
886 - format!(
887 - "{frame_char} downloading… {} received",
888 - format_size_human(downloaded)
889 - )
890 - }
891 - _ => format!("{frame_char} loading model…"),
787 + let style = if app.load_error.is_some() {
788 + Style::default().fg(Color::Red)
789 + } else {
790 + Style::default().fg(Color::White)
791 };
792
894 - lines.push(Line::from(vec![
895 - Span::styled(
896 - "siGit > ",
897 - Style::default()
898 - .fg(Color::Green)
899 - .add_modifier(Modifier::BOLD),
900 - ),
901 - Span::styled(
902 - status_text,
903 - Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
904 - ),
905 - ]));
793 + frame.render_widget(
794 + Paragraph::new(content)
795 + .style(style)
796 + .wrap(Wrap { trim: false }),
797 + area,
798 + );
799 }
800
908 - // thinking indicator (animated spinner)
909 - if app.thinking {
910 - let frame_char = app.thinking_frame();
911 - lines.push(Line::from(vec![
912 - Span::styled(
913 - "siGit > ",
914 - Style::default()
915 - .fg(Color::Green)
916 - .add_modifier(Modifier::BOLD),
917 - ),
918 - Span::styled(
919 - format!("{frame_char} thinking…"),
920 - Style::default()
921 - .fg(Color::Yellow)
922 - .add_modifier(Modifier::DIM),
923 - ),
924 - ]));
801 + fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) {
802 + let line = Line::from(vec![
803 + Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
804 + Span::styled(" quit", Style::default().fg(Color::DarkGray)),
805 + ]);
806 + frame.render_widget(Paragraph::new(line), area);
807 }
808
927 - // auto-scroll
928 - app.auto_scroll(area.height, area.width);
809 + fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
810 + let inner_width = area.width.saturating_sub(2);
811 + let inner_height = area.height.saturating_sub(2);
812
930 - let paragraph = Paragraph::new(lines)
931 - .wrap(Wrap { trim: false })
932 - .scroll((app.scroll_offset, 0))
933 - .style(Style::default());
813 + app.auto_scroll(inner_height, area.width);
814
935 - frame.render_widget(paragraph, area);
936 -}
815 + let block = Block::default()
816 + .borders(Borders::ALL)
817 + .border_style(Style::default().fg(Color::DarkGray));
818
938 -fn render_chat_message<'a>(lines: &mut Vec<Line<'a>>, msg: &ChatMessage) {
939 - let text_lines: Vec<&str> = msg.text.split('\n').collect();
819 + let inner = block.inner(area);
820 + frame.render_widget(block, area);
821
941 - match msg.role {
942 - Role::User => {
943 - for (i, segment) in text_lines.iter().enumerate() {
944 - let mut spans = Vec::new();
945 - if i == 0 {
946 - spans.push(Span::styled(
947 - "you > ",
948 - Style::default()
949 - .fg(Color::Cyan)
950 - .add_modifier(Modifier::BOLD),
951 - ));
952 - }
953 - spans.push(Span::styled(
954 - segment.to_string(),
955 - Style::default().fg(Color::White),
956 - ));
957 - lines.push(Line::from(spans));
822 + let mut lines: Vec<Line> = Vec::new();
823 +
824 + for msg in &app.messages {
825 + render_chat_message(&mut lines, msg, inner_width as usize);
826 + }
827 +
828 + // In-progress streaming token buffer.
829 + if !app.stream_buf.is_empty() {
830 + let fake = ChatMessage {
831 + role: Role::Assistant,
832 + text: app.stream_buf.clone(),
833 + think_block: None,
834 + };
835 + render_chat_message(&mut lines, &fake, inner_width as usize);
836 + // blinking cursor at end
837 + if app.blink_on
838 + && let Some(last) = lines.last_mut()
839 + {
840 + last.spans
841 + .push(Span::styled("▋", Style::default().fg(Color::Green)));
842 }
843 }
960 - Role::Assistant => {
961 - // Show thinking block dimmed if present.
962 - if let Some(ref think) = msg.think_block {
963 - let think_summary = if think.len() > 120 {
964 - format!("{}…", &think[..120])
844 +
845 + // Thinking / switching spinner.
846 + if app.thinking {
847 + lines.push(Line::from(Span::styled(
848 + format!(" {} thinking…", app.thinking_frame()),
849 + Style::default().fg(Color::DarkGray),
850 + )));
851 + } else if app.switching_model {
852 + let frame_str = app.switching_frame();
853 + let progress_str = if let Some((downloaded, expected)) = app.download_progress {
854 + if expected > 0 {
855 + let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8;
856 + let dl_str = format_size_human(downloaded);
857 + let ex_str = format_size_human(expected);
858 + format!(" — {dl_str} / {ex_str} ({pct}%)")
859 + } else if downloaded > 0 {
860 + format!(" — {} downloaded", format_size_human(downloaded))
861 } else {
966 - think.clone()
967 - };
968 - lines.push(Line::from(vec![
969 - Span::styled("💭 ", Style::default().fg(Color::DarkGray)),
970 - Span::styled(
971 - think_summary,
972 - Style::default()
973 - .fg(Color::DarkGray)
974 - .add_modifier(Modifier::ITALIC),
975 - ),
976 - ]));
977 - }
862 + String::new()
863 + }
864 + } else {
865 + String::new()
866 + };
867 + lines.push(Line::from(Span::styled(
868 + format!(" {frame_str} switching model{progress_str}…"),
869 + Style::default().fg(Color::DarkGray),
870 + )));
871 + }
872 +
873 + let total_lines = lines.len() as u16;
874 + let scroll = if total_lines > inner_height {
875 + app.scroll_offset.min(total_lines - inner_height)
876 + } else {
877 + 0
878 + };
879 +
880 + frame.render_widget(
881 + Paragraph::new(lines)
882 + .scroll((scroll, 0))
883 + .wrap(Wrap { trim: false }),
884 + inner,
885 + );
886 + }
887
979 - for (i, segment) in text_lines.iter().enumerate() {
888 + fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) {
889 + match msg.role {
890 + Role::Banner => {
891 + // Each character in banner art gets its own rainbow colour.
892 + let palette = [
893 + Color::Red,
894 + Color::Yellow,
895 + Color::Green,
896 + Color::Cyan,
897 + Color::Blue,
898 + Color::Magenta,
899 + ];
900 let mut spans = Vec::new();
981 - if i == 0 {
982 - spans.push(Span::styled(
983 - "siGit > ",
984 - Style::default()
985 - .fg(Color::Green)
986 - .add_modifier(Modifier::BOLD),
987 - ));
901 + for (i, ch) in msg.text.chars().enumerate() {
902 + let color = palette[i % palette.len()];
903 + spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
904 }
989 - spans.push(Span::styled(
990 - segment.to_string(),
991 - Style::default().fg(Color::White),
992 - ));
905 lines.push(Line::from(spans));
906 }
995 - }
996 - Role::System => {
997 - for segment in &text_lines {
998 - lines.push(Line::from(Span::styled(
999 - segment.to_string(),
1000 - Style::default().fg(Color::DarkGray),
1001 - )));
907 + Role::System => {
908 + for text_line in msg.text.split('\n') {
909 + lines.push(Line::from(Span::styled(
910 + text_line.to_string(),
911 + Style::default().fg(Color::DarkGray),
912 + )));
913 + }
914 }
1003 - }
1004 - Role::Banner => {
1005 - for segment in &text_lines {
1006 - lines.push(Line::from(Span::styled(
1007 - segment.to_string(),
1008 - Style::default().fg(Color::White),
1009 - )));
915 + Role::User => {
916 + let prefix = Span::styled(
917 + "you > ".to_string(),
918 + Style::default()
919 + .fg(Color::Green)
920 + .add_modifier(Modifier::BOLD),
921 + );
922 + let mut first = true;
923 + for text_line in msg.text.split('\n') {
924 + if first {
925 + lines.push(Line::from(vec![
926 + prefix.clone(),
927 + Span::raw(text_line.to_string()),
928 + ]));
929 + first = false;
930 + } else {
931 + lines.push(Line::from(Span::raw(format!(" {text_line}"))));
932 + }
933 + }
934 + }
935 + Role::Assistant => {
936 + // If there is a think block, render it first, dimmed.
937 + if let Some(ref think) = msg.think_block {
938 + lines.push(Line::from(Span::styled(
939 + " ┌ thinking ".to_string(),
940 + Style::default().fg(Color::DarkGray),
941 + )));
942 + for think_line in think.split('\n') {
943 + lines.push(Line::from(Span::styled(
944 + format!(" │ {think_line}"),
945 + Style::default().fg(Color::DarkGray),
946 + )));
947 + }
948 + lines.push(Line::from(Span::styled(
949 + " └─────────".to_string(),
950 + Style::default().fg(Color::DarkGray),
951 + )));
952 + }
953 +
954 + let prefix = Span::styled(
955 + "siGit > ".to_string(),
956 + Style::default()
957 + .fg(Color::Cyan)
958 + .add_modifier(Modifier::BOLD),
959 + );
960 + let mut first = true;
961 + for text_line in msg.text.split('\n') {
962 + if first {
963 + lines.push(Line::from(vec![
964 + prefix.clone(),
965 + Span::raw(text_line.to_string()),
966 + ]));
967 + first = false;
968 + } else {
969 + lines.push(Line::from(Span::raw(format!(" {text_line}"))));
970 + }
971 + }
972 }
973 }
974 }
1013 -}
975
1015 -fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1016 - let block = Block::default()
1017 - .borders(Borders::TOP)
1018 - .border_style(Style::default().fg(Color::DarkGray))
1019 - .title(if app.thinking {
1020 - " thinking… "
1021 - } else if app.is_streaming() {
1022 - " streaming… "
1023 - } else {
1024 - " message "
1025 - })
1026 - .title_style(Style::default().fg(if app.is_busy() {
1027 - Color::Yellow
1028 - } else {
1029 - Color::DarkGray
1030 - }));
1031 -
1032 - let input_text = Paragraph::new(app.input.as_str())
1033 - .style(Style::default().fg(if app.is_busy() {
1034 - Color::DarkGray
1035 - } else {
1036 - Color::White
1037 - }))
1038 - .block(block);
1039 -
1040 - frame.render_widget(input_text, area);
1041 -
1042 - // Place cursor inside the input block (offset by 1 for the border).
1043 - if !app.is_busy() {
1044 - let x = area.x + app.cursor as u16 + 1;
1045 - let y = area.y + 1;
1046 - frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), y));
976 + fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
977 + let block = Block::default()
978 + .borders(Borders::ALL)
979 + .border_style(Style::default().fg(Color::DarkGray))
980 + .title(" message ");
981 +
982 + let inner = block.inner(area);
983 + frame.render_widget(block, area);
984 +
985 + let display = app.input.clone();
986 + frame.render_widget(
987 + Paragraph::new(display.clone()).wrap(Wrap { trim: false }),
988 + inner,
989 + );
990 +
991 + // Position the real terminal cursor inside the input box.
992 + let col = (app.cursor as u16) % inner.width;
993 + let row = (app.cursor as u16) / inner.width;
994 + frame.set_cursor_position(Position {
995 + x: inner.x + col,
996 + y: inner.y + row,
997 + });
998 }
1048 -}
1049 -
1050 -fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1051 - let hints: &[(&str, &str)] = if app.show_model_picker {
1052 - &[("↑↓", "select"), ("Enter", "load"), ("Esc", "close")]
1053 - } else if app.is_busy() {
1054 - &[("Ctrl+C", "cancel")]
1055 - } else {
1056 - &[
1057 - ("Enter", "send"),
1058 - ("/help", "commands"),
1059 - ("/models", "models"),
1060 - ("Ctrl+C", "quit"),
1061 - ]
1062 - };
999
1064 - let mut spans: Vec<Span<'_>> = Vec::new();
1065 - for (i, (key, label)) in hints.iter().enumerate() {
1066 - if i > 0 {
1067 - spans.push(Span::styled(" ", Style::default()));
1000 + fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1001 + let mut spans = vec![
1002 + Span::styled(
1003 + " Enter ",
1004 + Style::default().fg(Color::Black).bg(Color::Green),
1005 + ),
1006 + Span::styled(" send ", Style::default().fg(Color::DarkGray)),
1007 + Span::styled(
1008 + " /help ",
1009 + Style::default().fg(Color::Black).bg(Color::DarkGray),
1010 + ),
1011 + Span::styled(" commands ", Style::default().fg(Color::DarkGray)),
1012 + Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
1013 + Span::styled(" quit", Style::default().fg(Color::DarkGray)),
1014 + ];
1015 +
1016 + if app.thinking || app.switching_model || app.is_streaming() {
1017 + spans.push(Span::styled(
1018 + " (busy — Ctrl+C to cancel)",
1019 + Style::default().fg(Color::Yellow),
1020 + ));
1021 }
1069 - spans.push(Span::styled(
1070 - format!(" {key} "),
1071 - Style::default()
1072 - .fg(Color::White)
1073 - .bg(Color::DarkGray)
1074 - .add_modifier(Modifier::BOLD),
1075 - ));
1076 - spans.push(Span::styled(
1077 - format!(" {label}"),
1078 - Style::default().fg(Color::Gray),
1079 - ));
1080 - }
1022
1082 - frame.render_widget(
1083 - Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)),
1084 - area,
1085 - );
1086 -}
1023 + frame.render_widget(Paragraph::new(Line::from(spans)), area);
1024 + }
1025
1088 -// ── Input handling ────────────────────────────────────────────────────────────
1026 + fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
1027 + if key.kind != KeyEventKind::Press {
1028 + return None;
1029 + }
1030
1090 -fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
1091 - if key.kind != KeyEventKind::Press {
1092 - return None;
1093 - }
1031 + if app.show_model_picker {
1032 + match key.code {
1033 + KeyCode::Esc => {
1034 + app.close_model_picker();
1035 + return None;
1036 + }
1037 + KeyCode::Up => {
1038 + app.move_model_picker_up();
1039 + return None;
1040 + }
1041 + KeyCode::Down => {
1042 + app.move_model_picker_down();
1043 + return None;
1044 + }
1045 + KeyCode::Enter => {
1046 + return Some(format!("/models {}", app.model_picker_index + 1));
1047 + }
1048 + _ => return None,
1049 + }
1050 + }
1051
1095 - if app.show_model_picker {
1052 match key.code {
1097 - KeyCode::Esc => {
1098 - app.close_model_picker();
1099 - return None;
1053 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1054 + app.quit = true;
1055 + None
1056 }
1101 - KeyCode::Up => {
1102 - app.move_model_picker_up();
1103 - return None;
1104 - }
1105 - KeyCode::Down => {
1106 - app.move_model_picker_down();
1107 - return None;
1057 + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1058 + app.quit = true;
1059 + None
1060 }
1061 KeyCode::Enter => {
1110 - return Some(format!("/models {}", app.model_picker_index + 1));
1062 + if app.input.trim().is_empty() {
1063 + return None;
1064 + }
1065 + let text = app.input.drain(..).collect::<String>();
1066 + app.cursor = 0;
1067 + Some(text)
1068 }
1112 - _ => return None,
1113 - }
1114 - }
1115 -
1116 - match key.code {
1117 - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1118 - app.quit = true;
1119 - None
1120 - }
1121 - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1122 - app.quit = true;
1123 - None
1124 - }
1125 - KeyCode::Enter => {
1126 - if app.input.trim().is_empty() {
1127 - return None;
1069 + KeyCode::Backspace => {
1070 + if app.cursor > 0 {
1071 + app.cursor -= 1;
1072 + app.input.remove(app.cursor);
1073 + }
1074 + None
1075 }
1129 - let text = app.input.drain(..).collect::<String>();
1130 - app.cursor = 0;
1131 - Some(text)
1132 - }
1133 - KeyCode::Backspace => {
1134 - if app.cursor > 0 {
1135 - app.cursor -= 1;
1136 - app.input.remove(app.cursor);
1076 + KeyCode::Delete => {
1077 + if app.cursor < app.input.len() {
1078 + app.input.remove(app.cursor);
1079 + }
1080 + None
1081 }
1138 - None
1139 - }
1140 - KeyCode::Delete => {
1141 - if app.cursor < app.input.len() {
1142 - app.input.remove(app.cursor);
1082 + KeyCode::Left => {
1083 + app.cursor = app.cursor.saturating_sub(1);
1084 + None
1085 }
1144 - None
1145 - }
1146 - KeyCode::Left => {
1147 - app.cursor = app.cursor.saturating_sub(1);
1148 - None
1149 - }
1150 - KeyCode::Right => {
1151 - if app.cursor < app.input.len() {
1086 + KeyCode::Right => {
1087 + if app.cursor < app.input.len() {
1088 + app.cursor += 1;
1089 + }
1090 + None
1091 + }
1092 + KeyCode::Home => {
1093 + app.cursor = 0;
1094 + None
1095 + }
1096 + KeyCode::End => {
1097 + app.cursor = app.input.len();
1098 + None
1099 + }
1100 + KeyCode::Char(ch) => {
1101 + app.input.insert(app.cursor, ch);
1102 app.cursor += 1;
1103 + None
1104 }
1154 - None
1155 - }
1156 - KeyCode::Home => {
1157 - app.cursor = 0;
1158 - None
1159 - }
1160 - KeyCode::End => {
1161 - app.cursor = app.input.len();
1162 - None
1105 + _ => None,
1106 }
1164 - KeyCode::Char(ch) => {
1165 - app.input.insert(app.cursor, ch);
1166 - app.cursor += 1;
1167 - None
1168 - }
1169 - _ => None,
1107 }
1171 -}
1108
1173 -// ── Slash command execution ───────────────────────────────────────────────────
1174 -
1175 -async fn exec_slash<B: ratatui::backend::Backend>(
1176 - app: &mut App,
1177 - cmd: SlashCommand,
1178 - engine: Arc<ChatEngine>,
1179 - terminal: &mut ratatui::Terminal<B>,
1180 -) {
1181 - match cmd {
1182 - SlashCommand::Help => {
1183 - app.messages.push(ChatMessage::system(
1184 - "/help — show this message\n\
1185 - /models — open the model picker\n\
1186 - /models N — switch to model N\n\
1187 - /clear — wipe conversation history\n\
1188 - /status — show engine status\n\
1189 - /exit — quit chat",
1190 - ));
1191 - }
1192 - SlashCommand::Clear => {
1193 - let cleared = engine.clear_history().await;
1194 - app.messages.clear();
1195 - app.scroll_offset = 0;
1196 - app.messages.push(ChatMessage::system(format!(
1197 - "Cleared {cleared} turn(s). History is empty.",
1198 - )));
1199 - }
1200 - SlashCommand::Status => {
1201 - let info = engine.as_ref().info().await;
1202 - let model = info.model_name.as_deref().unwrap_or("(none)");
1203 - let mem = info.approx_memory.as_deref().unwrap_or("unknown");
1204 - app.messages.push(ChatMessage::system(format!(
1205 - "status: {:?} model: {} memory: {} history: {} turns",
1206 - info.status, model, mem, info.history_length,
1207 - )));
1208 - }
1209 - SlashCommand::Models(selection) => match selection {
1210 - None => {
1211 - app.open_model_picker(&engine);
1109 + // ── Slash command execution ───────────────────────────────────────────────
1110 +
1111 + async fn exec_slash<B: ratatui::backend::Backend>(
1112 + app: &mut App,
1113 + cmd: SlashCommand,
1114 + engine: Arc<ChatEngine>,
1115 + terminal: &mut ratatui::Terminal<B>,
1116 + ) {
1117 + match cmd {
1118 + SlashCommand::Help => {
1119 + app.messages.push(ChatMessage::system(
1120 + "/help — show this message\n\
1121 + /models — open the model picker\n\
1122 + /models N — switch to model N\n\
1123 + /clear — wipe conversation history\n\
1124 + /status — show engine status\n\
1125 + /exit — quit chat",
1126 + ));
1127 }
1213 - Some(n) => {
1214 - let idx = n.saturating_sub(1);
1215 - match app.model_picker_items.get(idx).cloned() {
1216 - None => {
1217 - app.messages.push(ChatMessage::system(format!(
1218 - "error: no model #{n} — type /models to see the list."
1219 - )));
1220 - }
1221 - Some(model) => {
1222 - if model.cache_health == ModelCacheHealth::Incomplete {
1223 - app.close_model_picker();
1128 + SlashCommand::Clear => {
1129 + let cleared = engine.clear_history().await;
1130 + app.messages.clear();
1131 + app.scroll_offset = 0;
1132 + app.messages.push(ChatMessage::system(format!(
1133 + "Cleared {cleared} turn(s). History is empty.",
1134 + )));
1135 + }
1136 + SlashCommand::Status => {
1137 + let info = engine.as_ref().info().await;
1138 + let model = info.model_name.as_deref().unwrap_or("(none)");
1139 + let mem = info.approx_memory.as_deref().unwrap_or("unknown");
1140 + app.messages.push(ChatMessage::system(format!(
1141 + "status: {:?} model: {} memory: {} history: {} turns",
1142 + info.status, model, mem, info.history_length,
1143 + )));
1144 + }
1145 + SlashCommand::Models(selection) => match selection {
1146 + None => {
1147 + app.open_model_picker(&engine);
1148 + }
1149 + Some(n) => {
1150 + let idx = n.saturating_sub(1);
1151 + match app.model_picker_items.get(idx).cloned() {
1152 + None => {
1153 app.messages.push(ChatMessage::system(format!(
1225 - "error: {} has an incomplete local cache and cannot be selected yet.",
1226 - model.display_name
1154 + "error: no model #{n} — type /models to see the list."
1155 )));
1228 - return;
1156 }
1157 + Some(model) => {
1158 + if model.cache_health == ModelCacheHealth::Incomplete {
1159 + app.close_model_picker();
1160 + app.messages.push(ChatMessage::system(format!(
1161 + "error: {} has an incomplete local cache and cannot be selected yet.",
1162 + model.display_name
1163 + )));
1164 + return;
1165 + }
1166
1231 - let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded {
1232 - format!(
1233 - "Downloading and loading {} ({})… this may take a few minutes.",
1234 - model.display_name, model.description
1235 - )
1236 - } else {
1237 - format!("Loading {}…", model.display_name)
1238 - };
1239 -
1240 - app.close_model_picker();
1241 - app.messages.push(ChatMessage::system(loading_msg));
1242 - terminal.draw(|frame| render(frame, app)).ok();
1243 -
1244 - let (tx, rx) = mpsc::channel(1);
1245 - app.model_load_rx = Some(rx);
1246 - app.switching_model = true;
1247 - app.switching_model_id = Some(model.config.model_id.clone());
1248 - // Only show download progress for models not yet cached.
1249 - app.download_progress =
1250 - if model.cache_health == ModelCacheHealth::NotDownloaded {
1251 - Some((0, 0))
1167 + let loading_msg = if model.cache_health
1168 + == ModelCacheHealth::NotDownloaded
1169 + {
1170 + format!(
1171 + "Downloading and loading {} ({})… this may take a few minutes.",
1172 + model.display_name, model.description
1173 + )
1174 } else {
1253 - None
1175 + format!("Loading {}…", model.display_name)
1176 };
1177
1256 - let sampling = SamplingConfig {
1257 - max_tokens: Some(model.max_tokens),
1258 - ..SamplingConfig::default()
1259 - };
1260 -
1261 - // Use a dedicated OS thread with its own tokio Runtime
1262 - // so that load_gguf_model's internal block_in_place
1263 - // cannot steal the main runtime's worker threads and
1264 - // freeze the TUI draw loop. This mirrors the pattern
1265 - // used at startup in run_interactive / run_acp_server.
1266 - let system_prompt = crate::system_prompt_for_model(model.tool_calling);
1267 - let engine_handle = Arc::clone(&engine);
1268 - let tool_calling = model.tool_calling;
1269 - std::thread::spawn(move || {
1270 - let rt = tokio::runtime::Runtime::new()
1271 - .expect("failed to create model-loader runtime");
1272 - let update = rt.block_on(async move {
1273 - match engine_handle
1274 - .load_gguf_model(
1275 - model.config.clone(),
1276 - Some(system_prompt.to_string()),
1277 - Some(sampling),
1278 - )
1279 - .await
1280 - {
1281 - Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
1282 - Err(err) => ModelLoadUpdate::Error(err.to_string()),
1283 - }
1178 + app.close_model_picker();
1179 + app.messages.push(ChatMessage::system(loading_msg));
1180 + terminal.draw(|frame| render(frame, app)).ok();
1181 +
1182 + let (tx, rx) = mpsc::channel(1);
1183 + app.model_load_rx = Some(rx);
1184 + app.switching_model = true;
1185 + app.switching_model_id = Some(model.config.model_id.clone());
1186 + // Only show download progress for models not yet cached.
1187 + app.download_progress =
1188 + if model.cache_health == ModelCacheHealth::NotDownloaded {
1189 + Some((0, 0))
1190 + } else {
1191 + None
1192 + };
1193 +
1194 + let sampling = SamplingConfig {
1195 + max_tokens: Some(model.max_tokens),
1196 + ..SamplingConfig::default()
1197 + };
1198 +
1199 + // Use a dedicated OS thread with its own tokio Runtime
1200 + // so that load_gguf_model's internal block_in_place
1201 + // cannot steal the main runtime's worker threads and
1202 + // freeze the TUI draw loop. This mirrors the pattern
1203 + // used at startup in run_interactive / run_acp_server.
1204 + let system_prompt = crate::system_prompt_for_model(model.tool_calling);
1205 + let engine_handle = Arc::clone(&engine);
1206 + let tool_calling = model.tool_calling;
1207 + std::thread::spawn(move || {
1208 + let rt = tokio::runtime::Runtime::new()
1209 + .expect("failed to create model-loader runtime");
1210 + let update = rt.block_on(async move {
1211 + match engine_handle
1212 + .load_gguf_model(
1213 + model.config.clone(),
1214 + Some(system_prompt.to_string()),
1215 + Some(sampling),
1216 + )
1217 + .await
1218 + {
1219 + Ok(_) => {
1220 + ModelLoadUpdate::Loaded(model.display_name.clone())
1221 + }
1222 + Err(err) => ModelLoadUpdate::Error(err.to_string()),
1223 + }
1224 + });
1225 + // blocking_send is fine here — the channel has
1226 + // capacity 1 and the receiver is always alive while
1227 + // switching_model is true.
1228 + let _ = tx.blocking_send(update);
1229 });
1285 - // blocking_send is fine here — the channel has
1286 - // capacity 1 and the receiver is always alive while
1287 - // switching_model is true.
1288 - let _ = tx.blocking_send(update);
1289 - });
1290 - // tool_calling is applied when ModelLoadUpdate::Loaded
1291 - // arrives in the event loop (see model_load_rx handler).
1292 - app.pending_tool_calling = Some(tool_calling);
1230 + // tool_calling is applied when ModelLoadUpdate::Loaded
1231 + // arrives in the event loop (see model_load_rx handler).
1232 + app.pending_tool_calling = Some(tool_calling);
1233 + }
1234 }
1235 }
1236 + },
1237 + SlashCommand::Exit => {
1238 + app.quit = true;
1239 + }
1240 + SlashCommand::Unknown(cmd) => {
1241 + app.messages
1242 + .push(ChatMessage::system(format!("unknown command: {cmd}")));
1243 }
1296 - },
1297 - SlashCommand::Exit => {
1298 - app.quit = true;
1299 - }
1300 - SlashCommand::Unknown(cmd) => {
1301 - app.messages
1302 - .push(ChatMessage::system(format!("unknown command: {cmd}")));
1244 }
1245 }
1305 -}
1246
1307 -// ── Background inference task ────────────────────────────────────────────────
1247 + // ── Background inference task ─────────────────────────────────────────────
1248
1309 -/// Maximum number of tool-calling rounds before forcing a text response.
1310 -const MAX_TOOL_ROUNDS: usize = 10;
1249 + /// Maximum number of tool-calling rounds before forcing a text response.
1250 + const MAX_TOOL_ROUNDS: usize = 10;
1251
1312 -/// Build onde `ToolDefinition`s from our agent tools.
1313 -fn build_onde_tools() -> Vec<ToolDefinition> {
1314 - crate::tools::all_tools()
1315 - .into_iter()
1316 - .map(|t| ToolDefinition {
1317 - name: t.name.to_string(),
1318 - description: t.description.to_string(),
1319 - parameters_schema: t.parameters_schema.to_string(),
1320 - })
1321 - .collect()
1322 -}
1252 + /// Build onde `ToolDefinition`s from our agent tools.
1253 + fn build_onde_tools() -> Vec<ToolDefinition> {
1254 + crate::tools::all_tools()
1255 + .into_iter()
1256 + .map(|t| ToolDefinition {
1257 + name: t.name.to_string(),
1258 + description: t.description.to_string(),
1259 + parameters_schema: t.parameters_schema.to_string(),
1260 + })
1261 + .collect()
1262 + }
1263
1324 -/// Runs the agentic tool-calling loop on a background task and sends
1325 -/// progress updates back through `tx`.
1326 -///
1327 -/// The sender is dropped when the task finishes, which the event loop
1328 -/// detects as `None` from `rx.recv()`.
1329 -async fn run_inference_task(
1330 - engine: Arc<ChatEngine>,
1331 - text: String,
1332 - tx: mpsc::Sender<InferenceUpdate>,
1333 - tools_enabled: bool,
1334 -) {
1335 - let onde_tools = if tools_enabled {
1336 - build_onde_tools()
1337 - } else {
1338 - vec![]
1339 - };
1264 + /// Runs the agentic tool-calling loop on a background task and sends
1265 + /// progress updates back through `tx`.
1266 + ///
1267 + /// The sender is dropped when the task finishes, which the event loop
1268 + /// detects as `None` from `rx.recv()`.
1269 + async fn run_inference_task(
1270 + engine: Arc<ChatEngine>,
1271 + text: String,
1272 + tx: mpsc::Sender<InferenceUpdate>,
1273 + tools_enabled: bool,
1274 + ) {
1275 + let onde_tools = if tools_enabled {
1276 + build_onde_tools()
1277 + } else {
1278 + vec![]
1279 + };
1280
1341 - let mut result = match engine.send_message_with_tools(&text, &onde_tools).await {
1342 - Ok(r) => r,
1343 - Err(err) => {
1344 - let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
1345 - return;
1346 - }
1347 - };
1281 + let mut result = match engine.send_message_with_tools(&text, &onde_tools).await {
1282 + Ok(r) => r,
1283 + Err(err) => {
1284 + let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
1285 + return;
1286 + }
1287 + };
1288
1349 - let mut round = 0;
1289 + let mut round = 0;
1290
1351 - while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1352 - round += 1;
1353 - log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
1291 + while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1292 + round += 1;
1293 + log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
1294
1355 - let mut tool_results = Vec::new();
1295 + let mut tool_results = Vec::new();
1296
1357 - for tc in &result.tool_calls {
1358 - log::info!(
1359 - " → {}({})",
1360 - tc.function_name,
1361 - tc.arguments.chars().take(120).collect::<String>()
1362 - );
1297 + for tc in &result.tool_calls {
1298 + log::info!(
1299 + " → {}({})",
1300 + tc.function_name,
1301 + tc.arguments.chars().take(120).collect::<String>()
1302 + );
1303
1364 - // Notify the UI about the tool call.
1365 - let _ = tx
1366 - .send(InferenceUpdate::ToolUse(tc.function_name.clone()))
1367 - .await;
1304 + // Notify the UI about the tool call.
1305 + let _ = tx
1306 + .send(InferenceUpdate::ToolUse(tc.function_name.clone()))
1307 + .await;
1308
1369 - // Execute the tool (async — read_website uses spawn_blocking internally).
1370 - let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await;
1371 - log::info!(" ← {} chars", output.len());
1309 + // Execute the tool.
1310 + let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await;
1311 + log::info!(" ← {} chars", output.len());
1312
1373 - tool_results.push(ToolResult {
1374 - tool_call_id: tc.id.clone(),
1375 - content: output,
1376 - });
1377 - }
1313 + tool_results.push(ToolResult {
1314 + tool_call_id: tc.id.clone(),
1315 + content: output,
1316 + });
1317 + }
1318
1379 - // Allow further tool calls unless we've hit the limit.
1380 - let next_tools = if round < MAX_TOOL_ROUNDS {
1381 - Some(onde_tools.as_slice())
1382 - } else {
1383 - None // force a text response on the last round
1384 - };
1319 + // Allow further tool calls unless we've hit the limit.
1320 + let next_tools = if round < MAX_TOOL_ROUNDS {
1321 + Some(onde_tools.as_slice())
1322 + } else {
1323 + None // force a text response on the last round
1324 + };
1325
1386 - match engine.send_tool_results(tool_results, next_tools).await {
1387 - Ok(r) => result = r,
1388 - Err(err) => {
1389 - let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
1390 - return;
1326 + match engine.send_tool_results(tool_results, next_tools).await {
1327 + Ok(r) => result = r,
1328 + Err(err) => {
1329 + let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
1330 + return;
1331 + }
1332 }
1333 }
1393 - }
1334
1395 - // Send the final text response, or a fallback if the model returned nothing.
1396 - if result.tool_calls.is_empty() {
1397 - if result.text.is_empty() {
1398 - log::warn!("model returned empty reply — may have exhausted max_tokens on thinking");
1399 - let _ = tx
1400 - .send(InferenceUpdate::Error(
1401 - "(empty response — the model may have used all tokens on internal reasoning. \
1402 - Try a shorter or simpler prompt.)"
1403 - .to_string(),
1404 - ))
1405 - .await;
1406 - } else {
1407 - let _ = tx.send(InferenceUpdate::Response(result.text)).await;
1335 + // Send the final text response, or a fallback if the model returned nothing.
1336 + if result.tool_calls.is_empty() {
1337 + if result.text.is_empty() {
1338 + log::warn!(
1339 + "model returned empty reply — may have exhausted max_tokens on thinking"
1340 + );
1341 + let _ = tx
1342 + .send(InferenceUpdate::Error(
1343 + "(empty response — the model may have used all tokens on internal reasoning. \
1344 + Try a shorter or simpler prompt.)"
1345 + .to_string(),
1346 + ))
1347 + .await;
1348 + } else {
1349 + let _ = tx.send(InferenceUpdate::Response(result.text)).await;
1350 + }
1351 }
1409 - }
1410 -
1411 - log::info!("inference complete — {} tool round(s)", round);
1412 - // Sender drops here → event loop sees `None`.
1413 -}
1352
1415 -// ── Main loop ─────────────────────────────────────────────────────────────────
1353 + log::info!("inference complete — {} tool round(s)", round);
1354 + // Sender drops here → event loop sees `None`.
1355 + }
1356
1417 -/// Run the interactive chat UI. Blocks until the user quits.
1418 -///
1419 -/// Accepts a terminal that has already been initialised by the caller —
1420 -/// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility.
1421 -///
1422 -/// `load_rx` is the receiving end of a [`std::sync::mpsc`] channel. A
1423 -/// dedicated OS thread loads the model and sends `Ok(())` or `Err(msg)` when
1424 -/// done. The event loop polls `try_recv()` on every tick — non-blocking,
1425 -/// zero contention with the tokio runtime.
1426 -#[cfg(unix)]
1427 -pub async fn run_with<B: ratatui::backend::Backend>(
1428 - terminal: &mut ratatui::Terminal<B>,
1429 - engine: Arc<ChatEngine>,
1430 - load_rx: std_mpsc::Receiver<Result<(), String>>,
1431 - load_model_name: String,
1432 -) -> Result<()> {
1433 - event_loop(terminal, engine, load_rx, load_model_name).await
1434 -}
1357 + // ── Main loop ─────────────────────────────────────────────────────────────
1358 +
1359 + /// Run the interactive chat UI. Blocks until the user quits.
1360 + ///
1361 + /// Accepts a terminal that has already been initialised by the caller —
1362 + /// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility.
1363 + ///
1364 + /// `load_rx` is the receiving end of a [`std::sync::mpsc`] channel. A
1365 + /// dedicated OS thread loads the model and sends `Ok(())` or `Err(msg)` when
1366 + /// done. The event loop polls `try_recv()` on every tick — non-blocking,
1367 + /// zero contention with the tokio runtime.
1368 + pub async fn run_with<B: ratatui::backend::Backend>(
1369 + terminal: &mut ratatui::Terminal<B>,
1370 + engine: Arc<ChatEngine>,
1371 + load_rx: std_mpsc::Receiver<Result<(), String>>,
1372 + load_model_name: String,
1373 + ) -> Result<()> {
1374 + event_loop(terminal, engine, load_rx, load_model_name).await
1375 + }
1376
1436 -#[cfg(unix)]
1437 -async fn event_loop<B: ratatui::backend::Backend>(
1438 - terminal: &mut ratatui::Terminal<B>,
1439 - engine: Arc<ChatEngine>,
1440 - load_rx: std_mpsc::Receiver<Result<(), String>>,
1441 - load_model_name: String,
1442 -) -> Result<()> {
1443 - let mut app = App::new(load_model_name);
1444 - let mut event_stream = EventStream::new();
1445 -
1446 - // 100 ms per tick ≈ 10 fps — enough for a smooth spinner.
1447 - let mut ticker = interval(Duration::from_millis(100));
1448 -
1449 - loop {
1450 - // ── Poll the loader channel (non-blocking) ────────────────────────
1451 - if app.is_loading {
1452 - match load_rx.try_recv() {
1453 - Ok(Ok(())) => app.finish_loading(),
1454 - Ok(Err(e)) => app.set_load_error(e),
1455 - Err(std_mpsc::TryRecvError::Empty) => {}
1456 - Err(std_mpsc::TryRecvError::Disconnected) => {
1457 - app.set_load_error("Model loader thread crashed.".to_string());
1377 + async fn event_loop<B: ratatui::backend::Backend>(
1378 + terminal: &mut ratatui::Terminal<B>,
1379 + engine: Arc<ChatEngine>,
1380 + load_rx: std_mpsc::Receiver<Result<(), String>>,
1381 + load_model_name: String,
1382 + ) -> Result<()> {
1383 + let mut app = App::new(load_model_name);
1384 + let mut event_stream = EventStream::new();
1385 +
1386 + // 100 ms per tick ≈ 10 fps — enough for a smooth spinner.
1387 + let mut ticker = interval(Duration::from_millis(100));
1388 +
1389 + loop {
1390 + // ── Poll the loader channel (non-blocking) ────────────────────────
1391 + if app.is_loading {
1392 + match load_rx.try_recv() {
1393 + Ok(Ok(())) => app.finish_loading(),
1394 + Ok(Err(e)) => app.set_load_error(e),
1395 + Err(std_mpsc::TryRecvError::Empty) => {}
1396 + Err(std_mpsc::TryRecvError::Disconnected) => {
1397 + app.set_load_error("Model loader thread crashed.".to_string());
1398 + }
1399 }
1400 }
1460 - }
1401
1462 - // redraw every iteration
1463 - terminal.draw(|frame| render(frame, &mut app))?;
1402 + // redraw every iteration
1403 + terminal.draw(|frame| render(frame, &mut app))?;
1404
1465 - if let Some(rx) = app.model_load_rx.as_mut() {
1466 - match rx.try_recv() {
1467 - Ok(ModelLoadUpdate::Loaded(model_name)) => {
1468 - engine.clear_history().await;
1469 - if let Some(tc) = app.pending_tool_calling.take() {
1470 - app.tool_calling = tc;
1405 + if let Some(rx) = app.model_load_rx.as_mut() {
1406 + match rx.try_recv() {
1407 + Ok(ModelLoadUpdate::Loaded(model_name)) => {
1408 + engine.clear_history().await;
1409 + if let Some(tc) = app.pending_tool_calling.take() {
1410 + app.tool_calling = tc;
1411 + }
1412 + app.switching_model = false;
1413 + app.switching_model_id = None;
1414 + app.download_progress = None;
1415 + app.model_load_cancelled = false;
1416 + app.model_load_rx = None;
1417 + app.current_model_name = model_name.clone();
1418 +
1419 + let save_result = app
1420 + .model_picker_items
1421 + .iter()
1422 + .find(|item| item.display_name == model_name)
1423 + .map(|item| crate::setup::SelectedModel {
1424 + model_id: item.config.model_id.clone(),
1425 + gguf_file: item
1426 + .config
1427 + .files
1428 + .first()
1429 + .cloned()
1430 + .unwrap_or_else(String::new),
1431 + })
1432 + .filter(|selected| !selected.gguf_file.is_empty())
1433 + .map(|selected| crate::setup::save_selected_model(&selected))
1434 + .unwrap_or_else(|| {
1435 + Err(format!(
1436 + "could not determine a stable identifier for {}",
1437 + model_name
1438 + ))
1439 + });
1440 +
1441 + if let Err(error) = save_result {
1442 + app.messages.push(ChatMessage::system(format!(
1443 + "warning: switched to {} but could not save the selection: {}",
1444 + model_name, error
1445 + )));
1446 + } else {
1447 + app.messages
1448 + .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
1449 + }
1450 }
1472 - app.switching_model = false;
1473 - app.switching_model_id = None;
1474 - app.download_progress = None;
1475 - app.model_load_cancelled = false;
1476 - app.model_load_rx = None;
1477 - app.current_model_name = model_name.clone();
1478 -
1479 - let save_result = app
1480 - .model_picker_items
1481 - .iter()
1482 - .find(|item| item.display_name == model_name)
1483 - .map(|item| crate::setup::SelectedModel {
1484 - model_id: item.config.model_id.clone(),
1485 - gguf_file: item
1486 - .config
1487 - .files
1488 - .first()
1489 - .cloned()
1490 - .unwrap_or_else(String::new),
1491 - })
1492 - .filter(|selected| !selected.gguf_file.is_empty())
1493 - .map(|selected| crate::setup::save_selected_model(&selected))
1494 - .unwrap_or_else(|| {
1495 - Err(format!(
1496 - "could not determine a stable identifier for {}",
1497 - model_name
1498 - ))
1499 - });
1500 -
1501 - if let Err(error) = save_result {
1502 - app.messages.push(ChatMessage::system(format!(
1503 - "warning: switched to {} but could not save the selection: {}",
1504 - model_name, error
1505 - )));
1506 - } else {
1451 + Ok(ModelLoadUpdate::Error(error)) => {
1452 + app.switching_model = false;
1453 + app.switching_model_id = None;
1454 + app.download_progress = None;
1455 + app.model_load_cancelled = false;
1456 + app.model_load_rx = None;
1457 app.messages
1508 - .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
1458 + .push(ChatMessage::system(format!("error loading model: {error}")));
1459 }
1510 - }
1511 - Ok(ModelLoadUpdate::Error(error)) => {
1512 - app.switching_model = false;
1513 - app.switching_model_id = None;
1514 - app.download_progress = None;
1515 - app.model_load_cancelled = false;
1516 - app.model_load_rx = None;
1517 - app.messages
1518 - .push(ChatMessage::system(format!("error loading model: {error}")));
1519 - }
1520 - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
1521 - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1522 - let was_cancelled = app.model_load_cancelled;
1523 - app.switching_model = false;
1524 - app.switching_model_id = None;
1525 - app.download_progress = None;
1526 - app.model_load_cancelled = false;
1527 - app.model_load_rx = None;
1528 - if !was_cancelled {
1529 - app.messages.push(ChatMessage::system(
1530 - "error loading model: loader task disconnected".to_string(),
1531 - ));
1460 + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
1461 + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1462 + let was_cancelled = app.model_load_cancelled;
1463 + app.switching_model = false;
1464 + app.switching_model_id = None;
1465 + app.download_progress = None;
1466 + app.model_load_cancelled = false;
1467 + app.model_load_rx = None;
1468 + if !was_cancelled {
1469 + app.messages.push(ChatMessage::system(
1470 + "error loading model: loader task disconnected".to_string(),
1471 + ));
1472 + }
1473 }
1474 }
1475 }
1535 - }
1536 -
1537 - if app.quit {
1538 - break;
1539 - }
1476
1541 - // multiplex terminal events, streaming tokens, inference updates,
1542 - // and the thinking-spinner timer.
1543 - tokio::select! {
1544 - biased;
1545 -
1546 - // ── Spinner tick (loading phase only) ─────────────────────────
1547 - _ = ticker.tick(), if app.is_loading => {
1548 - app.tick();
1477 + if app.quit {
1478 + break;
1479 }
1480
1551 - // ── Streaming LLM tokens ──────────────────────────────────────
1552 - chunk = async {
1553 - match app.stream_rx.as_mut() {
1554 - Some(rx) => rx.recv().await,
1555 - None => pending().await,
1481 + // multiplex terminal events, streaming tokens, inference updates,
1482 + // and the thinking-spinner timer.
1483 + tokio::select! {
1484 + biased;
1485 +
1486 + // ── Spinner tick (loading phase only) ─────────────────────────
1487 + _ = ticker.tick(), if app.is_loading => {
1488 + app.tick();
1489 }
1557 - } => {
1558 - match chunk {
1559 - Some(chunk) => {
1560 - if !chunk.delta.is_empty() {
1561 - app.push_stream_delta(&chunk.delta);
1490 +
1491 + // ── Streaming LLM tokens ──────────────────────────────────────
1492 + chunk = async {
1493 + match app.stream_rx.as_mut() {
1494 + Some(rx) => rx.recv().await,
1495 + None => pending().await,
1496 + }
1497 + } => {
1498 + match chunk {
1499 + Some(chunk) => {
1500 + if !chunk.delta.is_empty() {
1501 + app.push_stream_delta(&chunk.delta);
1502 + }
1503 + if chunk.done {
1504 + app.finalize_stream();
1505 + }
1506 }
1563 - if chunk.done {
1507 + // Sender dropped without sending done=true.
1508 + None => {
1509 app.finalize_stream();
1510 }
1511 }
1567 - // Sender dropped without sending done=true.
1568 - None => {
1569 - app.finalize_stream();
1570 - }
1512 }
1572 - }
1513
1574 - // ── inference updates from background task ───────────────────
1575 - update = async {
1576 - match app.inference_rx.as_mut() {
1577 - Some(rx) => rx.recv().await,
1578 - None => pending().await,
1579 - }
1580 - } => {
1581 - match update {
1582 - Some(InferenceUpdate::ToolUse(name)) => {
1583 - app.messages.push(ChatMessage::system(format!("🔧 {name}")));
1584 - }
1585 - Some(InferenceUpdate::Response(text)) => {
1586 - app.stop_thinking();
1587 - app.messages.push(ChatMessage::assistant(text));
1514 + // ── inference updates from background task ───────────────────
1515 + update = async {
1516 + match app.inference_rx.as_mut() {
1517 + Some(rx) => rx.recv().await,
1518 + None => pending().await,
1519 }
1589 - Some(InferenceUpdate::Error(msg)) => {
1590 - app.stop_thinking();
1591 - app.messages.push(ChatMessage::system(format!("error: {msg}")));
1592 - }
1593 - None => {
1594 - // Sender dropped — task finished (possibly with no
1595 - // text response, e.g. all tool calls with empty final).
1596 - app.stop_thinking();
1520 + } => {
1521 + match update {
1522 + Some(InferenceUpdate::ToolUse(name)) => {
1523 + app.messages.push(ChatMessage::system(format!("🔧 {name}")));
1524 + }
1525 + Some(InferenceUpdate::Response(text)) => {
1526 + app.stop_thinking();
1527 + app.messages.push(ChatMessage::assistant(text));
1528 + }
1529 + Some(InferenceUpdate::Error(msg)) => {
1530 + app.stop_thinking();
1531 + app.messages.push(ChatMessage::system(format!("error: {msg}")));
1532 + }
1533 + None => {
1534 + // Sender dropped — task finished (possibly with no
1535 + // text response, e.g. all tool calls with empty final).
1536 + app.stop_thinking();
1537 + }
1538 }
1539 }
1599 - }
1540
1601 - // ── thinking / switching spinner tick (100ms) ────────────────
1602 - _ = async {
1603 - if app.thinking || app.switching_model {
1604 - tokio::time::sleep(Duration::from_millis(100)).await
1605 - } else {
1606 - pending().await
1607 - }
1608 - } => {
1609 - app.tick_thinking();
1610 - // Refresh download-progress bytes from the HF cache dir so
1611 - // the progress bar in render_messages stays current.
1612 - if app.switching_model {
1613 - app.poll_download_progress();
1541 + // ── thinking / switching spinner tick (100ms) ────────────────
1542 + _ = async {
1543 + if app.thinking || app.switching_model {
1544 + tokio::time::sleep(Duration::from_millis(100)).await
1545 + } else {
1546 + pending().await
1547 + }
1548 + } => {
1549 + app.tick_thinking();
1550 + // Refresh download-progress bytes from the HF cache dir so
1551 + // the progress bar in render_messages stays current.
1552 + if app.switching_model {
1553 + app.poll_download_progress();
1554 + }
1555 }
1615 - }
1616 -
1617 - // ── Terminal events ───────────────────────────────────────────
1618 - maybe_event = event_stream.next() => {
1619 - let Some(Ok(event)) = maybe_event else {
1620 - break;
1621 - };
1556
1623 - if let Event::Key(key) = event {
1624 - // During loading, only Ctrl+C / Ctrl+D are accepted.
1625 - if app.is_loading {
1626 - if key.kind == KeyEventKind::Press {
1627 - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1628 - if ctrl
1629 - && (key.code == KeyCode::Char('c')
1630 - || key.code == KeyCode::Char('d'))
1631 - {
1632 - app.quit = true;
1557 + // ── Terminal events ───────────────────────────────────────────
1558 + maybe_event = event_stream.next() => {
1559 + let Some(Ok(event)) = maybe_event else {
1560 + break;
1561 + };
1562 +
1563 + if let Event::Key(key) = event {
1564 + // During loading, only Ctrl+C / Ctrl+D are accepted.
1565 + if app.is_loading {
1566 + if key.kind == KeyEventKind::Press {
1567 + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1568 + if ctrl
1569 + && (key.code == KeyCode::Char('c')
1570 + || key.code == KeyCode::Char('d'))
1571 + {
1572 + app.quit = true;
1573 + }
1574 }
1575 + continue;
1576 }
1635 - continue;
1636 - }
1577
1638 - // While busy (streaming or thinking), only Ctrl+C/D work.
1639 - if app.is_busy() {
1640 - if key.kind == KeyEventKind::Press {
1641 - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1642 - if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
1643 - if app.is_streaming() {
1644 - app.finalize_stream();
1645 - app.messages.push(ChatMessage::system("(cancelled)"));
1646 - }
1647 - if app.thinking {
1648 - // Drop the receiver — the background task
1649 - // will see a closed channel and stop.
1650 - app.stop_thinking();
1651 - app.messages.push(ChatMessage::system("(cancelled)"));
1652 - }
1653 - if app.switching_model {
1654 - // Mark as cancelled before dropping the
1655 - // receiver so the Disconnected arm in the
1656 - // model_load_rx handler stays silent.
1657 - app.model_load_cancelled = true;
1658 - app.switching_model = false;
1659 - app.switching_model_id = None;
1660 - app.download_progress = None;
1661 - app.model_load_rx = None;
1662 - app.messages
1663 - .push(ChatMessage::system("(download cancelled — model switch aborted)"));
1578 + // While busy (streaming or thinking), only Ctrl+C/D work.
1579 + if app.is_busy() {
1580 + if key.kind == KeyEventKind::Press {
1581 + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1582 + if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
1583 + if app.is_streaming() {
1584 + app.finalize_stream();
1585 + app.messages.push(ChatMessage::system("(cancelled)"));
1586 + }
1587 + if app.thinking {
1588 + // Drop the receiver — the background task
1589 + // will see a closed channel and stop.
1590 + app.stop_thinking();
1591 + app.messages.push(ChatMessage::system("(cancelled)"));
1592 + }
1593 + if app.switching_model {
1594 + // Mark as cancelled before dropping the
1595 + // receiver so the Disconnected arm in the
1596 + // model_load_rx handler stays silent.
1597 + app.model_load_cancelled = true;
1598 + app.switching_model = false;
1599 + app.switching_model_id = None;
1600 + app.download_progress = None;
1601 + app.model_load_rx = None;
1602 + app.messages
1603 + .push(ChatMessage::system("(download cancelled — model switch aborted)"));
1604 + }
1605 }
1606 }
1666 - }
1667 - continue;
1668 - }
1669 -
1670 - if let Some(text) = handle_key(&mut app, key) {
1671 - if let Some(cmd) = parse_slash(&text) {
1672 - exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
1607 continue;
1608 }
1609
1676 - // ── Spawn inference on a background task ─────────
1677 - app.messages.push(ChatMessage::user(&text));
1678 - app.start_thinking();
1610 + if let Some(text) = handle_key(&mut app, key) {
1611 + if let Some(cmd) = parse_slash(&text) {
1612 + exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
1613 + continue;
1614 + }
1615 +
1616 + // ── Spawn inference on a background task ─────────
1617 + app.messages.push(ChatMessage::user(&text));
1618 + app.start_thinking();
1619
1680 - let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
1681 - app.inference_rx = Some(rx);
1620 + let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
1621 + app.inference_rx = Some(rx);
1622
1683 - let engine_handle = Arc::clone(&engine);
1684 - let user_text = text.clone();
1685 - let tools_enabled = app.tool_calling;
1686 - tokio::spawn(async move {
1687 - run_inference_task(engine_handle, user_text, tx, tools_enabled).await;
1688 - });
1623 + let engine_handle = Arc::clone(&engine);
1624 + let user_text = text.clone();
1625 + let tools_enabled = app.tool_calling;
1626 + tokio::spawn(async move {
1627 + run_inference_task(engine_handle, user_text, tx, tools_enabled).await;
1628 + });
1629 + }
1630 }
1631 }
1632 }
1633 }
1634 +
1635 + Ok(())
1636 }
1637
1695 - Ok(())
1696 -}
1638 + // ── Download progress helpers (TUI) ──────────────────────────────────────
1639
1698 -// ── Download progress helpers (TUI) ──────────────────────────────────────────
1640 + /// Recursively sum the on-disk size of all files under `path`, following
1641 + /// symlinks so hf-hub's blob layout is counted correctly.
1642 + fn dir_size_recursive(path: &std::path::Path) -> u64 {
1643 + let mut total: u64 = 0;
1644 + let Ok(entries) = std::fs::read_dir(path) else {
1645 + return 0;
1646 + };
1647 + for entry in entries.flatten() {
1648 + let entry_path = entry.path();
1649 + if entry_path.is_dir() {
1650 + total += dir_size_recursive(&entry_path);
1651 + } else if let Ok(meta) = entry_path.metadata() {
1652 + total += meta.len();
1653 + }
1654 + }
1655 + total
1656 + }
1657
1700 -/// Recursively sum the on-disk size of all files under `path`, following
1701 -/// symlinks so hf-hub's blob layout is counted correctly.
1702 -#[cfg(unix)]
1703 -fn dir_size_recursive(path: &std::path::Path) -> u64 {
1704 - let mut total: u64 = 0;
1705 - let Ok(entries) = std::fs::read_dir(path) else {
1706 - return 0;
1707 - };
1708 - for entry in entries.flatten() {
1709 - let entry_path = entry.path();
1710 - if entry_path.is_dir() {
1711 - total += dir_size_recursive(&entry_path);
1712 - } else if let Ok(meta) = entry_path.metadata() {
1713 - total += meta.len();
1658 + /// Format a byte count as a terse human-readable string.
1659 + fn format_size_human(bytes: u64) -> String {
1660 + const GB: u64 = 1_073_741_824;
1661 + const MB: u64 = 1_048_576;
1662 + const KB: u64 = 1_024;
1663 + if bytes >= GB {
1664 + format!("{:.2} GB", bytes as f64 / GB as f64)
1665 + } else if bytes >= MB {
1666 + format!("{:.1} MB", bytes as f64 / MB as f64)
1667 + } else if bytes >= KB {
1668 + format!("{:.0} KB", bytes as f64 / KB as f64)
1669 + } else {
1670 + format!("{bytes} B")
1671 }
1672 }
1716 - total
1717 -}
1673 +} // end #[cfg(unix)] mod tui
1674
1719 -/// Format a byte count as a terse human-readable string.
1675 +// Re-export the Unix-only public entry point so callers can write
1676 +// `chat::run_with(...)` on all platforms and get a clean "not available"
1677 +// compile error on Windows rather than a missing-item error.
1678 #[cfg(unix)]
1721 -fn format_size_human(bytes: u64) -> String {
1722 - const GB: u64 = 1_073_741_824;
1723 - const MB: u64 = 1_048_576;
1724 - const KB: u64 = 1_024;
1725 - if bytes >= GB {
1726 - format!("{:.2} GB", bytes as f64 / GB as f64)
1727 - } else if bytes >= MB {
1728 - format!("{:.1} MB", bytes as f64 / MB as f64)
1729 - } else if bytes >= KB {
1730 - format!("{:.0} KB", bytes as f64 / KB as f64)
1731 - } else {
1732 - format!("{bytes} B")
1733 - }
1734 -}
1679 +pub use tui::run_with;
1680 +
1681 +// ── Tests (platform-agnostic) ─────────────────────────────────────────────────
1682
1683 #[cfg(test)]
1684 mod tests {