mobius 0.9.25

A small, modular Rust framework for building coding agents
Documentation
use std::collections::BTreeSet;

use super::{Basis, Entry, MANIFEST, Scope, ScratchpadStore, Snapshot, WriteOutcome, text};
use crate::Result;
use crate::middleware::{FrontendEventSink, MiddlewareCommandOutput};
use crate::protocol::{
    FrontendAction, FrontendActionListItem, FrontendEvent, FrontendListItemState, FrontendSlot,
    FrontendSymbol, FrontendTone, FrontendWidget, FrontendWidgetContent, Op,
};

pub(super) fn surface_widgets(snapshot: &Snapshot) -> Vec<FrontendWidget> {
    let global_notes = snapshot
        .global
        .iter()
        .map(|entry| entry.note.as_str())
        .collect::<BTreeSet<_>>();
    vec![
        global_widget(&snapshot.global),
        frontend_widget(
            "chat_menu",
            FrontendSlot::ChatMenu,
            text::WIDGET_TEXT,
            action_list_content(
                text::WIDGET_SESSION_TITLE,
                Scope::Session,
                &snapshot.session,
                Some(&global_notes),
            ),
        ),
    ]
}

pub(super) fn global_widget(entries: &[Entry]) -> FrontendWidget {
    frontend_widget(
        "navigation",
        FrontendSlot::Navigation,
        text::WIDGET_TEXT,
        action_list_content(text::WIDGET_GLOBAL_TITLE, Scope::Global, entries, None),
    )
}

fn frontend_widget(
    id: &str,
    slot: FrontendSlot,
    text: &str,
    content: FrontendWidgetContent,
) -> FrontendWidget {
    FrontendWidget {
        id: id.into(),
        slot,
        text: text.into(),
        tone: FrontendTone::Neutral,
        symbol: Some(FrontendSymbol::Brain),
        icon_only: false,
        progress: None,
        content: Some(content),
        action: Some(Op::CapabilityCommand {
            capability: MANIFEST.id.into(),
            command: "scratchpad".into(),
            arguments: "refresh".into(),
            input: None,
            target: None,
        }),
    }
}

fn action_list_content(
    title: &str,
    scope: Scope,
    entries: &[Entry],
    global_notes: Option<&BTreeSet<&str>>,
) -> FrontendWidgetContent {
    FrontendWidgetContent::ActionList {
        title: title.into(),
        items: entries
            .iter()
            .rev()
            .map(|entry| {
                action_list_item(
                    scope,
                    entry,
                    global_notes.is_some_and(|notes| notes.contains(entry.note.as_str())),
                )
            })
            .collect(),
    }
}

pub(super) fn action_list_item(
    scope: Scope,
    entry: &Entry,
    already_global: bool,
) -> FrontendActionListItem {
    let scope_name = scope_name(scope);
    let mut actions = Vec::with_capacity(if scope == Scope::Session { 3 } else { 2 });
    if scope == Scope::Session && !already_global {
        actions.push(list_action(
            entry,
            FrontendSymbol::Promote,
            text::ACTION_PROMOTE,
            FrontendTone::Neutral,
            format!("promote {}", entry.id),
            None,
        ));
    }
    actions.push(list_action(
        entry,
        FrontendSymbol::Edit,
        text::ACTION_EDIT,
        FrontendTone::Neutral,
        format!("edit {scope_name} {}", entry.id),
        Some(&entry.note),
    ));
    actions.push(list_action(
        entry,
        FrontendSymbol::Delete,
        text::ACTION_DELETE,
        FrontendTone::Error,
        format!("forget {scope_name} {}", entry.id),
        None,
    ));
    FrontendActionListItem {
        id: entry.id.clone(),
        text: entry.note.clone(),
        state: FrontendListItemState::Plain,
        actions,
    }
}

fn list_action(
    entry: &Entry,
    symbol: FrontendSymbol,
    label: &str,
    tone: FrontendTone,
    arguments: String,
    input: Option<&str>,
) -> FrontendAction {
    FrontendAction {
        id: format!("{}:{}", symbol.as_str(), entry.id),
        label: label.into(),
        symbol,
        tone,
        op: Op::CapabilityCommand {
            capability: MANIFEST.id.into(),
            command: "scratchpad".into(),
            arguments,
            input: input.map(str::to_owned),
            target: None,
        },
    }
}

pub(super) fn widget_events(snapshot: &Snapshot) -> Vec<FrontendEvent> {
    surface_widgets(snapshot)
        .into_iter()
        .map(|item| FrontendEvent::Widget {
            capability: MANIFEST.id.into(),
            item,
        })
        .collect()
}

pub(super) fn publish_widgets(frontend: &FrontendEventSink, snapshot: &Snapshot) -> Result<()> {
    for event in widget_events(snapshot) {
        frontend(event)?;
    }
    Ok(())
}

pub(super) async fn publish_current_widgets(
    store: &ScratchpadStore,
    session_id: &str,
    frontend: &FrontendEventSink,
) -> Result<()> {
    let snapshot = store.snapshot(session_id).await?;
    publish_widgets(frontend, &snapshot)
}

pub(super) fn parse_scope(scope: &str) -> Option<Scope> {
    match scope {
        "session" => Some(Scope::Session),
        "global" => Some(Scope::Global),
        _ => None,
    }
}

const fn scope_name(scope: Scope) -> &'static str {
    match scope {
        Scope::Session => "session",
        Scope::Global => "global",
    }
}

pub(super) fn usage() -> MiddlewareCommandOutput {
    MiddlewareCommandOutput::render(MANIFEST.id, text::COMMAND_USAGE, FrontendTone::Warning)
}

pub(super) fn command_confirmation(
    action: &str,
    outcome: WriteOutcome,
    snapshot: &Snapshot,
) -> MiddlewareCommandOutput {
    let text = match outcome {
        WriteOutcome::Added => format!("Successfully {action} the scratchpad note."),
        WriteOutcome::Updated => text::MESSAGE_UPDATED_PROVENANCE.into(),
        WriteOutcome::Existing => text::MESSAGE_GLOBAL_EXISTING.into(),
    };
    let mut events = widget_events(snapshot);
    events.extend(MiddlewareCommandOutput::render(MANIFEST.id, text, FrontendTone::Success).events);
    MiddlewareCommandOutput::events(events)
}

pub(super) fn format_snapshot(snapshot: &Snapshot) -> String {
    format!(
        "{}\n{}\n\n{}\n{}",
        text::MESSAGE_SESSION_HEADING,
        format_entries(&snapshot.session),
        text::MESSAGE_GLOBAL_HEADING,
        format_entries(&snapshot.global)
    )
}

fn format_entries(entries: &[Entry]) -> String {
    if entries.is_empty() {
        return text::MESSAGE_NO_NOTES.into();
    }
    entries
        .iter()
        .map(|entry| format!("[{}] {}\n  {}", entry.id, entry.note, entry_metadata(entry)))
        .collect::<Vec<_>>()
        .join("\n")
}

fn entry_metadata(entry: &Entry) -> String {
    format!(
        "{} ยท created at Unix time {}",
        basis_label(&entry.basis),
        entry.created_at
    )
}

fn basis_label(basis: &Basis) -> &'static str {
    match basis {
        Basis::AgentObservation => text::MESSAGE_AGENT_OBSERVATION,
        Basis::UserConfirmed => text::MESSAGE_USER_CONFIRMED,
    }
}