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 {