mobius-cli 0.15.6

The terminal client for a möbius gateway
Documentation
use std::io;

use mobius::{Error, Result};
use mobius_gateway::client::{GatewayEvents, GatewaySender, MAX_PENDING_FRAMES};
use mobius_gateway::wire::{ClientMessage, ReadyPayload, ServerFrame, ServerMessage};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::Event;
use tokio::time::MissedTickBehavior;
use uuid::Uuid;

use super::render::render;
use super::state::BotsState;
use super::{Action, FollowUp};
use crate::frontend::setup;
use crate::frontend::terminal::{INPUT_POLL, MAX_INPUT_BATCH, poll_event};

type BotsTerminal = Terminal<CrosstermBackend<io::Stdout>>;

pub(in crate::frontend) async fn run(
    terminal: &mut BotsTerminal,
    sender: &GatewaySender,
    events: &mut GatewayEvents,
    gateway: &mut ReadyPayload,
    preferred_bot_id: Option<&str>,
    protected_bot_id: Option<&str>,
) -> Result<()> {
    terminal.clear()?;
    let mut state = BotsState::new(gateway, preferred_bot_id, protected_bot_id);
    request_routines(sender, &mut state).await?;
    let mut tick = tokio::time::interval(INPUT_POLL);
    tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
    let mut deferred = Vec::new();
    let mut events_open = true;
    let mut dirty = true;

    let result = 'screen: loop {
        if dirty {
            terminal.draw(|frame| render(frame, &state, gateway))?;
            dirty = false;
        }
        tokio::select! {
            frame = events.next(), if events_open => {
                let frame = match frame {
                    Ok(frame) => frame,
                    Err(error) => break 'screen Err(gateway_error(error)),
                };
                match frame {
                    Some(frame) => {
                        let follow_up =
                            handle_frame(frame.message, gateway, &mut state, &mut deferred)?;
                        request_follow_up(sender, &mut state, follow_up).await?;
                    }
                    None => {
                        events_open = false;
                        state.fail("Gateway disconnected. Press q to close.");
                    }
                }
                state.clamp(gateway);
                dirty = true;
            }
            _ = tick.tick() => {
                for _ in 0..MAX_INPUT_BATCH {
                    let Some(event) = poll_event()? else { break; };
                    dirty = true;
                    let action = match event {
                        Event::Key(key) => state.handle_key(key, gateway),
                        Event::Paste(value) => {
                            state.paste(&value);
                            Action::None
                        }
                        Event::Resize(_, _)
                        | Event::FocusGained
                        | Event::FocusLost
                        | Event::Mouse(_) => Action::None,
                    };
                    match action {
                        Action::None => {}
                        Action::Exit => break 'screen Ok(()),
                        Action::Setup { bot_id, mode } => {
                            if let Err(error) = setup::run_bot(
                                terminal,
                                mode,
                                None,
                                sender,
                                events,
                                gateway,
                                &bot_id,
                            ).await {
                                state.fail(error.to_string());
                            }
                            terminal.clear()?;
                            state.clamp(gateway);
                        }
                        Action::Send {
                            request_id,
                            message,
                            label,
                            follow_up,
                        } => match sender.send(*message).await {
                            Ok(()) => state.begin(request_id, label, follow_up),
                            Err(error) => state.fail(error.to_string()),
                        },
                    }
                }
            }
        }
    };
    events.prepend(deferred).map_err(gateway_error)?;
    result
}

pub(super) fn handle_frame(
    message: ServerMessage,
    gateway: &mut ReadyPayload,
    state: &mut BotsState,
    deferred: &mut Vec<ServerFrame>,
) -> Result<FollowUp> {
    let mut follow_up = FollowUp::None;
    match message {
        ServerMessage::Ready { payload } => *gateway = payload,
        ServerMessage::GatewayConfigured {
            request_id,
            payload,
        } => {
            *gateway = payload.clone();
            defer(
                ServerMessage::GatewayConfigured {
                    request_id,
                    payload,
                },
                deferred,
            )?;
        }
        ServerMessage::Sessions {
            request_id,
            sessions,
        } => {
            gateway.sessions = sessions.clone();
            if request_id.is_some() {
                defer(
                    ServerMessage::Sessions {
                        request_id,
                        sessions,
                    },
                    deferred,
                )?;
            }
        }
        ServerMessage::Bots { request_id, bots } => {
            gateway.bots = bots.clone();
            if request_id
                .as_ref()
                .is_some_and(|id| pending_matches(state, id))
            {
                follow_up = state.complete().unwrap_or(FollowUp::None);
            } else if request_id.is_some() {
                defer(ServerMessage::Bots { request_id, bots }, deferred)?;
            }
        }
        ServerMessage::Swarms { request_id, swarms } => {
            gateway.swarms = swarms.clone();
            if request_id
                .as_ref()
                .is_some_and(|id| pending_matches(state, id))
            {
                follow_up = state.complete().unwrap_or(FollowUp::None);
            } else if request_id.is_some() {
                defer(ServerMessage::Swarms { request_id, swarms }, deferred)?;
            }
        }
        ServerMessage::Routines {
            request_id,
            routines,
        } if pending_matches(state, &request_id) => {
            state.routines = routines;
            follow_up = state.complete().unwrap_or(FollowUp::None);
        }
        ServerMessage::RoutineHistory { request_id, runs }
            if pending_matches(state, &request_id) =>
        {
            state.runs = runs;
            follow_up = state.complete().unwrap_or(FollowUp::None);
        }
        ServerMessage::RoutineRunPreview {
            request_id,
            preview,
        } if pending_matches(state, &request_id) => {
            state.preview = Some(preview);
            follow_up = state.complete().unwrap_or(FollowUp::None);
        }
        ServerMessage::Accepted { request_id } if pending_matches(state, &request_id) => {
            follow_up = state.complete().unwrap_or(FollowUp::None);
        }
        ServerMessage::Rejected {
            request_id,
            message,
            ..
        } if pending_matches(state, &request_id) => state.fail(message),
        message => defer(message, deferred)?,
    }
    Ok(follow_up)
}

fn defer(message: ServerMessage, deferred: &mut Vec<ServerFrame>) -> Result<()> {
    if deferred.len() == MAX_PENDING_FRAMES {
        return Err(Error::Stopped(format!(
            "gateway event backlog exceeds {MAX_PENDING_FRAMES} frames while managing Bots: {message:?}"
        )));
    }
    deferred.push(ServerFrame::new(message));
    Ok(())
}

fn pending_matches(state: &BotsState, request_id: &str) -> bool {
    state
        .pending
        .as_ref()
        .is_some_and(|pending| pending.request_id == request_id)
}

async fn request_routines(sender: &GatewaySender, state: &mut BotsState) -> Result<()> {
    let request_id = Uuid::new_v4().to_string();
    sender
        .send(ClientMessage::ListRoutines {
            request_id: request_id.clone(),
            bot_id: None,
        })
        .await
        .map_err(gateway_error)?;
    state.begin(request_id, "Load routines", FollowUp::None);
    Ok(())
}

async fn request_runs(
    sender: &GatewaySender,
    state: &mut BotsState,
    routine_id: String,
) -> Result<()> {
    let request_id = Uuid::new_v4().to_string();
    sender
        .send(ClientMessage::ListRoutineHistory {
            request_id: request_id.clone(),
            id: Some(routine_id),
        })
        .await
        .map_err(gateway_error)?;
    state.begin(request_id, "Load run history", FollowUp::None);
    Ok(())
}

async fn request_follow_up(
    sender: &GatewaySender,
    state: &mut BotsState,
    follow_up: FollowUp,
) -> Result<()> {
    match follow_up {
        FollowUp::None => Ok(()),
        FollowUp::Routines => request_routines(sender, state).await,
        FollowUp::Runs(routine_id) => request_runs(sender, state, routine_id).await,
    }
}

fn gateway_error(error: impl std::fmt::Display) -> Error {
    Error::Stopped(error.to_string())
}