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
}