wryme 1.5.0

wryme • that small, calm window where agents come to meet you
// Entry point. Owns the terminal, the tokio runtime, the API client, and
// the event loop that selects between keyboard events and streaming deltas.

use anyhow::{Context, Result};
use clap::Parser;
use crossterm::{
    event::{
        Event, EventStream, KeyEventKind, DisableMouseCapture, EnableMouseCapture,
    },
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures_util::StreamExt;
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io::Stdout;
use tokio::sync::mpsc;

mod api;
mod api_chat;
mod api_responses;
mod app;
mod book;
mod demo;
mod explore;
mod input;
mod jobs;
mod tools;
mod keys;
mod md;
mod popup;
mod popup_ui;
mod shop;
mod station;
mod station_save;
mod ui;
mod voice;

use api::{Client, StreamEvent};
use app::App;
use input::Input;

#[derive(Parser, Debug)]
#[command(
    name = "wryme",
    version = env!("WRYME_VERSION"),
    about = "wryme • that small, calm window where agents come to meet you"
)]
struct Args {
    /// Name of a saved station to use. Defaults to the first saved station,
    /// or a synthesized "untitled" station built from the newest model the
    /// first shop advertises, or the built-in demo if nothing is configured.
    #[arg(long)]
    station: Option<String>,

    /// Optional system prompt prepended to every request.
    #[arg(long)]
    system: Option<String>,
}

#[tokio::main]
async fn main() -> Result<()> {
    init_logging();
    let _sentry = sentry::init(sentry::ClientOptions {
        dsn: std::env::var("SENTRY_DSN")
            .ok()
            .and_then(|s| s.parse().ok())
            .or_else(|| {
                "https://114f188d49c0df6af704e00e13a7f512@o4510982366625792.ingest.us.sentry.io/4512044113068032"
                    .parse()
                    .ok()
            }),
        release: Some(env!("WRYME_VERSION").into()),
        traces_sample_rate: 0.0,
        ..Default::default()
    });
    tracing::info!(version = env!("WRYME_VERSION"), "wme launch");
    let args = Args::parse();

    let mut shops = shop::load_all().context("loading shops")?;
    let discovery_errors = shop::discover_all(&mut shops).await;
    let stations = station::load_all().context("loading stations")?;
    let (active, active_origin) = station::pick(&stations, &shops, args.station.as_deref())?;

    // Resolve the shop that advertises this station's model.
    let active_shop = shop::find_for_model(&shops, &active.model)
        .cloned()
        .with_context(|| {
            format!(
                "station '{}' wants model '{}' but no shop advertises it. \
                 add this model to a shop's `models = [...]` list in shops.toml.",
                active.name, active.model
            )
        })?;

    let client = Client::new().context("building api client")?;
    tracing::info!(
        shop = %active_shop.name,
        model = %active.model,
        protocol = ?active_shop.protocol,
        window = ?active_shop.window,
        "wme start"
    );

    let mut terminal = setup_terminal().context("entering tui")?;
    install_panic_hook();

    let result = run(
        &mut terminal,
        client,
        args.system,
        shops,
        stations,
        active,
        active_shop,
        active_origin,
        discovery_errors,
    )
    .await;

    restore_terminal(&mut terminal).ok();
    result
}

fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
    enable_raw_mode()?;
    let mut stdout = std::io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    terminal.clear()?;
    Ok(terminal)
}

fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
    terminal.show_cursor()?;
    Ok(())
}

fn install_panic_hook() {
    let prev = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let _ = disable_raw_mode();
        let _ = execute!(std::io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
        prev(info);
    }));
}

fn init_logging() {
    let dir = std::env::var("HOME").ok().map(|h| {
        std::path::PathBuf::from(h)
            .join(".local")
            .join("share")
            .join("wryme")
    });
    let Some(dir) = dir else { return };
    let _ = std::fs::create_dir_all(&dir);
    let Ok(file) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(dir.join("wryme.log"))
    else {
        return;
    };
    let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "wme=info".into());
    let _ = tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_writer(std::sync::Mutex::new(file))
        .with_ansi(false)
        .try_init();
}

async fn run(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    client: Client,
    system: Option<String>,
    shops: Vec<shop::Shop>,
    stations: Vec<station::Station>,
    active_station: station::Station,
    active_shop: shop::Shop,
    active_origin: Option<String>,
    discovery_errors: Vec<(String, String)>,
) -> Result<()> {
    let mut app = App::new(
        system,
        shops,
        stations,
        active_station,
        active_shop,
        active_origin,
    );
    if !discovery_errors.is_empty() {
        let summary = discovery_errors
            .iter()
            .map(|(s, e)| format!("{}: {}", s, e))
            .collect::<Vec<_>>()
            .join("; ");
        app.note(format!("discovery: {}", summary));
    }
    let mut input = Input::new();
    let mut events = EventStream::new();
    let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
    let mut in_flight_task: Option<tokio::task::JoinHandle<()>> = None;

    loop {
        terminal.draw(|f| ui::draw(f, &mut app, &input))?;
        if app.should_quit {
            break;
        }

        tokio::select! {
            maybe_ev = events.next() => {
                let Some(Ok(ev)) = maybe_ev else { continue };
                match ev {
                    Event::Key(k) if k.kind != KeyEventKind::Release => {
                        keys::handle_key(k, &mut app, &mut input, &client, &tx, &mut in_flight_task);
                    }
                    Event::Mouse(m) => {
                        keys::handle_mouse(m, &mut app);
                    }
                    Event::Resize(_, _) => { /* redraw on next loop */ }
                    _ => {}
                }
            }
            Some(stream_ev) = rx.recv() => {
                match stream_ev {
                    StreamEvent::Delta { text } => {
                        app.append_to_last_assistant(&text);
                        if app.voice_on && !app.voice_muted {
                            let voice = app.active_station.voice.clone();
                            app.ensure_speaker(voice);
                            app.voice_buffer.push_str(&text);
                            for s in crate::voice::split_sentences(&mut app.voice_buffer) {
                                if let Some(sp) = &app.voice_speaker {
                                    sp.say(s);
                                }
                            }
                        }
                    }
                    StreamEvent::Brain { text } => {
                        app.append_to_last_brain(&text);
                    }
                    StreamEvent::ToolCall { name } => {
                        app.record_tool_call(name);
                    }
                    StreamEvent::ToolResult { call_id, name, arguments, output } => {
                        app.record_tool_result(call_id, name, arguments, output);
                    }
                    StreamEvent::ResponseId { id } => {
                        app.last_response_id = Some(id);
                    }
                    StreamEvent::WindowUnsupported { shop } => {
                        // Runtime only, config file untouched: this shop
                        // doesn't retain windows, so pin it to full for
                        // the rest of this window. Trips once per launch.
                        for s in app.shops.iter_mut() {
                            if s.name == shop {
                                s.window = crate::shop::WindowMode::Full;
                            }
                        }
                        if app.active_shop.name == shop {
                            app.active_shop.window = crate::shop::WindowMode::Full;
                        }
                        app.note(format!("{shop}: warm window unsupported, using full"));
                    }
                    StreamEvent::Usage { input, output } => {
                        // Latest prompt size replaces (each request
                        // re-reports the full transcript); generated
                        // tokens accumulate across the window.
                        app.usage_ctx = input;
                        app.usage_out += output;
                    }
                    StreamEvent::Done => {
                        app.finish_streaming();
                        if app.voice_on && !app.voice_muted {
                            let tail = app.voice_buffer.trim().to_string();
                            app.voice_buffer.clear();
                            let voice = app.active_station.voice.clone();
                            app.ensure_speaker(voice);
                            if let Some(sp) = &app.voice_speaker {
                                if !tail.is_empty() {
                                    sp.say(tail);
                                }
                                sp.flush();
                            }
                        }
                        if let Some(t) = in_flight_task.take() {
                            drop(t);
                        }
                    }
                    StreamEvent::Error { message } => {
                        tracing::error!(err = %message, "turn error");
                        app.note(format!("upstream: {message}"));
                    }
                }
            }
            _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => {
                // Background auto-delivery: a finished async job, and no
                // turn in flight, so fire a calm background turn that
                // plants the result and lets the model tell the user.
                if jobs::has_due() && !app.in_flight {
                    app.begin_assistant();
                    app.in_flight = true;
                    let msgs = app.api_messages();
                    let prev_id = app.last_response_id.clone();
                    let shop = app.active_shop.clone();
                    let station = app.active_station.clone();
                    let client = client.clone();
                    let engine = app.engine.clone();
                    let tx = tx.clone();
                    in_flight_task = Some(tokio::spawn(async move {
                        client
                            .stream_completion(shop, station, msgs, prev_id, engine, tx)
                            .await;
                    }));
                }
            }
        }
    }

    if let Some(t) = in_flight_task.take() {
        t.abort();
    }
    Ok(())
}