@setoelkahfi / 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
index 2c24312..9a4e709 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1535,29 +1535,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "env_filter" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equator" version = "0.4.2" @@ -2849,30 +2826,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jiff" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "jni" version = "0.21.1" @@ -4061,15 +4014,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -[[package]] -name = "portable-atomic-util" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -5303,13 +5247,14 @@ dependencies = [ "anyhow", "async-trait", "crossterm 0.28.1", - "env_logger", "futures", + "libc", "log", "onde", "ratatui", "tokio", "tokio-util", + "tracing-subscriber", "uuid 1.23.0", ]
Cargo.toml
+3 -2
index 550dc65..5284f02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ onde = "0.1.1" # Async runtime async-trait = "0.1" -tokio = { version = "1", features = ["rt", "macros", "io-std", "io-util", "sync"] } +tokio = { version = "1", features = ["rt", "macros", "io-std", "io-util", "sync", "time"] } tokio-util = { version = "0.7", features = ["compat"] } futures = "0.3" @@ -29,6 +29,7 @@ ratatui = { version = "0.29", default-features = false, features = ["crossterm"] # Utilities anyhow = "1" +libc = "0.2" log = "0.4" -env_logger = "0.11" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } uuid = { version = "1", features = ["v4"] }
src/chat.rs
+251 -67
index c7bab80..aa612a5 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2,6 +2,13 @@ //! //! Takes over the alternate screen and multiplexes terminal events with //! streaming LLM tokens via `tokio::select!`. +//! +//! The UI has two phases: +//! +//! 1. **Loading phase** — banner art lines are revealed one by one while the +//! model loads in the background. A braille spinner follows once all lines +//! are visible. +//! 2. **Chat phase** — normal interactive chat once `load_rx` resolves. use std::future::pending; @@ -16,9 +23,10 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::{Duration, interval}; -// ── Message types ──────────────────────────────────────────────────────────── +// ── Message types ───────────────────────────────────────────────────────────── #[derive(Clone, Copy, PartialEq, Eq)] enum Role { @@ -55,7 +63,7 @@ impl ChatMessage { } } -// ── App state ──────────────────────────────────────────────────────────────── +// ── App state ───────────────────────────────────────────────────────────────── struct App { messages: Vec<ChatMessage>, @@ -68,6 +76,17 @@ struct App { /// Toggled every other tick while streaming — drives the blinking cursor. blink_on: bool, blink_counter: u8, + + // ── Loading-phase state ─────────────────────────────────────────────────── + /// True while the model is still loading; switches to false on completion. + is_loading: bool, + /// How many banner art lines have been revealed so far. + banner_reveal: usize, + /// Monotonic counter incremented on every animation tick. + /// Drives the braille spinner and the trailing-dot animation. + load_tick: u32, + /// Set when model loading fails; keeps the loading view up with the error. + load_error: Option<String>, } const BANNER_ART: &str = "\ @@ -87,22 +106,9 @@ const BANNER_ART: &str = "\ impl App { fn new() -> Self { - let mut messages = Vec::new(); - for line in BANNER_ART.lines() { - messages.push(ChatMessage::system(line)); - } - messages.push(ChatMessage::system("")); - messages.push(ChatMessage::system(format!( - "siGit Code v{}", - env!("CARGO_PKG_VERSION"), - ))); - messages.push(ChatMessage::system( - "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit", - )); - messages.push(ChatMessage::system("Type /help for commands.")); - Self { - messages, + // Messages start empty; banner lines are added in finish_loading(). + messages: Vec::new(), input: String::new(), cursor: 0, scroll_offset: 0, @@ -111,6 +117,10 @@ impl App { quit: false, blink_on: true, blink_counter: 0, + is_loading: true, + banner_reveal: 0, + load_tick: 0, + load_error: None, } } @@ -129,11 +139,48 @@ impl App { fn push_stream_delta(&mut self, delta: &str) { self.stream_buf.push_str(delta); - // tick the blink self.blink_counter = self.blink_counter.wrapping_add(1); self.blink_on = self.blink_counter % 4 < 2; } + /// Reveal the next banner line and advance the animation tick counter. + /// Called on every ticker firing during the loading phase. + fn advance_banner(&mut self) { + let total = BANNER_ART.lines().count(); + if self.banner_reveal < total { + self.banner_reveal += 1; + } + // Keep ticking even after all lines are revealed so the spinner moves. + self.load_tick = self.load_tick.wrapping_add(1); + } + + /// Transition from loading phase to normal chat. + /// Adds the banner lines and welcome messages to the message log so they + /// appear naturally in the chat scroll buffer. + fn finish_loading(&mut self) { + self.is_loading = false; + for line in BANNER_ART.lines() { + self.messages.push(ChatMessage::system(line)); + } + self.messages.push(ChatMessage::system("")); + self.messages.push(ChatMessage::system(format!( + "siGit Code v{}", + env!("CARGO_PKG_VERSION"), + ))); + self.messages.push(ChatMessage::system( + "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit", + )); + self.messages + .push(ChatMessage::system("Type /help for commands.")); + } + + /// Record a loading error. The loading view stays visible so the user can + /// read the message before pressing Ctrl+C. + fn set_load_error(&mut self, error: String) { + self.load_error = Some(error); + // is_loading stays true so render_loading() keeps rendering. + } + /// Total lines the messages area would need (rough estimate for scrolling). fn total_message_lines(&self, width: u16) -> u16 { if width == 0 { @@ -144,7 +191,6 @@ impl App { for msg in &self.messages { lines += wrapped_line_count(&msg.text, msg.role, w); } - // streaming buffer if !self.stream_buf.is_empty() { lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w); } @@ -165,7 +211,7 @@ impl App { fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { let prefix_len = match role { Role::User => 6, // "you > " - Role::Assistant => 7, // "siGit > " — wait, that's 8. Let's just use 7 for "siGit> " + Role::Assistant => 7, // "siGit > " Role::System => 0, }; let effective = if width > prefix_len { @@ -185,7 +231,7 @@ fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { count.max(1) } -// ── Slash commands ─────────────────────────────────────────────────────────── +// ── Slash commands ──────────────────────────────────────────────────────────── enum SlashCommand { Help, @@ -210,24 +256,38 @@ fn parse_slash(input: &str) -> Option<SlashCommand> { }) } -// ── Rendering ──────────────────────────────────────────────────────────────── +// ── Rendering ───────────────────────────────────────────────────────────────── fn render(frame: &mut Frame, app: &mut App) { let area = frame.area(); - // Layout: title(1) | messages(flex) | input(3) | footer(1) - let zones = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(3), - Constraint::Length(1), - ]) - .split(area); - - render_title(frame, zones[0]); - render_messages(frame, app, zones[1]); - render_input(frame, app, zones[2]); - render_footer(frame, app, zones[3]); + if app.is_loading { + // Loading phase: title | animated banner area | slim footer (no input). + let zones = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + + render_title(frame, zones[0]); + render_loading(frame, app, zones[1]); + render_loading_footer(frame, zones[2]); + } else { + // Chat phase: title | messages | input | footer. + let zones = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + render_title(frame, zones[0]); + render_messages(frame, app, zones[1]); + render_input(frame, app, zones[2]); + render_footer(frame, app, zones[3]); + } } fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) { @@ -250,6 +310,79 @@ fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) { ); } +/// Animated loading screen. +/// +/// Reveals banner art lines one at a time (`banner_reveal` grows on each tick). +/// Once every line is visible a braille spinner and trailing dots indicate that +/// the model is still being pulled into memory. If loading fails, a red error +/// message replaces the spinner. +fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + const DOTS: &[&str] = &["", ".", "..", "..."]; + + let banner_lines: Vec<&str> = BANNER_ART.lines().collect(); + let total = banner_lines.len(); + let mut lines: Vec<Line<'_>> = Vec::new(); + + // Reveal lines up to the current animation frame. + for line in banner_lines.iter().take(app.banner_reveal) { + lines.push(Line::from(Span::styled( + *line, + Style::default().fg(Color::DarkGray), + ))); + } + + if let Some(ref err) = app.load_error { + // Error state — show message and prompt the user to quit. + lines.push(Line::from(vec![ + Span::styled( + " ✘ ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::styled(err.clone(), Style::default().fg(Color::Red)), + ])); + lines.push(Line::from(Span::styled( + " Press Ctrl+C to quit.", + Style::default().fg(Color::DarkGray), + ))); + } else if app.banner_reveal >= total { + // All lines visible — show spinner while the model finishes loading. + let spinner = SPINNER[(app.load_tick as usize) % SPINNER.len()]; + let dots = DOTS[(app.load_tick as usize / 3) % DOTS.len()]; + + lines.push(Line::from(vec![ + Span::styled( + format!(" {spinner} "), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled("Loading model", Style::default().fg(Color::White)), + Span::styled(dots, Style::default().fg(Color::DarkGray)), + ])); + } + + frame.render_widget(Paragraph::new(lines).style(Style::default()), area); +} + +/// One-line footer shown only during the loading phase. +fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) { + let spans = vec![ + Span::styled( + " Ctrl+C ", + Style::default() + .fg(Color::Black) + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" quit", Style::default().fg(Color::DarkGray)), + ]; + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)), + area, + ); +} + fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { let mut lines: Vec<Line<'_>> = Vec::new(); @@ -257,7 +390,7 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect render_chat_message(&mut lines, msg); } - // streaming partial response + // Streaming partial response. if !app.stream_buf.is_empty() || app.is_streaming() { let mut spans = vec![Span::styled( "siGit > ", @@ -266,17 +399,17 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect .add_modifier(Modifier::BOLD), )]; - // split on newlines so multi-line streaming renders correctly + // Split on newlines so multi-line streaming renders correctly. let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect(); for (i, segment) in buf_lines.iter().enumerate() { if i > 0 { lines.push(Line::from(spans.drain(..).collect::<Vec<_>>())); - // continuation lines get no prefix + // Continuation lines get no prefix. } spans.push(Span::raw(segment.to_string())); } - // blinking cursor while streaming + // Blinking block cursor while streaming. if app.is_streaming() && app.blink_on { spans.push(Span::styled("█", Style::default().fg(Color::Green))); } @@ -284,7 +417,6 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect lines.push(Line::from(spans)); } - // auto-scroll app.auto_scroll(area.height, area.width); let paragraph = Paragraph::new(lines) @@ -371,7 +503,7 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { frame.render_widget(input_text, area); - // place cursor inside the input block (1 for border padding) + // Place cursor inside the input block (offset by 1 for the border). if !app.is_streaming() { let x = area.x + app.cursor as u16 + 1; let y = area.y + 1; @@ -410,7 +542,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { ); } -// ── Input handling ─────────────────────────────────────────────────────────── +// ── Input handling ──────────────────────────────────────────────────────────── fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> { if key.kind != KeyEventKind::Press { @@ -474,7 +606,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> { } } -// ── Slash command execution ────────────────────────────────────────────────── +// ── Slash command execution ─────────────────────────────────────────────────── async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) { match cmd { @@ -513,35 +645,75 @@ async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) { } } -// ── Main loop ──────────────────────────────────────────────────────────────── +// ── Main loop ───────────────────────────────────────────────────────────────── -/// Run the interactive chat UI. Blocks until the user quits. +/// Run the interactive chat UI. Blocks until the user quits. +/// +/// Accepts a terminal that has already been initialised by the caller — +/// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility. +/// Initialising the terminal *before* starting concurrent model loading +/// guarantees the alternate screen is active before any log line can fire. /// -/// The caller must have already loaded a model into `engine`. -pub async fn run(engine: &ChatEngine) -> Result<()> { - let mut terminal = ratatui::init(); - let result = event_loop(&mut terminal, engine).await; - ratatui::restore(); - result +/// `load_rx` resolves to `Ok(())` when the model finishes loading, or to +/// `Err(message)` if loading failed. The TUI animates the banner art while +/// waiting for this signal, then transitions to the normal chat view. +pub async fn run_with<B: ratatui::backend::Backend>( + terminal: &mut ratatui::Terminal<B>, + engine: &ChatEngine, + load_rx: oneshot::Receiver<Result<(), String>>, +) -> Result<()> { + event_loop(terminal, engine, load_rx).await } -async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine) -> Result<()> { +async fn event_loop<B: ratatui::backend::Backend>( + terminal: &mut ratatui::Terminal<B>, + engine: &ChatEngine, + load_rx: oneshot::Receiver<Result<(), String>>, +) -> Result<()> { let mut app = App::new(); let mut event_stream = EventStream::new(); + // 80 ms per tick ≈ 12.5 fps — snappy enough for the banner reveal without + // burning the CPU. + let mut ticker = interval(Duration::from_millis(80)); + + // Wrap in Option so we can "disarm" it once the oneshot resolves. + let mut load_rx = Some(load_rx); + loop { - // draw terminal.draw(|frame| render(frame, &mut app))?; if app.quit { break; } - // multiplex terminal events and streaming tokens tokio::select! { biased; - // streaming chunks — only active when we have a receiver + // ── Model load signal ───────────────────────────────────────── + // Polls the oneshot receiver until it fires, then disarms it. + result = async { + match load_rx.as_mut() { + Some(rx) => match rx.await { + Ok(r) => r, + Err(_) => Err("Model load task was dropped unexpectedly.".to_string()), + }, + None => pending::<Result<(), String>>().await, + } + }, if load_rx.is_some() => { + load_rx = None; + match result { + Ok(()) => app.finish_loading(), + Err(e) => app.set_load_error(e), + } + } + + // ── Animation tick (loading phase only) ─────────────────────── + _ = ticker.tick(), if app.is_loading => { + app.advance_banner(); + } + + // ── Streaming LLM tokens ────────────────────────────────────── chunk = async { match app.stream_rx.as_mut() { Some(rx) => rx.recv().await, @@ -557,27 +729,42 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine app.finalize_stream(); } } - // sender dropped without done=true + // Sender dropped without sending done=true. None => { app.finalize_stream(); } } } - // terminal events + // ── Terminal events ─────────────────────────────────────────── maybe_event = event_stream.next() => { let Some(Ok(event)) = maybe_event else { - // stream ended or error — bail break; }; if let Event::Key(key) = event { - // while streaming, only ctrl+c/d work + // During loading, only Ctrl+C / Ctrl+D are accepted. + if app.is_loading { + if key.kind == KeyEventKind::Press { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl + && (key.code == KeyCode::Char('c') + || key.code == KeyCode::Char('d')) + { + app.quit = true; + } + } + continue; + } + + // While streaming, only Ctrl+C / Ctrl+D cancel the stream. if app.is_streaming() { if key.kind == KeyEventKind::Press { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) { - // drop the receiver to stop reading + if ctrl + && (key.code == KeyCode::Char('c') + || key.code == KeyCode::Char('d')) + { app.finalize_stream(); app.messages.push(ChatMessage::system("(cancelled)")); } @@ -586,13 +773,11 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine } if let Some(text) = handle_key(&mut app, key) { - // check for slash command first if let Some(cmd) = parse_slash(&text) { exec_slash(&mut app, cmd, engine).await; continue; } - // regular message — send to engine app.messages.push(ChatMessage::user(&text)); match engine.stream_message(text).await { @@ -603,9 +788,8 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine app.blink_on = true; } Err(err) => { - app.messages.push(ChatMessage::system(format!( - "error: {err}" - ))); + app.messages + .push(ChatMessage::system(format!("error: {err}"))); } } }
src/main.rs
+146 -54
index b2e6711..4cfd28c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,12 @@ //! siGit Code — AI coding agent powered by a local LLM via Onde Inference. //! +//! In interactive (TTY) mode **all** process output — `log::` crate events, +//! `tracing` events from mistralrs_core, and even raw `println!` calls buried +//! inside third-party crates — is redirected to `$TMPDIR/sigit.log` by +//! rewiring the stdout/stderr file descriptors with `dup2(2)` before any +//! library code runs. Ratatui receives a private copy of the original +//! terminal fd so its rendering is unaffected. +//! //! Two modes of operation: //! //! - **Interactive** (stdin is a TTY): full-screen chat UI built on ratatui. @@ -25,7 +32,7 @@ mod chat; mod setup; -use std::io::IsTerminal; +use std::io::{BufWriter, IsTerminal, Write}; use std::sync::Arc; use agent_client_protocol::{ @@ -36,8 +43,12 @@ use agent_client_protocol::{ }; use futures::future::LocalBoxFuture; use onde::inference::{ChatEngine, GgufModelConfig}; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, mpsc, oneshot}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use tracing_subscriber::{EnvFilter, fmt as tracing_fmt}; + +#[cfg(unix)] +use std::os::unix::io::{AsRawFd, FromRawFd}; const SYSTEM_PROMPT: &str = "\ Your name is siGit — spelled exactly that way: lowercase 's', uppercase 'G', \ @@ -213,52 +224,131 @@ impl Agent for SiGitAgent { } } -// ── Banner ─────────────────────────────────────────────────────────────────── - -fn print_banner() { - const BANNER: &str = r#" -77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777 -77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777 -1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111 -1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111 -1111111111111111111111111111111201 109 304 40 00 79 100041111111111 -333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333 -333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333 -3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333 -2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222 -2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222 -2222255555555555555555555555299 708 1002 80 00 90852222222222222 -55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555 -88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888 - - siGit Code v%VERSION% -"#; - - let art = BANNER.replace("%VERSION%", env!("CARGO_PKG_VERSION")); - eprintln!("{art}"); +// ── Output capture ──────────────────────────────────────────────────────────── + +/// Redirect **both** stdout and stderr to `$TMPDIR/sigit.log` at the +/// file-descriptor level and return a [`std::fs::File`] handle to the *real* +/// terminal (the original stdout) so ratatui can still render to it. +/// +/// This is the nuclear option — it catches absolutely everything that any +/// library writes to stdout (`println!` in mistralrs `print_metadata`) or +/// stderr (`tracing::info!`, `log::info!`, raw `eprintln!`). +/// +/// Returns **two** `File` handles to the real terminal (both created via +/// `dup(STDOUT)` *before* the redirect): +/// +/// 1. **`tui`** — given to ratatui's `CrosstermBackend` for rendering. +/// 2. **`cleanup`** — kept by the caller for writing `LeaveAlternateScreen` +/// and restoring stdout/stderr after the TUI exits (since ratatui 0.29 +/// does not expose `writer_mut()` on the backend). +#[cfg(unix)] +fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> { + let log_path = std::env::temp_dir().join("sigit.log"); + let log_file = std::fs::File::create(&log_path)?; + let log_fd = log_file.as_raw_fd(); + + // Save TWO copies of the real terminal fd before we clobber stdout. + let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) }; + anyhow::ensure!( + saved_tui >= 0, + "dup(stdout) for tui failed: {}", + std::io::Error::last_os_error() + ); + let saved_cleanup = unsafe { libc::dup(libc::STDOUT_FILENO) }; + anyhow::ensure!( + saved_cleanup >= 0, + "dup(stdout) for cleanup failed: {}", + std::io::Error::last_os_error() + ); + + // Point stdout and stderr at the log file. + unsafe { + libc::dup2(log_fd, libc::STDOUT_FILENO); + libc::dup2(log_fd, libc::STDERR_FILENO); + } + + // `log_file` can drop — dup2 created independent references to the + // underlying file description, so stdout/stderr keep it alive. + + Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe { + std::fs::File::from_raw_fd(saved_cleanup) + })) } -// ── Interactive mode ───────────────────────────────────────────────────────── +// ── Logging ─────────────────────────────────────────────────────────────────── + +/// Initialise `tracing-subscriber` as the single logging backend. +/// +/// In TUI mode stdout/stderr have already been redirected to the log file by +/// [`redirect_output_to_log`], so the subscriber simply writes to stderr +/// (which *is* the log file). In ACP mode stderr is the real stderr. +fn init_logging(is_tty: bool) { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let _ = tracing_fmt::Subscriber::builder() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .with_ansi(!is_tty) + .try_init(); +} -/// Load the model, then hand off to the ratatui chat TUI. -async fn run_interactive() -> anyhow::Result<()> { - println!(" Loading model..."); +// ── Interactive mode ───────────────────────────────────────────────────────── +/// Start the TUI immediately, load the model concurrently, signal completion +/// via a oneshot channel so the TUI can animate the banner while waiting. +/// +/// The terminal is set up *manually* against the saved real-terminal `File` +/// returned by [`redirect_output_to_log`]. Because stdout/stderr have +/// already been redirected to the log file at that point, any `println!`, +/// `eprintln!`, `log::info!`, or `tracing::info!` emitted by mistralrs or +/// onde goes straight to `$TMPDIR/sigit.log` and never touches the screen. +/// +/// `tty` is given to ratatui; `cleanup_tty` is a second fd to the same +/// terminal, used for `LeaveAlternateScreen` and restoring stdout/stderr +/// (we cannot access the backend's writer because `writer_mut()` is private +/// in ratatui 0.29). +async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> { let engine = ChatEngine::new(); let config = GgufModelConfig::platform_default(); - engine - .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None) - .await - .map_err(|e| anyhow::anyhow!("model load failed: {e}"))?; - - let info = engine.info().await; - println!( - " \x1b[32m✓\x1b[0m {} ({})\n", - info.model_name.as_deref().unwrap_or("unknown"), - info.approx_memory.as_deref().unwrap_or("?"), + let (load_tx, load_rx) = oneshot::channel::<Result<(), String>>(); + + // Set up the terminal manually on the real tty fd. + crossterm::terminal::enable_raw_mode()?; + let mut tty = BufWriter::new(tty); + crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?; + let backend = ratatui::backend::CrosstermBackend::new(tty); + let mut terminal = ratatui::Terminal::new(backend)?; + + // Drive model loading and the TUI concurrently. Both futures hold a + // shared `&engine` reference, which is valid because ChatEngine is Sync + // and all its methods take `&self`. + let (_, chat_result) = tokio::join!( + async { + let result = engine + .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None) + .await; + // Ignore send errors — the TUI may have already quit (Ctrl+C). + let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); + }, + chat::run_with(&mut terminal, &engine, load_rx), ); - chat::run(&engine).await + // Restore the terminal before exiting. + // Use the separate cleanup fd — the backend's writer is private. + crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?; + cleanup_tty.flush()?; + crossterm::terminal::disable_raw_mode()?; + + // Restore stdout/stderr so any post-TUI error messages are visible. + #[cfg(unix)] + { + let cleanup_fd = cleanup_tty.as_raw_fd(); + unsafe { + libc::dup2(cleanup_fd, libc::STDOUT_FILENO); + libc::dup2(cleanup_fd, libc::STDERR_FILENO); + } + } + + chat_result } // ── ACP server mode ────────────────────────────────────────────────────────── @@ -312,21 +402,23 @@ async fn run_acp_server() -> anyhow::Result<()> { #[tokio::main] async fn main() -> anyhow::Result<()> { - // Logs always go to stderr (stdout is either the TUI or the ACP wire). - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) - .target(env_logger::Target::Stderr) - .init(); - - // Shared model cache (macOS App Group) — must run before anything - // touches hf-hub or ChatEngine. - setup::setup_shared_model_cache(); - - if std::io::stdin().is_terminal() { - // Interactive mode — full-screen chat TUI. - print_banner(); - run_interactive().await + let is_tty = std::io::stdin().is_terminal(); + + if is_tty { + // Redirect stdout/stderr to $TMPDIR/sigit.log *first* — before any + // library code can println!/eprintln!/log to the real terminal. + #[cfg(unix)] + let (tty, cleanup_tty) = redirect_output_to_log()?; + #[cfg(not(unix))] + anyhow::bail!("interactive mode requires Unix (macOS / Linux)"); + + init_logging(true); + setup::setup_shared_model_cache(); + run_interactive(tty, cleanup_tty).await } else { - // Editor spawned us — speak ACP over stdio. + // ACP mode: no redirect needed, logs go to stderr. + init_logging(false); + setup::setup_shared_model_cache(); log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION")); run_acp_server().await }