@hej / sigit / commits / 9b2d1b1

clean logs

Seto Elkahfi committed Apr 13, 2026 at 21:26 UTC 9b2d1b12ec7cbb77d5bc68b93d414e817a86545e
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 }