4 files changed
+402
-180
Cargo.lock
+2
-57
@@ -1535,29 +1535,6 @@ dependencies = [
1535
"syn 2.0.117",
1536
]
1537
1538
-[[package]]
1539
-name = "env_filter"
1540
-version = "1.0.1"
1541
-source = "registry+https://github.com/rust-lang/crates.io-index"
1542
-checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
1543
-dependencies = [
1544
- "log",
1545
- "regex",
1546
-]
1547
-
1548
-[[package]]
1549
-name = "env_logger"
1550
-version = "0.11.10"
1551
-source = "registry+https://github.com/rust-lang/crates.io-index"
1552
-checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
1553
-dependencies = [
1554
- "anstream",
1555
- "anstyle",
1556
- "env_filter",
1557
- "jiff",
1558
- "log",
1559
-]
1560
-
1538
[[package]]
1539
name = "equator"
1540
version = "0.4.2"
@@ -2849,30 +2826,6 @@ version = "1.0.18"
2826
source = "registry+https://github.com/rust-lang/crates.io-index"
2827
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
2828
2852
-[[package]]
2853
-name = "jiff"
2854
-version = "0.2.23"
2855
-source = "registry+https://github.com/rust-lang/crates.io-index"
2856
-checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
2857
-dependencies = [
2858
- "jiff-static",
2859
- "log",
2860
- "portable-atomic",
2861
- "portable-atomic-util",
2862
- "serde_core",
2863
-]
2864
-
2865
-[[package]]
2866
-name = "jiff-static"
2867
-version = "0.2.23"
2868
-source = "registry+https://github.com/rust-lang/crates.io-index"
2869
-checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
2870
-dependencies = [
2871
- "proc-macro2",
2872
- "quote",
2873
- "syn 2.0.117",
2874
-]
2875
-
2829
[[package]]
2830
name = "jni"
2831
version = "0.21.1"
@@ -4061,15 +4014,6 @@ version = "1.13.1"
4014
source = "registry+https://github.com/rust-lang/crates.io-index"
4015
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
4016
4064
-[[package]]
4065
-name = "portable-atomic-util"
4066
-version = "0.2.6"
4067
-source = "registry+https://github.com/rust-lang/crates.io-index"
4068
-checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
4069
-dependencies = [
4070
- "portable-atomic",
4071
-]
4072
-
4017
[[package]]
4018
name = "potential_utf"
4019
version = "0.1.5"
@@ -5303,13 +5247,14 @@ dependencies = [
5247
"anyhow",
5248
"async-trait",
5249
"crossterm 0.28.1",
5306
- "env_logger",
5250
"futures",
5251
+ "libc",
5252
"log",
5253
"onde",
5254
"ratatui",
5255
"tokio",
5256
"tokio-util",
5257
+ "tracing-subscriber",
5258
"uuid 1.23.0",
5259
]
5260
Cargo.toml
+3
-2
@@ -19,7 +19,7 @@ onde = "0.1.1"
19
20
# Async runtime
21
async-trait = "0.1"
22
-tokio = { version = "1", features = ["rt", "macros", "io-std", "io-util", "sync"] }
22
+tokio = { version = "1", features = ["rt", "macros", "io-std", "io-util", "sync", "time"] }
23
tokio-util = { version = "0.7", features = ["compat"] }
24
futures = "0.3"
25
@@ -29,6 +29,7 @@ ratatui = { version = "0.29", default-features = false, features = ["crossterm"]
29
30
# Utilities
31
anyhow = "1"
32
+libc = "0.2"
33
log = "0.4"
33
-env_logger = "0.11"
34
+tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
35
uuid = { version = "1", features = ["v4"] }
src/chat.rs
+251
-67
@@ -2,6 +2,13 @@
2
//!
3
//! Takes over the alternate screen and multiplexes terminal events with
4
//! streaming LLM tokens via `tokio::select!`.
5
+//!
6
+//! The UI has two phases:
7
+//!
8
+//! 1. **Loading phase** — banner art lines are revealed one by one while the
9
+//! model loads in the background. A braille spinner follows once all lines
10
+//! are visible.
11
+//! 2. **Chat phase** — normal interactive chat once `load_rx` resolves.
12
13
use std::future::pending;
14
@@ -16,9 +23,10 @@ use ratatui::{
23
text::{Line, Span},
24
widgets::{Block, Borders, Paragraph, Wrap},
25
};
19
-use tokio::sync::mpsc;
26
+use tokio::sync::{mpsc, oneshot};
27
+use tokio::time::{Duration, interval};
28
21
-// ── Message types ────────────────────────────────────────────────────────────
29
+// ── Message types ─────────────────────────────────────────────────────────────
30
31
#[derive(Clone, Copy, PartialEq, Eq)]
32
enum Role {
@@ -55,7 +63,7 @@ impl ChatMessage {
63
}
64
}
65
58
-// ── App state ────────────────────────────────────────────────────────────────
66
+// ── App state ─────────────────────────────────────────────────────────────────
67
68
struct App {
69
messages: Vec<ChatMessage>,
@@ -68,6 +76,17 @@ struct App {
76
/// Toggled every other tick while streaming — drives the blinking cursor.
77
blink_on: bool,
78
blink_counter: u8,
79
+
80
+ // ── Loading-phase state ───────────────────────────────────────────────────
81
+ /// True while the model is still loading; switches to false on completion.
82
+ is_loading: bool,
83
+ /// How many banner art lines have been revealed so far.
84
+ banner_reveal: usize,
85
+ /// Monotonic counter incremented on every animation tick.
86
+ /// Drives the braille spinner and the trailing-dot animation.
87
+ load_tick: u32,
88
+ /// Set when model loading fails; keeps the loading view up with the error.
89
+ load_error: Option<String>,
90
}
91
92
const BANNER_ART: &str = "\
@@ -87,22 +106,9 @@ const BANNER_ART: &str = "\
106
107
impl App {
108
fn new() -> Self {
90
- let mut messages = Vec::new();
91
- for line in BANNER_ART.lines() {
92
- messages.push(ChatMessage::system(line));
93
- }
94
- messages.push(ChatMessage::system(""));
95
- messages.push(ChatMessage::system(format!(
96
- "siGit Code v{}",
97
- env!("CARGO_PKG_VERSION"),
98
- )));
99
- messages.push(ChatMessage::system(
100
- "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
101
- ));
102
- messages.push(ChatMessage::system("Type /help for commands."));
103
-
109
Self {
105
- messages,
110
+ // Messages start empty; banner lines are added in finish_loading().
111
+ messages: Vec::new(),
112
input: String::new(),
113
cursor: 0,
114
scroll_offset: 0,
@@ -111,6 +117,10 @@ impl App {
117
quit: false,
118
blink_on: true,
119
blink_counter: 0,
120
+ is_loading: true,
121
+ banner_reveal: 0,
122
+ load_tick: 0,
123
+ load_error: None,
124
}
125
}
126
@@ -129,11 +139,48 @@ impl App {
139
140
fn push_stream_delta(&mut self, delta: &str) {
141
self.stream_buf.push_str(delta);
132
- // tick the blink
142
self.blink_counter = self.blink_counter.wrapping_add(1);
143
self.blink_on = self.blink_counter % 4 < 2;
144
}
145
146
+ /// Reveal the next banner line and advance the animation tick counter.
147
+ /// Called on every ticker firing during the loading phase.
148
+ fn advance_banner(&mut self) {
149
+ let total = BANNER_ART.lines().count();
150
+ if self.banner_reveal < total {
151
+ self.banner_reveal += 1;
152
+ }
153
+ // Keep ticking even after all lines are revealed so the spinner moves.
154
+ self.load_tick = self.load_tick.wrapping_add(1);
155
+ }
156
+
157
+ /// Transition from loading phase to normal chat.
158
+ /// Adds the banner lines and welcome messages to the message log so they
159
+ /// appear naturally in the chat scroll buffer.
160
+ fn finish_loading(&mut self) {
161
+ self.is_loading = false;
162
+ for line in BANNER_ART.lines() {
163
+ self.messages.push(ChatMessage::system(line));
164
+ }
165
+ self.messages.push(ChatMessage::system(""));
166
+ self.messages.push(ChatMessage::system(format!(
167
+ "siGit Code v{}",
168
+ env!("CARGO_PKG_VERSION"),
169
+ )));
170
+ self.messages.push(ChatMessage::system(
171
+ "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
172
+ ));
173
+ self.messages
174
+ .push(ChatMessage::system("Type /help for commands."));
175
+ }
176
+
177
+ /// Record a loading error. The loading view stays visible so the user can
178
+ /// read the message before pressing Ctrl+C.
179
+ fn set_load_error(&mut self, error: String) {
180
+ self.load_error = Some(error);
181
+ // is_loading stays true so render_loading() keeps rendering.
182
+ }
183
+
184
/// Total lines the messages area would need (rough estimate for scrolling).
185
fn total_message_lines(&self, width: u16) -> u16 {
186
if width == 0 {
@@ -144,7 +191,6 @@ impl App {
191
for msg in &self.messages {
192
lines += wrapped_line_count(&msg.text, msg.role, w);
193
}
147
- // streaming buffer
194
if !self.stream_buf.is_empty() {
195
lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w);
196
}
@@ -165,7 +211,7 @@ impl App {
211
fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
212
let prefix_len = match role {
213
Role::User => 6, // "you > "
168
- Role::Assistant => 7, // "siGit > " — wait, that's 8. Let's just use 7 for "siGit> "
214
+ Role::Assistant => 7, // "siGit > "
215
Role::System => 0,
216
};
217
let effective = if width > prefix_len {
@@ -185,7 +231,7 @@ fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
231
count.max(1)
232
}
233
188
-// ── Slash commands ───────────────────────────────────────────────────────────
234
+// ── Slash commands ────────────────────────────────────────────────────────────
235
236
enum SlashCommand {
237
Help,
@@ -210,24 +256,38 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
256
})
257
}
258
213
-// ── Rendering ────────────────────────────────────────────────────────────────
259
+// ── Rendering ─────────────────────────────────────────────────────────────────
260
261
fn render(frame: &mut Frame, app: &mut App) {
262
let area = frame.area();
263
218
- // Layout: title(1) | messages(flex) | input(3) | footer(1)
219
- let zones = Layout::vertical([
220
- Constraint::Length(1),
221
- Constraint::Min(1),
222
- Constraint::Length(3),
223
- Constraint::Length(1),
224
- ])
225
- .split(area);
226
-
227
- render_title(frame, zones[0]);
228
- render_messages(frame, app, zones[1]);
229
- render_input(frame, app, zones[2]);
230
- render_footer(frame, app, zones[3]);
264
+ if app.is_loading {
265
+ // Loading phase: title | animated banner area | slim footer (no input).
266
+ let zones = Layout::vertical([
267
+ Constraint::Length(1),
268
+ Constraint::Min(1),
269
+ Constraint::Length(1),
270
+ ])
271
+ .split(area);
272
+
273
+ render_title(frame, zones[0]);
274
+ render_loading(frame, app, zones[1]);
275
+ render_loading_footer(frame, zones[2]);
276
+ } else {
277
+ // Chat phase: title | messages | input | footer.
278
+ let zones = Layout::vertical([
279
+ Constraint::Length(1),
280
+ Constraint::Min(1),
281
+ Constraint::Length(3),
282
+ Constraint::Length(1),
283
+ ])
284
+ .split(area);
285
+
286
+ render_title(frame, zones[0]);
287
+ render_messages(frame, app, zones[1]);
288
+ render_input(frame, app, zones[2]);
289
+ render_footer(frame, app, zones[3]);
290
+ }
291
}
292
293
fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) {
@@ -250,6 +310,79 @@ fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) {
310
);
311
}
312
313
+/// Animated loading screen.
314
+///
315
+/// Reveals banner art lines one at a time (`banner_reveal` grows on each tick).
316
+/// Once every line is visible a braille spinner and trailing dots indicate that
317
+/// the model is still being pulled into memory. If loading fails, a red error
318
+/// message replaces the spinner.
319
+fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
320
+ const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
321
+ const DOTS: &[&str] = &["", ".", "..", "..."];
322
+
323
+ let banner_lines: Vec<&str> = BANNER_ART.lines().collect();
324
+ let total = banner_lines.len();
325
+ let mut lines: Vec<Line<'_>> = Vec::new();
326
+
327
+ // Reveal lines up to the current animation frame.
328
+ for line in banner_lines.iter().take(app.banner_reveal) {
329
+ lines.push(Line::from(Span::styled(
330
+ *line,
331
+ Style::default().fg(Color::DarkGray),
332
+ )));
333
+ }
334
+
335
+ if let Some(ref err) = app.load_error {
336
+ // Error state — show message and prompt the user to quit.
337
+ lines.push(Line::from(vec![
338
+ Span::styled(
339
+ " ✘ ",
340
+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
341
+ ),
342
+ Span::styled(err.clone(), Style::default().fg(Color::Red)),
343
+ ]));
344
+ lines.push(Line::from(Span::styled(
345
+ " Press Ctrl+C to quit.",
346
+ Style::default().fg(Color::DarkGray),
347
+ )));
348
+ } else if app.banner_reveal >= total {
349
+ // All lines visible — show spinner while the model finishes loading.
350
+ let spinner = SPINNER[(app.load_tick as usize) % SPINNER.len()];
351
+ let dots = DOTS[(app.load_tick as usize / 3) % DOTS.len()];
352
+
353
+ lines.push(Line::from(vec![
354
+ Span::styled(
355
+ format!(" {spinner} "),
356
+ Style::default()
357
+ .fg(Color::Yellow)
358
+ .add_modifier(Modifier::BOLD),
359
+ ),
360
+ Span::styled("Loading model", Style::default().fg(Color::White)),
361
+ Span::styled(dots, Style::default().fg(Color::DarkGray)),
362
+ ]));
363
+ }
364
+
365
+ frame.render_widget(Paragraph::new(lines).style(Style::default()), area);
366
+}
367
+
368
+/// One-line footer shown only during the loading phase.
369
+fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) {
370
+ let spans = vec![
371
+ Span::styled(
372
+ " Ctrl+C ",
373
+ Style::default()
374
+ .fg(Color::Black)
375
+ .bg(Color::DarkGray)
376
+ .add_modifier(Modifier::BOLD),
377
+ ),
378
+ Span::styled(" quit", Style::default().fg(Color::DarkGray)),
379
+ ];
380
+ frame.render_widget(
381
+ Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)),
382
+ area,
383
+ );
384
+}
385
+
386
fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
387
let mut lines: Vec<Line<'_>> = Vec::new();
388
@@ -257,7 +390,7 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
390
render_chat_message(&mut lines, msg);
391
}
392
260
- // streaming partial response
393
+ // Streaming partial response.
394
if !app.stream_buf.is_empty() || app.is_streaming() {
395
let mut spans = vec![Span::styled(
396
"siGit > ",
@@ -266,17 +399,17 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
399
.add_modifier(Modifier::BOLD),
400
)];
401
269
- // split on newlines so multi-line streaming renders correctly
402
+ // Split on newlines so multi-line streaming renders correctly.
403
let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect();
404
for (i, segment) in buf_lines.iter().enumerate() {
405
if i > 0 {
406
lines.push(Line::from(spans.drain(..).collect::<Vec<_>>()));
274
- // continuation lines get no prefix
407
+ // Continuation lines get no prefix.
408
}
409
spans.push(Span::raw(segment.to_string()));
410
}
411
279
- // blinking cursor while streaming
412
+ // Blinking block cursor while streaming.
413
if app.is_streaming() && app.blink_on {
414
spans.push(Span::styled("█", Style::default().fg(Color::Green)));
415
}
@@ -284,7 +417,6 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
417
lines.push(Line::from(spans));
418
}
419
287
- // auto-scroll
420
app.auto_scroll(area.height, area.width);
421
422
let paragraph = Paragraph::new(lines)
@@ -371,7 +503,7 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
503
504
frame.render_widget(input_text, area);
505
374
- // place cursor inside the input block (1 for border padding)
506
+ // Place cursor inside the input block (offset by 1 for the border).
507
if !app.is_streaming() {
508
let x = area.x + app.cursor as u16 + 1;
509
let y = area.y + 1;
@@ -410,7 +542,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
542
);
543
}
544
413
-// ── Input handling ───────────────────────────────────────────────────────────
545
+// ── Input handling ────────────────────────────────────────────────────────────
546
547
fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
548
if key.kind != KeyEventKind::Press {
@@ -474,7 +606,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
606
}
607
}
608
477
-// ── Slash command execution ──────────────────────────────────────────────────
609
+// ── Slash command execution ───────────────────────────────────────────────────
610
611
async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) {
612
match cmd {
@@ -513,35 +645,75 @@ async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) {
645
}
646
}
647
516
-// ── Main loop ────────────────────────────────────────────────────────────────
648
+// ── Main loop ─────────────────────────────────────────────────────────────────
649
518
-/// Run the interactive chat UI. Blocks until the user quits.
650
+/// Run the interactive chat UI. Blocks until the user quits.
651
+///
652
+/// Accepts a terminal that has already been initialised by the caller —
653
+/// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility.
654
+/// Initialising the terminal *before* starting concurrent model loading
655
+/// guarantees the alternate screen is active before any log line can fire.
656
///
520
-/// The caller must have already loaded a model into `engine`.
521
-pub async fn run(engine: &ChatEngine) -> Result<()> {
522
- let mut terminal = ratatui::init();
523
- let result = event_loop(&mut terminal, engine).await;
524
- ratatui::restore();
525
- result
657
+/// `load_rx` resolves to `Ok(())` when the model finishes loading, or to
658
+/// `Err(message)` if loading failed. The TUI animates the banner art while
659
+/// waiting for this signal, then transitions to the normal chat view.
660
+pub async fn run_with<B: ratatui::backend::Backend>(
661
+ terminal: &mut ratatui::Terminal<B>,
662
+ engine: &ChatEngine,
663
+ load_rx: oneshot::Receiver<Result<(), String>>,
664
+) -> Result<()> {
665
+ event_loop(terminal, engine, load_rx).await
666
}
667
528
-async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine) -> Result<()> {
668
+async fn event_loop<B: ratatui::backend::Backend>(
669
+ terminal: &mut ratatui::Terminal<B>,
670
+ engine: &ChatEngine,
671
+ load_rx: oneshot::Receiver<Result<(), String>>,
672
+) -> Result<()> {
673
let mut app = App::new();
674
let mut event_stream = EventStream::new();
675
676
+ // 80 ms per tick ≈ 12.5 fps — snappy enough for the banner reveal without
677
+ // burning the CPU.
678
+ let mut ticker = interval(Duration::from_millis(80));
679
+
680
+ // Wrap in Option so we can "disarm" it once the oneshot resolves.
681
+ let mut load_rx = Some(load_rx);
682
+
683
loop {
533
- // draw
684
terminal.draw(|frame| render(frame, &mut app))?;
685
686
if app.quit {
687
break;
688
}
689
540
- // multiplex terminal events and streaming tokens
690
tokio::select! {
691
biased;
692
544
- // streaming chunks — only active when we have a receiver
693
+ // ── Model load signal ─────────────────────────────────────────
694
+ // Polls the oneshot receiver until it fires, then disarms it.
695
+ result = async {
696
+ match load_rx.as_mut() {
697
+ Some(rx) => match rx.await {
698
+ Ok(r) => r,
699
+ Err(_) => Err("Model load task was dropped unexpectedly.".to_string()),
700
+ },
701
+ None => pending::<Result<(), String>>().await,
702
+ }
703
+ }, if load_rx.is_some() => {
704
+ load_rx = None;
705
+ match result {
706
+ Ok(()) => app.finish_loading(),
707
+ Err(e) => app.set_load_error(e),
708
+ }
709
+ }
710
+
711
+ // ── Animation tick (loading phase only) ───────────────────────
712
+ _ = ticker.tick(), if app.is_loading => {
713
+ app.advance_banner();
714
+ }
715
+
716
+ // ── Streaming LLM tokens ──────────────────────────────────────
717
chunk = async {
718
match app.stream_rx.as_mut() {
719
Some(rx) => rx.recv().await,
@@ -557,27 +729,42 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
729
app.finalize_stream();
730
}
731
}
560
- // sender dropped without done=true
732
+ // Sender dropped without sending done=true.
733
None => {
734
app.finalize_stream();
735
}
736
}
737
}
738
567
- // terminal events
739
+ // ── Terminal events ───────────────────────────────────────────
740
maybe_event = event_stream.next() => {
741
let Some(Ok(event)) = maybe_event else {
570
- // stream ended or error — bail
742
break;
743
};
744
745
if let Event::Key(key) = event {
575
- // while streaming, only ctrl+c/d work
746
+ // During loading, only Ctrl+C / Ctrl+D are accepted.
747
+ if app.is_loading {
748
+ if key.kind == KeyEventKind::Press {
749
+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
750
+ if ctrl
751
+ && (key.code == KeyCode::Char('c')
752
+ || key.code == KeyCode::Char('d'))
753
+ {
754
+ app.quit = true;
755
+ }
756
+ }
757
+ continue;
758
+ }
759
+
760
+ // While streaming, only Ctrl+C / Ctrl+D cancel the stream.
761
if app.is_streaming() {
762
if key.kind == KeyEventKind::Press {
763
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
579
- if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
580
- // drop the receiver to stop reading
764
+ if ctrl
765
+ && (key.code == KeyCode::Char('c')
766
+ || key.code == KeyCode::Char('d'))
767
+ {
768
app.finalize_stream();
769
app.messages.push(ChatMessage::system("(cancelled)"));
770
}
@@ -586,13 +773,11 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
773
}
774
775
if let Some(text) = handle_key(&mut app, key) {
589
- // check for slash command first
776
if let Some(cmd) = parse_slash(&text) {
777
exec_slash(&mut app, cmd, engine).await;
778
continue;
779
}
780
595
- // regular message — send to engine
781
app.messages.push(ChatMessage::user(&text));
782
783
match engine.stream_message(text).await {
@@ -603,9 +788,8 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
788
app.blink_on = true;
789
}
790
Err(err) => {
606
- app.messages.push(ChatMessage::system(format!(
607
- "error: {err}"
608
- )));
791
+ app.messages
792
+ .push(ChatMessage::system(format!("error: {err}")));
793
}
794
}
795
}
src/main.rs
+146
-54
@@ -1,5 +1,12 @@
1
//! siGit Code — AI coding agent powered by a local LLM via Onde Inference.
2
//!
3
+//! In interactive (TTY) mode **all** process output — `log::` crate events,
4
+//! `tracing` events from mistralrs_core, and even raw `println!` calls buried
5
+//! inside third-party crates — is redirected to `$TMPDIR/sigit.log` by
6
+//! rewiring the stdout/stderr file descriptors with `dup2(2)` before any
7
+//! library code runs. Ratatui receives a private copy of the original
8
+//! terminal fd so its rendering is unaffected.
9
+//!
10
//! Two modes of operation:
11
//!
12
//! - **Interactive** (stdin is a TTY): full-screen chat UI built on ratatui.
@@ -25,7 +32,7 @@
32
mod chat;
33
mod setup;
34
28
-use std::io::IsTerminal;
35
+use std::io::{BufWriter, IsTerminal, Write};
36
use std::sync::Arc;
37
38
use agent_client_protocol::{
@@ -36,8 +43,12 @@ use agent_client_protocol::{
43
};
44
use futures::future::LocalBoxFuture;
45
use onde::inference::{ChatEngine, GgufModelConfig};
39
-use tokio::sync::{Mutex, mpsc};
46
+use tokio::sync::{Mutex, mpsc, oneshot};
47
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
48
+use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
49
+
50
+#[cfg(unix)]
51
+use std::os::unix::io::{AsRawFd, FromRawFd};
52
53
const SYSTEM_PROMPT: &str = "\
54
Your name is siGit — spelled exactly that way: lowercase 's', uppercase 'G', \
@@ -213,52 +224,131 @@ impl Agent for SiGitAgent {
224
}
225
}
226
216
-// ── Banner ───────────────────────────────────────────────────────────────────
217
-
218
-fn print_banner() {
219
- const BANNER: &str = r#"
220
-77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777
221
-77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777
222
-1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111
223
-1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111
224
-1111111111111111111111111111111201 109 304 40 00 79 100041111111111
225
-333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333
226
-333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333
227
-3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333
228
-2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222
229
-2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222
230
-2222255555555555555555555555299 708 1002 80 00 90852222222222222
231
-55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
232
-88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888
233
-
234
- siGit Code v%VERSION%
235
-"#;
236
-
237
- let art = BANNER.replace("%VERSION%", env!("CARGO_PKG_VERSION"));
238
- eprintln!("{art}");
227
+// ── Output capture ────────────────────────────────────────────────────────────
228
+
229
+/// Redirect **both** stdout and stderr to `$TMPDIR/sigit.log` at the
230
+/// file-descriptor level and return a [`std::fs::File`] handle to the *real*
231
+/// terminal (the original stdout) so ratatui can still render to it.
232
+///
233
+/// This is the nuclear option — it catches absolutely everything that any
234
+/// library writes to stdout (`println!` in mistralrs `print_metadata`) or
235
+/// stderr (`tracing::info!`, `log::info!`, raw `eprintln!`).
236
+///
237
+/// Returns **two** `File` handles to the real terminal (both created via
238
+/// `dup(STDOUT)` *before* the redirect):
239
+///
240
+/// 1. **`tui`** — given to ratatui's `CrosstermBackend` for rendering.
241
+/// 2. **`cleanup`** — kept by the caller for writing `LeaveAlternateScreen`
242
+/// and restoring stdout/stderr after the TUI exits (since ratatui 0.29
243
+/// does not expose `writer_mut()` on the backend).
244
+#[cfg(unix)]
245
+fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
246
+ let log_path = std::env::temp_dir().join("sigit.log");
247
+ let log_file = std::fs::File::create(&log_path)?;
248
+ let log_fd = log_file.as_raw_fd();
249
+
250
+ // Save TWO copies of the real terminal fd before we clobber stdout.
251
+ let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) };
252
+ anyhow::ensure!(
253
+ saved_tui >= 0,
254
+ "dup(stdout) for tui failed: {}",
255
+ std::io::Error::last_os_error()
256
+ );
257
+ let saved_cleanup = unsafe { libc::dup(libc::STDOUT_FILENO) };
258
+ anyhow::ensure!(
259
+ saved_cleanup >= 0,
260
+ "dup(stdout) for cleanup failed: {}",
261
+ std::io::Error::last_os_error()
262
+ );
263
+
264
+ // Point stdout and stderr at the log file.
265
+ unsafe {
266
+ libc::dup2(log_fd, libc::STDOUT_FILENO);
267
+ libc::dup2(log_fd, libc::STDERR_FILENO);
268
+ }
269
+
270
+ // `log_file` can drop — dup2 created independent references to the
271
+ // underlying file description, so stdout/stderr keep it alive.
272
+
273
+ Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe {
274
+ std::fs::File::from_raw_fd(saved_cleanup)
275
+ }))
276
}
277
241
-// ── Interactive mode ─────────────────────────────────────────────────────────
278
+// ── Logging ───────────────────────────────────────────────────────────────────
279
+
280
+/// Initialise `tracing-subscriber` as the single logging backend.
281
+///
282
+/// In TUI mode stdout/stderr have already been redirected to the log file by
283
+/// [`redirect_output_to_log`], so the subscriber simply writes to stderr
284
+/// (which *is* the log file). In ACP mode stderr is the real stderr.
285
+fn init_logging(is_tty: bool) {
286
+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
287
+ let _ = tracing_fmt::Subscriber::builder()
288
+ .with_env_filter(filter)
289
+ .with_writer(std::io::stderr)
290
+ .with_ansi(!is_tty)
291
+ .try_init();
292
+}
293
243
-/// Load the model, then hand off to the ratatui chat TUI.
244
-async fn run_interactive() -> anyhow::Result<()> {
245
- println!(" Loading model...");
294
+// ── Interactive mode ─────────────────────────────────────────────────────────
295
296
+/// Start the TUI immediately, load the model concurrently, signal completion
297
+/// via a oneshot channel so the TUI can animate the banner while waiting.
298
+///
299
+/// The terminal is set up *manually* against the saved real-terminal `File`
300
+/// returned by [`redirect_output_to_log`]. Because stdout/stderr have
301
+/// already been redirected to the log file at that point, any `println!`,
302
+/// `eprintln!`, `log::info!`, or `tracing::info!` emitted by mistralrs or
303
+/// onde goes straight to `$TMPDIR/sigit.log` and never touches the screen.
304
+///
305
+/// `tty` is given to ratatui; `cleanup_tty` is a second fd to the same
306
+/// terminal, used for `LeaveAlternateScreen` and restoring stdout/stderr
307
+/// (we cannot access the backend's writer because `writer_mut()` is private
308
+/// in ratatui 0.29).
309
+async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
310
let engine = ChatEngine::new();
311
let config = GgufModelConfig::platform_default();
249
- engine
250
- .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
251
- .await
252
- .map_err(|e| anyhow::anyhow!("model load failed: {e}"))?;
253
-
254
- let info = engine.info().await;
255
- println!(
256
- " \x1b[32m✓\x1b[0m {} ({})\n",
257
- info.model_name.as_deref().unwrap_or("unknown"),
258
- info.approx_memory.as_deref().unwrap_or("?"),
312
+ let (load_tx, load_rx) = oneshot::channel::<Result<(), String>>();
313
+
314
+ // Set up the terminal manually on the real tty fd.
315
+ crossterm::terminal::enable_raw_mode()?;
316
+ let mut tty = BufWriter::new(tty);
317
+ crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
318
+ let backend = ratatui::backend::CrosstermBackend::new(tty);
319
+ let mut terminal = ratatui::Terminal::new(backend)?;
320
+
321
+ // Drive model loading and the TUI concurrently. Both futures hold a
322
+ // shared `&engine` reference, which is valid because ChatEngine is Sync
323
+ // and all its methods take `&self`.
324
+ let (_, chat_result) = tokio::join!(
325
+ async {
326
+ let result = engine
327
+ .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
328
+ .await;
329
+ // Ignore send errors — the TUI may have already quit (Ctrl+C).
330
+ let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
331
+ },
332
+ chat::run_with(&mut terminal, &engine, load_rx),
333
);
334
261
- chat::run(&engine).await
335
+ // Restore the terminal before exiting.
336
+ // Use the separate cleanup fd — the backend's writer is private.
337
+ crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
338
+ cleanup_tty.flush()?;
339
+ crossterm::terminal::disable_raw_mode()?;
340
+
341
+ // Restore stdout/stderr so any post-TUI error messages are visible.
342
+ #[cfg(unix)]
343
+ {
344
+ let cleanup_fd = cleanup_tty.as_raw_fd();
345
+ unsafe {
346
+ libc::dup2(cleanup_fd, libc::STDOUT_FILENO);
347
+ libc::dup2(cleanup_fd, libc::STDERR_FILENO);
348
+ }
349
+ }
350
+
351
+ chat_result
352
}
353
354
// ── ACP server mode ──────────────────────────────────────────────────────────
@@ -312,21 +402,23 @@ async fn run_acp_server() -> anyhow::Result<()> {
402
403
#[tokio::main]
404
async fn main() -> anyhow::Result<()> {
315
- // Logs always go to stderr (stdout is either the TUI or the ACP wire).
316
- env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
317
- .target(env_logger::Target::Stderr)
318
- .init();
319
-
320
- // Shared model cache (macOS App Group) — must run before anything
321
- // touches hf-hub or ChatEngine.
322
- setup::setup_shared_model_cache();
323
-
324
- if std::io::stdin().is_terminal() {
325
- // Interactive mode — full-screen chat TUI.
326
- print_banner();
327
- run_interactive().await
405
+ let is_tty = std::io::stdin().is_terminal();
406
+
407
+ if is_tty {
408
+ // Redirect stdout/stderr to $TMPDIR/sigit.log *first* — before any
409
+ // library code can println!/eprintln!/log to the real terminal.
410
+ #[cfg(unix)]
411
+ let (tty, cleanup_tty) = redirect_output_to_log()?;
412
+ #[cfg(not(unix))]
413
+ anyhow::bail!("interactive mode requires Unix (macOS / Linux)");
414
+
415
+ init_logging(true);
416
+ setup::setup_shared_model_cache();
417
+ run_interactive(tty, cleanup_tty).await
418
} else {
329
- // Editor spawned us — speak ACP over stdio.
419
+ // ACP mode: no redirect needed, logs go to stderr.
420
+ init_logging(false);
421
+ setup::setup_shared_model_cache();
422
log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
423
run_acp_server().await
424
}