coven 0.1.0

A minimal streaming display and workflow runner for Claude Code's -p mode
Documentation
use std::io::Write;
use std::path::PathBuf;

use anyhow::Result;
use crossterm::event::Event;
use crossterm::terminal;

use crate::display::input::{InputAction, InputHandler};
use crate::display::renderer::{Renderer, StoredMessage};
use crate::fork::{self, ForkConfig};
use crate::session::runner::{SessionConfig, SessionRunner};
use crate::session::state::{SessionState, SessionStatus};
use crate::vcr::{Io, IoEvent, VcrContext};

use super::session_loop::{self, FollowUpAction, SessionOutcome};

pub struct RunConfig {
    pub prompt: Option<String>,
    pub extra_args: Vec<String>,
    pub show_thinking: bool,
    pub fork: bool,
    pub working_dir: Option<PathBuf>,
}

/// Run a single interactive session. Returns the stored messages for inspection.
pub async fn run<W: Write>(
    config: RunConfig,
    io: &mut Io,
    vcr: &VcrContext,
    writer: W,
) -> Result<Vec<StoredMessage>> {
    let mut renderer = Renderer::with_writer(writer);
    renderer.set_show_thinking(config.show_thinking);
    let mut input = InputHandler::new();
    let mut state = SessionState::default();
    if vcr.is_live() {
        terminal::enable_raw_mode()?;
    }
    renderer.render_help();

    let fork_system_prompt = config.fork.then(|| fork::fork_system_prompt().to_string());
    let fork_config = ForkConfig::if_enabled(config.fork, &config.extra_args, &config.working_dir);

    let Some(mut runner) = get_initial_runner(
        &config,
        &mut renderer,
        &mut input,
        &mut state,
        fork_system_prompt.as_deref(),
        io,
        vcr,
    )
    .await?
    else {
        if vcr.is_live() {
            terminal::disable_raw_mode()?;
        }
        return Ok(vec![]);
    };
    loop {
        let outcome = session_loop::run_session(
            &mut runner,
            &mut state,
            &mut renderer,
            &mut input,
            io,
            vcr,
            fork_config.as_ref(),
        )
        .await?;

        match outcome {
            SessionOutcome::Completed { .. } => {
                match session_loop::wait_for_followup(
                    &mut input,
                    &mut renderer,
                    &mut runner,
                    &mut state,
                    io,
                    vcr,
                )
                .await?
                {
                    FollowUpAction::Sent => {}
                    FollowUpAction::Exit => break,
                }
            }
            SessionOutcome::Interrupted => {
                runner.close_input();
                let _ = runner.wait().await;
                io.clear_event_channel();
                let Some(session_id) = state.session_id.take() else {
                    break;
                };
                renderer.render_interrupted();
                let Some(text) =
                    session_loop::wait_for_user_input(&mut input, &mut renderer, io, vcr).await?
                else {
                    break;
                };
                let session_cfg = SessionConfig {
                    prompt: Some(text),
                    extra_args: config.extra_args.clone(),
                    append_system_prompt: fork_system_prompt.clone(),
                    resume: Some(session_id),
                    working_dir: config.working_dir.clone(),
                };
                runner = session_loop::spawn_session(session_cfg, io, vcr).await?;
                state = SessionState::default();
            }
            SessionOutcome::ProcessExited => break,
        }
    }

    if vcr.is_live() {
        terminal::disable_raw_mode()?;
    }
    runner.close_input();
    let _ = runner.wait().await;
    Ok(renderer.into_messages())
}

/// Get the initial runner: either from prompt or by waiting for interactive input.
/// Returns None if the user exits without submitting.
async fn get_initial_runner<W: Write>(
    config: &RunConfig,
    renderer: &mut Renderer<W>,
    input: &mut InputHandler,
    state: &mut SessionState,
    fork_system_prompt: Option<&str>,
    io: &mut Io,
    vcr: &VcrContext,
) -> Result<Option<SessionRunner>> {
    if let Some(ref prompt) = config.prompt {
        let session_cfg = SessionConfig {
            prompt: Some(prompt.clone()),
            extra_args: config.extra_args.clone(),
            append_system_prompt: fork_system_prompt.map(String::from),
            working_dir: config.working_dir.clone(),
            ..Default::default()
        };
        return Ok(Some(
            session_loop::spawn_session(session_cfg, io, vcr).await?,
        ));
    }

    renderer.show_prompt();
    input.activate();

    loop {
        let io_event: IoEvent = vcr
            .call("next_event", (), async |(): &()| io.next_event().await)
            .await?;
        match io_event {
            IoEvent::Terminal(Event::Key(key_event)) => {
                let action = input.handle_key(&key_event);
                match action {
                    InputAction::Submit(text, _) => {
                        let session_cfg = SessionConfig {
                            prompt: Some(text),
                            extra_args: config.extra_args.clone(),
                            append_system_prompt: fork_system_prompt.map(String::from),
                            working_dir: config.working_dir.clone(),
                            ..Default::default()
                        };
                        let runner = session_loop::spawn_session(session_cfg, io, vcr).await?;
                        state.status = SessionStatus::Running;
                        return Ok(Some(runner));
                    }
                    InputAction::Interrupt | InputAction::EndSession => return Ok(None),
                    InputAction::Cancel => {
                        renderer.show_prompt();
                        input.activate();
                    }
                    _ => {}
                }
            }
            IoEvent::Terminal(_) | IoEvent::Claude(_) => {}
        }
    }
}