horus-cli 0.2.2

The terminal client for a Horus gateway
use std::io;
use std::time::Duration;

use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::SynchronizedUpdate;
use ratatui::crossterm::event::Event as TerminalEvent;
use ratatui::crossterm::event::KeyEventKind;
use ratatui::crossterm::execute;
use ratatui::crossterm::style::Print;
use tokio::time::MissedTickBehavior;

use super::TranscriptTone;
use super::TuiState;
use super::events::handle_gateway_event;
use super::input::UiAction;
use super::view::render_preview;
use crate::frontend::FrontendExit;
use crate::frontend::catalog::UiCatalog;
use crate::frontend::gateway;
use crate::frontend::gateway_actions::{prepare, render_response};
use crate::frontend::setup;
use crate::frontend::terminal::{INPUT_POLL, MAX_INPUT_BATCH, TerminalGuard, poll_event};
use horus::backend::model::ModelInfo;
use horus::protocol::{Op, Submission};
use horus::{Error, Result};
use horus_gateway::client::{GatewayEvents, GatewaySender};
use horus_gateway::wire::{ClientMessage, ReadyPayload, ServerMessage, SessionReadyPayload};
use uuid::Uuid;

const ELAPSED_INTERVAL: Duration = Duration::from_secs(1);
const CLEAR_SCREEN_AND_SCROLLBACK: &str = "\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H";

pub(in crate::frontend) async fn run(
    sender: GatewaySender,
    mut events: GatewayEvents,
    gateway: &mut ReadyPayload,
    session: &mut SessionReadyPayload,
    mut catalog: UiCatalog,
    local_gateway: bool,
    gateway_endpoint: String,
) -> Result<(FrontendExit, GatewaySender, GatewayEvents)> {
    let mut guard = TerminalGuard::alternate()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
    terminal.clear()?;
    if gateway.default_config.is_none() || gateway.models.is_empty() {
        setup::run(
            &mut terminal,
            setup::SetupMode::Login,
            None,
            &sender,
            &mut events,
            gateway,
            session,
        )
        .await?;
        if gateway.default_config.is_none() || gateway.models.is_empty() {
            drop(terminal);
            drop(guard);
            return Ok((FrontendExit::Discard, sender, events));
        }
        catalog.replace_model_choices(&gateway.models);
    }
    let mut workspace_inventory = catalog.start_workspace_inventory(local_gateway);
    let mut workspace_inventory_pending = true;
    let model = ModelInfo {
        model: session.session.model.model.clone(),
        reasoning_effort: session.session.model.reasoning_effort.clone(),
    };
    let model_route = session.session.model.route.clone();
    let session_id = session.session.session_id.clone();
    let mut state = TuiState::new(
        &catalog,
        catalog.workspace().to_path_buf(),
        model,
        model_route,
    );
    let mut tick = tokio::time::interval(INPUT_POLL);
    tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
    let mut elapsed = tokio::time::interval(ELAPSED_INTERVAL);
    elapsed.set_missed_tick_behavior(MissedTickBehavior::Skip);
    let mut events_open = true;
    let mut dirty = true;
    let mut exit = FrontendExit::Exit;
    let mut clear_on_exit = false;

    'ui: loop {
        if dirty {
            io::stdout().sync_update(|_| -> Result<()> {
                terminal.draw(|frame| {
                    if state.preview.is_some() {
                        render_preview(frame, &mut state);
                    } else {
                        super::view::render(frame, &mut state, &catalog);
                    }
                })?;
                Ok(())
            })??;
            dirty = false;
        }
        tokio::select! {
            event = events.next(), if events_open => {
                match event {
                    Ok(Some(frame)) => {
                        match frame.message {
                            ServerMessage::AgentEvent {
                                session_id: actual,
                                event,
                                blocks,
                                history,
                                preview,
                                ..
                            } if actual == session_id => {
                                handle_gateway_event(
                                    &mut state,
                                    event.msg,
                                    blocks,
                                    history,
                                    preview,
                                );
                            }
                            ServerMessage::SessionOpened { payload, .. } => {
                                *session = payload;
                                exit = FrontendExit::Reload;
                                break 'ui;
                            }
                            ServerMessage::Ready { payload } => {
                                *gateway = payload;
                                sync_gateway_models(&mut state, &mut catalog, gateway);
                            }
                            ServerMessage::SessionChanged { payload }
                                if payload.session.session_id == session_id
                                    && payload.config.revision >= session.config.revision =>
                            {
                                if payload.workspace.id == session.workspace.id
                                    && payload.contributions == session.contributions
                                {
                                    refresh_session(&mut state, session, payload);
                                } else {
                                    *session = payload;
                                    exit = FrontendExit::Resume(session_id.clone());
                                    break 'ui;
                                }
                            }
                            ServerMessage::Artifacts {
                                session_id: actual,
                                artifacts,
                                ..
                            } if actual == session_id => {
                                for artifact in artifacts {
                                    state.push(artifact.title, TranscriptTone::Neutral);
                                    state.apply_block(artifact.block);
                                }
                            }
                            message => {
                                if let Some(message) = render_response(&message, &session_id) {
                                    state.push(message, TranscriptTone::Neutral);
                                }
                            }
                        }
                        if let Some(request) = state.requested_resume.take() {
                            clear_on_exit = true;
                            exit = FrontendExit::Resume(request.session_id);
                            break 'ui;
                        }
                    }
                    Ok(None) => {
                        events_open = false;
                        state.disconnected = true;
                        state.finish_turn();
                        state.push("gateway disconnected · press q to exit", TranscriptTone::Error);
                    }
                    Err(error) => {
                        events_open = false;
                        state.disconnected = true;
                        state.finish_turn();
                        state.push(error.to_string(), TranscriptTone::Error);
                    }
                }
                dirty = true;
            }
            _ = tick.tick() => {
                for _ in 0..MAX_INPUT_BATCH {
                    let Some(event) = poll_event()? else {
                        break;
                    };
                    let action = match event {
                        TerminalEvent::Key(key) => {
                            dirty |= matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat);
                            state.handle_key(key, &catalog)
                        }
                        TerminalEvent::Paste(text) => {
                            if state.preview.is_none() && state.picker.is_none() {
                                let before = (state.input.len(), state.input_limit_reached);
                                state.insert_paste(&text);
                                dirty |=
                                    before != (state.input.len(), state.input_limit_reached);
                            }
                            UiAction::None
                        }
                        TerminalEvent::Resize(_, _) => {
                            dirty = true;
                            UiAction::None
                        }
                        TerminalEvent::Mouse(mouse) => {
                            dirty |= state.handle_mouse(mouse);
                            UiAction::None
                        }
                        TerminalEvent::FocusGained
                        | TerminalEvent::FocusLost => UiAction::None,
                    };
                    match action {
                        UiAction::None => {}
                        UiAction::Exit => {
                            if let Some(turn_id) = state.active_turn.clone() {
                                let _ = send_op(&sender, &session_id, Op::Interrupt { turn_id }).await;
                            }
                            break 'ui;
                        }
                        UiAction::New => {
                            exit = FrontendExit::New;
                            break 'ui;
                        }
                        UiAction::Clear => {
                            clear_on_exit = true;
                            exit = FrontendExit::New;
                            break 'ui;
                        }
                        UiAction::Submit(op) => {
                            if let Err(error) = send_op(&sender, &session_id, op).await {
                                state.push(error.to_string(), TranscriptTone::Error);
                            }
                        }
                        UiAction::Gateway(action) => match prepare(action, &session_id) {
                            Ok(message) => {
                                if let Err(error) = sender.send(*message).await {
                                    state.push(error.to_string(), TranscriptTone::Error);
                                }
                            }
                            Err(error) => state.push(error.to_string(), TranscriptTone::Error),
                        },
                        UiAction::GatewaySettings => {
                            match gateway::run(&mut terminal, &gateway_endpoint).await {
                                Ok(true) => {
                                    exit = FrontendExit::Reconnect;
                                    break 'ui;
                                }
                                Ok(false) => {}
                                Err(error) => {
                                    state.push(error.to_string(), TranscriptTone::Error);
                                }
                            }
                            dirty = true;
                        }
                        UiAction::Setup { mode, provider } => {
                            let workspace = session.workspace.id.clone();
                            let selected = session.session.session_id.clone();
                            let contributions = session.contributions.clone();
                            let result = setup::run(
                                &mut terminal,
                                mode,
                                provider.as_deref(),
                                &sender,
                                &mut events,
                                gateway,
                                session,
                            )
                            .await;
                            if session.workspace.id != workspace
                                || session.session.session_id != selected
                                || session.contributions != contributions
                            {
                                exit = FrontendExit::Resume(
                                    session.session.session_id.clone(),
                                );
                                break 'ui;
                            }
                            sync_gateway_models(&mut state, &mut catalog, gateway);
                            sync_session(&mut state, session);
                            if let Err(error) = result {
                                state.push(error.to_string(), TranscriptTone::Error);
                            }
                            dirty = true;
                        }
                    }
                }
            }
            _ = elapsed.tick(), if state.active_turn.is_some() => {
                dirty = true;
            }
            result = &mut workspace_inventory, if workspace_inventory_pending => {
                let _ = result;
                workspace_inventory_pending = false;
                state.reference_cache = None;
                dirty = true;
            }
        }
        guard.set_mouse_capture(state.preview.is_none())?;
    }
    drop(terminal);
    drop(guard);
    if clear_on_exit {
        execute!(io::stdout(), Print(CLEAR_SCREEN_AND_SCROLLBACK))?;
    }
    Ok((exit, sender, events))
}

fn refresh_session(
    state: &mut TuiState,
    session: &mut SessionReadyPayload,
    payload: SessionReadyPayload,
) {
    sync_session(state, &payload);
    *session = payload;
}

fn sync_session(state: &mut TuiState, session: &SessionReadyPayload) {
    state.model.model = super::terminal_text(&session.session.model.model);
    state.model.reasoning_effort = session
        .session
        .model
        .reasoning_effort
        .as_deref()
        .map(super::terminal_text);
    state.model_route.clone_from(&session.session.model.route);
}

fn sync_gateway_models(state: &mut TuiState, catalog: &mut UiCatalog, gateway: &ReadyPayload) {
    state.model_choices.clone_from(&gateway.models);
    catalog.replace_model_choices(&gateway.models);
}

async fn send_op(
    sender: &horus_gateway::client::GatewaySender,
    session_id: &str,
    op: Op,
) -> Result<()> {
    sender
        .send(ClientMessage::Submit {
            session_id: session_id.into(),
            submission: Submission {
                id: Uuid::new_v4().to_string(),
                op,
            },
        })
        .await
        .map_err(|error| Error::Stopped(error.to_string()))
}