superintelligence / src/main.rs

raw file 12182 bytes
mod config;
mod llm;
mod tools;

use std::io::{IsTerminal, Read, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};

use llm::{Chat, Image, Item};
use tools::Policy;

/// No args on a terminal starts an interactive session. Anything else is one
/// shot:
///
///   si                                  # interactive chat
///   si "what is a 3DS?"                 # one shot
///   si "look around" --yes              # one shot, tools run without asking
///   si "describe this" photo.jpg        # one shot, with an image
///   echo "what is a 3DS?" | si          # one shot from stdin
///
/// In the REPL, image paths typed in the prompt are attached to the message.
#[tokio::main]
async fn main() {
    if let Err(err) = run().await {
        eprintln!("error: {err:#}");
        std::process::exit(1);
    }
}

async fn run() -> anyhow::Result<()> {
    let raw_args: Vec<String> = std::env::args().skip(1).collect();

    let auto_approve = raw_args.iter().any(|a| a == "--yes" || a == "-y");
    let args: Vec<String> = raw_args
        .into_iter()
        .filter(|a| a != "--yes" && a != "-y")
        .collect();

    // Nothing but a terminal to talk to: interactive session.
    if args.is_empty() && std::io::stdin().is_terminal() {
        return repl(Policy { auto_approve }, Vec::new()).await;
    }

    let (prompt, images) = split_args(args)?;
    let policy = Policy { auto_approve };

    // Images but no question, on a real terminal: rather than blocking on
    // stdin, open a session with the images loaded so the user can just ask.
    if prompt.is_empty() && !images.is_empty() && std::io::stdin().is_terminal() {
        return repl(policy, images).await;
    }

    let mut chat = Chat::new(policy)?;
    let mut sink = OutputSink::new();

    let result = chat.send(&prompt, &images, |item| sink.on_item(item)).await;

    match result {
        Ok(_) => {
            sink.finish();
            Ok(())
        }
        Err(err) => {
            sink.abort();
            Err(err)
        }
    }
}

/// Multi-turn session: read a line, stream a reply, repeat until Ctrl-D.
///
/// `pending_images` are attached to the first message sent, which is how
/// `si photo.jpg` gets its image into the conversation.
async fn repl(policy: Policy, pending_images: Vec<Image>) -> anyhow::Result<()> {
    let mut chat = Chat::new(policy)?;
    let mut sink = OutputSink::new();
    let mut pending_images = pending_images;

    println!("si {} — /help for commands", config::MODEL);
    if !pending_images.is_empty() {
        println!(
            "\x1b[2m{} image(s) attached — ask about them\x1b[0m",
            pending_images.len()
        );
    }

    let stdin = std::io::stdin();
    loop {
        print!("\n> ");
        let _ = std::io::stdout().flush();

        let mut line = String::new();
        if stdin.read_line(&mut line)? == 0 {
            println!();
            break; // Ctrl-D
        }

        let input = line.trim();
        if input.is_empty() {
            continue;
        }

        match input {
            "/quit" | "/exit" => break,
            "/clear" => {
                chat.reset();
                pending_images.clear();
                sink.note("history cleared");
                continue;
            }
            "/help" => {
                sink.note(
                    "/quit exit · /clear forget history · /turns count · image paths are attached",
                );
                continue;
            }
            "/turns" => {
                sink.note(&format!("{} turn(s) in history", chat.turn_count()));
                continue;
            }
            _ => {}
        }

        // Any word naming an image file becomes an attachment.
        let (text, images) = match split_repl_input(input) {
            Ok(split) => split,
            Err(err) => {
                sink.note(&format!("error: {err:#}"));
                continue;
            }
        };

        // Images handed over at startup ride along with the first question.
        let mut images = images;
        if !pending_images.is_empty() {
            images.append(&mut pending_images);
        }

        match chat.send(&text, &images, |item| sink.on_item(item)).await {
            Ok(()) => sink.finish(),
            // Keep the session alive; the turn was rolled back.
            Err(err) => {
                sink.abort();
                eprintln!("error: {err:#}");
            }
        }
    }

    Ok(())
}

/// Pull image paths out of REPL input. Words with an image extension are
/// attachments; everything else is prompt text.
fn split_repl_input(input: &str) -> anyhow::Result<(String, Vec<Image>)> {
    let mut words: Vec<&str> = Vec::new();
    let mut images: Vec<Image> = Vec::new();

    for word in input.split_whitespace() {
        let path = PathBuf::from(word);
        if Image::mime_for(&path).is_some() {
            if !path.is_file() {
                anyhow::bail!("image not found: {}", path.display());
            }
            images.push(Image::load(&path)?);
        } else {
            words.push(word);
        }
    }

    Ok((words.join(" "), images))
}

/// Split argv into text prompt and image attachments. Anything with an image
/// extension is an image, not prompt text.
fn split_args(args: Vec<String>) -> anyhow::Result<(String, Vec<Image>)> {
    let mut words: Vec<String> = Vec::new();
    let mut images: Vec<Image> = Vec::new();

    for arg in args {
        let path = PathBuf::from(&arg);
        if Image::mime_for(&path).is_some() {
            if !path.is_file() {
                anyhow::bail!("image not found: {}", path.display());
            }
            images.push(Image::load(&path)?);
        } else {
            words.push(arg);
        }
    }

    // No prompt on the command line: fall back to stdin, but only when it's a
    // pipe. Blocking on a terminal's stdin here would silently hang.
    if words.is_empty() && !std::io::stdin().is_terminal() {
        let mut buf = String::new();
        std::io::stdin()
            .read_to_string(&mut buf)
            .map_err(|e| anyhow::anyhow!("reading stdin: {e}"))?;
        words.push(buf);
    }

    let prompt = words.join(" ").trim().to_string();
    if prompt.is_empty() && images.is_empty() {
        anyhow::bail!("empty prompt (pass text and/or an image path)");
    }
    Ok((prompt, images))
}

/// Minimum gap between live re-renders. Without this, a fast token stream
/// repaints the terminal hundreds of times a second.
const MIN_REPAINT_INTERVAL: Duration = Duration::from_millis(40);

/// Streams the reply to stdout.
///
/// On a terminal, the whole reply is kept as raw Markdown and re-rendered
/// through termimad periodically, so it always ends up fully formatted. When
/// piped, the raw Markdown is emitted straight through and never touched.
struct OutputSink {
    styled: bool,
    source: String,
    /// Terminal rows the last repaint occupied, so we know how far to move the
    /// cursor back up before overwriting.
    painted_rows: usize,
    last_repaint: Instant,
    /// When the in-flight tool call started, for timing its completion line.
    tool_started: Option<Instant>,
    skin: termimad::MadSkin,
}

impl OutputSink {
    fn new() -> Self {
        Self {
            styled: std::io::stdout().is_terminal(),
            source: String::new(),
            painted_rows: 0,
            last_repaint: Instant::now(),
            tool_started: None,
            skin: termimad::MadSkin::default(),
        }
    }

    /// A short non-model message (status, help). Ends the current paint so the
    /// next reply repaints from a clean slate.
    fn note(&mut self, text: &str) {
        if self.styled {
            self.clear();
        }
        println!("\x1b[2m{text}\x1b[0m");
        let _ = std::io::stdout().flush();
    }

    /// One ordered stream of model text and tool activity.
    ///
    /// Tool lines are dimmed and column-aligned so they read as machinery
    /// running alongside the answer rather than as part of it.
    fn on_item(&mut self, item: Item) {
        match item {
            Item::Text(delta) => self.on_text(&delta),

            Item::ToolStart { name, summary } => {
                // The reply pauses while a tool runs, so drop whatever was
                // painted; the next text repaint starts from a clean slate.
                if self.styled {
                    self.clear();
                }
                self.tool_started = Some(Instant::now());

                if !self.styled {
                    return; // keep piped output as pure model text
                }
                let args = summary
                    .strip_prefix(&format!("{name}("))
                    .and_then(|s| s.strip_suffix(')'))
                    .unwrap_or(&summary);
                println!("  \x1b[36m{name:<11}\x1b[0m\x1b[2m{args}\x1b[0m");
                let _ = std::io::stdout().flush();
            }

            Item::ToolDone { outcome } => {
                let Some(started) = self.tool_started.take() else {
                    return;
                };
                if !self.styled {
                    return;
                }
                let secs = started.elapsed().as_secs_f32();
                let timing = if secs >= 1.0 {
                    format!("{secs:.1}s")
                } else {
                    format!("{:.0}ms", secs * 1000.0)
                };
                // Errors in red, everything else green; both dimmed so the
                // answer stays the brightest thing on screen.
                let color = if outcome.starts_with("error:") {
                    "31"
                } else if outcome == "skipped" {
                    "33"
                } else {
                    "32"
                };
                println!(
                    "  \x1b[2m╰─\x1b[0m \x1b[2;{color}m{outcome}\x1b[0m\x1b[2m · {timing}\x1b[0m"
                );
                let _ = std::io::stdout().flush();
            }
        }
    }

    fn on_text(&mut self, delta: &str) {
        self.source.push_str(delta);

        if !self.styled {
            self.flush_raw(delta);
            return;
        }

        // Newlines change the document's shape, so repaint immediately rather
        // than waiting out the throttle.
        let structural = delta.contains('\n');
        if structural || self.last_repaint.elapsed() >= MIN_REPAINT_INTERVAL {
            self.repaint();
        }
    }

    fn finish(&mut self) {
        if self.styled {
            self.repaint();
        }
        println!();
        // The trailing newline moved us past this reply; the next repaint
        // must not clear it.
        self.painted_rows = 0;
        self.source.clear();
        let _ = std::io::stdout().flush();
    }

    /// On error: drop the half-painted render and leave a clean error line for
    /// the caller to print.
    fn abort(&mut self) {
        if self.styled {
            self.clear();
        }
        self.source.clear();
        self.tool_started = None;
        let _ = std::io::stdout().flush();
    }

    fn flush_raw(&mut self, delta: &str) {
        print!("{delta}");
        let _ = std::io::stdout().flush();
    }

    /// Move the cursor back above the previous render and wipe it.
    fn clear(&mut self) {
        if self.painted_rows > 0 {
            // ESC[<n>A: cursor up n rows. ESC[J: erase from cursor to screen end.
            print!("\x1b[{}A\x1b[J", self.painted_rows);
        }
        self.painted_rows = 0;
    }

    fn repaint(&mut self) {
        let width = termimad::terminal_size().0 as usize;
        // FmtText renders itself to a string; counting the lines in that gives
        // exactly how many terminal rows the repaint will occupy.
        let rendered = self.skin.text(&self.source, Some(width)).to_string();
        let rows = rendered.matches('\n').count();

        self.clear();
        print!("{rendered}");
        let _ = std::io::stdout().flush();

        self.painted_rows = rows;
        self.last_repaint = Instant::now();
    }
}