oy-cli 0.8.3

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use anyhow::{Context as _, Result, bail};
use dialoguer::{Confirm, theme::ColorfulTheme};
use std::fmt::Display;

use reedline_repl_rs::reedline::{
    DefaultPrompt, DefaultPromptSegment, EditCommand, Emacs, FileBackedHistory, KeyCode,
    KeyModifiers, Reedline, ReedlineEvent, Signal, default_emacs_keybindings,
};
use std::path::PathBuf;

use crate::config;
use crate::session::{self, Session};
use crate::tools::TodoStatus;

mod commands;
mod history;

use commands::handle_slash_command;
pub(crate) use commands::{
    RecentModelChoice, ask, choose_model, choose_model_with_initial_list, choose_recent_model,
};
use history::history_path;

const HISTORY_SIZE: usize = 10_000;
const MAX_CONTEXT_RECOVERY_ATTEMPTS: usize = 3;

fn chat_line_editor(history_path: PathBuf) -> Result<Reedline> {
    let mut keybindings = default_emacs_keybindings();
    keybindings.add_binding(KeyModifiers::NONE, KeyCode::Enter, ReedlineEvent::Submit);
    let insert_newline = ReedlineEvent::Edit(vec![EditCommand::InsertNewline]);
    keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::Enter, insert_newline.clone());
    keybindings.add_binding(KeyModifiers::ALT, KeyCode::Enter, insert_newline);

    Ok(Reedline::create()
        .with_history(Box::new(FileBackedHistory::with_file(
            HISTORY_SIZE,
            history_path,
        )?))
        .with_edit_mode(Box::new(Emacs::new(keybindings)))
        .use_bracketed_paste(true))
}

pub async fn run_chat(session: &mut Session) -> Result<i32> {
    crate::ui::section("oy chat");
    crate::ui::kv(
        "keys",
        "Enter sends · Alt/Shift+Enter newline · Ctrl-C interrupts active turn/quits prompt · /? help",
    );
    let history_path = history_path("chat")?;
    let mut line_editor = chat_line_editor(history_path.clone())?;
    let prompt = DefaultPrompt::new(
        DefaultPromptSegment::Basic("oy".to_string()),
        DefaultPromptSegment::Empty,
    );

    loop {
        let signal = match line_editor.read_line(&prompt) {
            Ok(signal) => signal,
            Err(err) if is_cursor_position_timeout(&err) => {
                crate::ui::warn("terminal cursor position timed out; resetting prompt");
                line_editor = chat_line_editor(history_path.clone())?;
                continue;
            }
            Err(err) => return Err(err.into()),
        };

        match signal {
            Signal::Success(line) => {
                line_editor.sync_history()?;
                if !handle_chat_line(session, line.trim()).await? {
                    break;
                }
            }
            Signal::CtrlD => break,
            Signal::CtrlC => {
                line_editor.sync_history()?;
                break;
            }
        }
    }
    prompt_update_todo_on_quit(session);
    Ok(0)
}

fn is_cursor_position_timeout(err: &impl Display) -> bool {
    let text = err.to_string();
    text.contains("cursor position") && text.contains("could not be read")
}

fn prompt_update_todo_on_quit(session: &Session) {
    if config::can_prompt() && !session.todos.is_empty() {
        let active = session
            .todos
            .iter()
            .filter(|item| item.status != TodoStatus::Done)
            .count();
        crate::ui::line(format_args!(
            "todo summary: {active}/{} active in memory; use the todo tool with persist=true to write TODO.md",
            session.todos.len()
        ));
    }
}

async fn handle_chat_line(session: &mut Session, line: &str) -> Result<bool> {
    if line.is_empty() {
        return Ok(true);
    }
    if let Some(command) = line.strip_prefix('/') {
        return handle_slash_command(session, command.trim()).await;
    }
    run_prompt_with_context_recovery(session, line).await?;
    Ok(true)
}

#[derive(Debug, Clone, Copy)]
struct ChatTurnInterrupted;

impl std::fmt::Display for ChatTurnInterrupted {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "chat turn interrupted")
    }
}

impl std::error::Error for ChatTurnInterrupted {}

async fn run_prompt_interruptible(session: &mut Session, prompt: &str) -> Result<String> {
    tokio::select! {
        result = session::run_prompt(session, prompt) => result,
        signal_result = tokio::signal::ctrl_c() => {
            signal_result.context("failed to listen for Ctrl-C")?;
            bail!(ChatTurnInterrupted);
        }
    }
}

async fn run_prompt_with_context_recovery(session: &mut Session, prompt: &str) -> Result<()> {
    let mut recovery_attempts = 0usize;
    loop {
        match run_prompt_interruptible(session, prompt).await {
            Ok(answer) => {
                if !answer.is_empty() {
                    crate::ui::markdown(&format!("{answer}\n"));
                }
                return Ok(());
            }
            Err(err) => {
                if err.is::<ChatTurnInterrupted>() {
                    session.transcript.undo_last_turn();
                    crate::ui::warn("interrupted current turn; still in chat");
                    return Ok(());
                }
                let Some(budget_err) = err
                    .downcast_ref::<session::ContextBudgetExceeded>()
                    .copied()
                else {
                    return Err(err);
                };
                recovery_attempts += 1;
                crate::ui::err_line(format_args!("model call failed: {err:#}"));
                session.transcript.undo_last_turn();
                if recovery_attempts >= MAX_CONTEXT_RECOVERY_ATTEMPTS {
                    offer_save_after_context_failures(session)?;
                    return Ok(());
                }
                if !recover_context_budget(session, recovery_attempts, budget_err)? {
                    return Ok(());
                }
            }
        }
    }
}

fn recover_context_budget(
    session: &mut Session,
    attempt: usize,
    budget_err: session::ContextBudgetExceeded,
) -> Result<bool> {
    if config::can_prompt() {
        let raised_limit =
            config::context_config().input_budget_tokens() >= budget_err.estimated_tokens;
        let choices = vec![
            format!(
                "Retry with current OY_CONTEXT_LIMIT={}{}",
                config::context_config().limit_tokens,
                if raised_limit {
                    " (now sufficient)"
                } else {
                    ""
                }
            ),
            "Force-truncate oldest history and retry".to_string(),
            "Save session and stop".to_string(),
            "Stop without saving".to_string(),
        ];
        let choice = ask("Context is over budget. Choose recovery", Some(&choices))?;
        if choice.starts_with("Retry with current OY_CONTEXT_LIMIT=") {
            return Ok(true);
        }
        match choice.as_str() {
            "Force-truncate oldest history and retry" => {}
            "Save session and stop" => {
                let path = session.save(None)?;
                crate::ui::success(format_args!("saved session {}", path.display()));
                crate::ui::line(
                    "Try `/load` later, or switch models with `/model` after reloading.",
                );
                return Ok(false);
            }
            _ => return Ok(false),
        }
    }

    let before = session.context_status().estimate.total_tokens;
    let removed = session.transcript.force_truncate_oldest_turns();
    let after = session.context_status().estimate.total_tokens;
    if removed == 0 || after >= before {
        if attempt + 1 >= MAX_CONTEXT_RECOVERY_ATTEMPTS {
            offer_save_after_context_failures(session)?;
            return Ok(false);
        }
        anyhow::bail!(
            "context remains over budget and no more history can be truncated; save the session and try a different model later"
        );
    }
    crate::ui::warn(format_args!(
        "force-truncated {removed} old messages: {before} -> {after} tokens"
    ));
    Ok(true)
}

fn offer_save_after_context_failures(session: &Session) -> Result<()> {
    crate::ui::warn(format_args!(
        "context is still over budget after {MAX_CONTEXT_RECOVERY_ATTEMPTS} recovery attempts"
    ));
    if config::can_prompt()
        && Confirm::with_theme(&ColorfulTheme::default())
            .with_prompt("Save this session so you can resume later?")
            .default(true)
            .interact()?
    {
        let path = session
            .save(None)
            .context("failed to save over-budget session")?;
        crate::ui::success(format_args!("saved session {}", path.display()));
    }
    crate::ui::line(
        "Try `/load` later, then raise OY_CONTEXT_LIMIT, use `/compact`, or switch models with `/model`.",
    );
    Ok(())
}