mobius-cli 0.9.4

The terminal client for a möbius gateway
Documentation
use super::support::*;
use super::*;

#[test]
fn completed_diff_replaces_the_pending_block_with_a_styled_diff() {
    let mut state = state();
    state.transcript.clear();
    state.apply_block(rendered(FrontendBlock {
        id: Some("turn/patch".into()),
        group: None,
        update: FrontendBlockUpdate::Replace,
        state: FrontendBlockState::Pending,
        role: FrontendBlockRole::Tool,
        title: "Edit note.rs".into(),
        text: String::new(),
        symbol: None,
        format: FrontendBlockFormat::PlainText,
        tone: FrontendTone::Neutral,
        files: Vec::new(),
    }));
    view::live_transcript_lines(&mut state, 0, 80);
    assert_eq!(
        state
            .transcript
            .front()
            .and_then(|entry| entry.rendered.as_ref())
            .map(|(width, _)| *width),
        Some(80)
    );
    state.apply_block(rendered(FrontendBlock {
        id: Some("turn/patch".into()),
        group: None,
        update: FrontendBlockUpdate::Replace,
        state: FrontendBlockState::Complete,
        role: FrontendBlockRole::Tool,
        title: "Edit note.rs".into(),
        text: "--- note.rs\n+++ note.rs\n@@ -1,5 +1,5 @@\n-fn old_name() {}\n+fn new_name() {}\n keep_one();\n-let removed = false;\n keep_two();\n+let added = true;\n keep_three();\n".into(),
        symbol: None,
        format: FrontendBlockFormat::UnifiedDiff,
        tone: FrontendTone::Success,
        files: Vec::new(),
    }));
    assert!(
        state
            .transcript
            .front()
            .is_some_and(|entry| entry.rendered.is_none())
    );

    assert_eq!(
        state.transcript.front().map(|entry| entry.format),
        Some(FrontendBlockFormat::UnifiedDiff)
    );
    let lines = view::live_transcript_lines(&mut state, 0, 80);
    let text = rendered_text(&lines);
    assert!(text.contains("◉ Edited note.rs (+2 -2)"), "{text}");
    assert!(text.contains("    1 -fn old_name() {}"), "{text}");
    assert!(text.contains("    1 +fn new_name() {}"), "{text}");
    assert!(!text.contains("◉ Edit note.rs"), "{text}");

    let changed_delete = lines
        .iter()
        .find(|line| rendered_text(std::slice::from_ref(line)).contains("-fn old_name"))
        .expect("changed delete");
    let changed_insert = lines
        .iter()
        .find(|line| rendered_text(std::slice::from_ref(line)).contains("+fn new_name"))
        .expect("changed insert");
    let pure_delete = lines
        .iter()
        .find(|line| rendered_text(std::slice::from_ref(line)).contains("-let removed"))
        .expect("pure delete");
    let pure_insert = lines
        .iter()
        .find(|line| rendered_text(std::slice::from_ref(line)).contains("+let added"))
        .expect("pure insert");

    assert_eq!(
        changed_delete.style.bg,
        Some(current().diff_delete_background())
    );
    assert_eq!(
        changed_insert.style.bg,
        Some(current().diff_add_background())
    );
    assert_eq!(
        pure_delete.style.bg,
        Some(current().diff_delete_background())
    );
    assert_eq!(pure_insert.style.bg, Some(current().diff_add_background()));
    assert!(
        [&changed_delete, &changed_insert, &pure_delete, &pure_insert]
            .into_iter()
            .all(|line| line.width() == 80)
    );
    assert!(lines.iter().flat_map(|line| &line.spans).any(|span| {
        span.content == "fn" && span.style.fg != Some(current().color(Role::Text))
    }));

    let narrow_lines = view::live_transcript_lines(&mut state, 0, 40);
    let narrow_insert = narrow_lines
        .iter()
        .find(|line| rendered_text(std::slice::from_ref(line)).contains("+fn new_name"))
        .expect("narrow insert");
    assert_eq!(
        (
            state
                .transcript
                .front()
                .and_then(|entry| entry.rendered.as_ref())
                .map(|(width, _)| *width),
            narrow_insert.width(),
        ),
        (Some(40), 40)
    );
}

#[test]
fn capability_header_is_live_styled_and_transparent() {
    let catalog = default_catalog();
    let mut state = state();
    state.transcript.clear();
    state.widgets.push((
        ("extensions".into(), "count".into()),
        FrontendWidget {
            id: "count".into(),
            slot: FrontendSlot::Header,
            text: "extensions 2".into(),
            tone: FrontendTone::Neutral,
            symbol: None,
            icon_only: false,
            progress: None,
            content: None,
            action: None,
        },
    ));
    let mut terminal = Terminal::new(TestBackend::new(50, 15)).expect("terminal");
    terminal
        .draw(|frame| view::render(frame, &mut state, &catalog))
        .expect("live pane draw");
    let live_pane = terminal.backend().to_string();
    let extension_cell = terminal
        .backend()
        .buffer()
        .content()
        .iter()
        .find(|cell| cell.symbol() == "2")
        .expect("styled capability cell");

    assert!(live_pane.contains("extensions 2"));
    assert_eq!(
        (extension_cell.fg, extension_cell.bg),
        (current().color(Role::Neutral), Color::Reset)
    );
}

#[test]
fn transcript_tail_widgets_render_after_live_output_in_arrival_order() {
    let mut state = state();
    state.transcript.clear();
    state.streaming = "working".into();
    let widget = |id: &str, text: &str| {
        EventMsg::Frontend(FrontendEvent::Widget {
            capability: "steering".into(),
            item: FrontendWidget {
                id: id.into(),
                slot: FrontendSlot::TranscriptTail,
                text: text.into(),
                tone: FrontendTone::Neutral,
                symbol: None,
                icon_only: false,
                progress: None,
                content: None,
                action: None,
            },
        })
    };
    state.handle_agent_event(widget("z-older", "older"), Vec::new());
    state.handle_agent_event(widget("a-newer", "newer"), Vec::new());

    let lines = view::live_transcript_lines(&mut state, 0, 80);

    assert_eq!(
        rendered_text(&lines),
        "◉ working\n\n┊ Steering message\n┊ older\n\n┊ Steering message\n┊ newer"
    );

    state.handle_agent_event(
        EventMsg::Frontend(FrontendEvent::RemoveWidget {
            capability: "steering".into(),
            id: "z-older".into(),
        }),
        Vec::new(),
    );
    let lines = view::live_transcript_lines(&mut state, 0, 80);
    assert_eq!(
        rendered_text(&lines),
        "◉ working\n\n┊ Steering message\n┊ newer"
    );
}

#[test]
fn sora_transcript_stays_styled_and_transparent_in_chat_and_preview() {
    let catalog = default_catalog();
    let mut state = state();
    state.transcript.clear();
    state.push_entry("λ".into(), TranscriptTone::Warning);
    let mut terminal = Terminal::new(TestBackend::new(40, 16)).expect("terminal");

    terminal
        .draw(|frame| view::render(frame, &mut state, &catalog))
        .expect("chat draw");
    let chat_cell = terminal
        .backend()
        .buffer()
        .content()
        .iter()
        .find(|cell| cell.symbol() == "λ")
        .expect("styled chat cell");
    assert_eq!(
        (chat_cell.fg, chat_cell.bg),
        (current().color(Role::Warning), Color::Reset)
    );

    state.open_transcript_preview();
    terminal
        .draw(|frame| view::render_preview(frame, &mut state))
        .expect("preview draw");
    let preview = terminal.backend().buffer();
    let preview_cell = preview
        .content()
        .iter()
        .find(|cell| cell.symbol() == "λ")
        .expect("styled preview cell");

    assert_eq!(
        (preview_cell.fg, preview_cell.bg),
        (current().color(Role::Warning), Color::Reset)
    );
    assert!(preview.content().iter().all(|cell| cell.bg == Color::Reset));
}

#[test]
fn empty_chat_shows_the_agent_card_without_polluting_the_transcript() {
    let mut state = state();
    let card = view::welcome_card(&state);

    assert!(state.transcript.is_empty());
    assert!(card.contains("⣠⡤⢶"));
    assert!(card.contains("MÖBIUS AGENT"));
    assert!(card.contains("model: kimi-k3 · high"));

    state.push("hello", TranscriptTone::User);
    let rendered = rendered_text(&view::live_transcript_lines(&mut state, 0, 80));
    assert!(!rendered.contains("MÖBIUS AGENT"));
}

#[test]
fn narrow_terminal_keeps_session_card_and_compact_footer() {
    let catalog = default_catalog();
    let mut state = state();
    state.cwd = "/work/mobius".into();
    let mut terminal = Terminal::new(TestBackend::new(35, 15)).expect("terminal");

    terminal
        .draw(|frame| view::render(frame, &mut state, &catalog))
        .expect("draw");
    let rendered = terminal.backend().to_string();

    assert!(rendered.contains("MÖBIUS AGENT"), "{rendered}");
    assert!(rendered.contains("⣠⡤⢶"), "{rendered}");
    assert!(rendered.contains("model: kimi-k3 · high"), "{rendered}");
    assert!(rendered.contains("kimi-k3 high"), "{rendered}");
    assert!(rendered.contains(""), "{rendered}");
}

#[test]
fn block_identity_is_scoped_by_explicit_capability() {
    let block = |title: &str| FrontendBlock {
        id: Some("same-id".into()),
        group: None,
        update: FrontendBlockUpdate::Replace,
        state: FrontendBlockState::Complete,
        role: FrontendBlockRole::Notice,
        title: title.into(),
        text: String::new(),
        symbol: None,
        files: Vec::new(),
        format: FrontendBlockFormat::PlainText,
        tone: FrontendTone::Neutral,
    };
    let mut state = state();
    state.apply_block(RenderedBlock {
        capability: "alpha".into(),
        block: block("Alpha"),
    });
    state.apply_block(RenderedBlock {
        capability: "beta".into(),
        block: block("Beta"),
    });
    state.apply_block(RenderedBlock {
        capability: "alpha".into(),
        block: block("Alpha updated"),
    });

    assert_eq!(
        state
            .transcript
            .iter()
            .map(|entry| entry.text.as_str())
            .collect::<Vec<_>>(),
        ["Alpha updated", "Beta"]
    );
}

#[test]
fn gateway_history_preserves_child_diff_rendering() {
    let mut state = state();
    let message = EventMsg::AgentMessage(mobius::protocol::AgentMessageEvent {
        session_id: "session".into(),
        turn_id: "turn".into(),
        model_step_id: "step".into(),
        message: "changed the file".into(),
        phase: AgentMessagePhase::FinalAnswer,
        message_target: None,
    });
    events::handle_gateway_history(
        &mut state,
        vec![recorded(
            message,
            vec![rendered(FrontendBlock {
                id: None,
                group: None,
                update: FrontendBlockUpdate::Replace,
                state: FrontendBlockState::Complete,
                role: FrontendBlockRole::Artifact,
                title: String::new(),
                text: "--- a/file\n+++ b/file\n-old\n+new".into(),
                symbol: None,
                format: FrontendBlockFormat::UnifiedDiff,
                tone: FrontendTone::Neutral,
                files: Vec::new(),
            })],
            None,
        )],
    );

    let entry = state.transcript.back().expect("rendered history entry");
    assert_eq!(entry.format, FrontendBlockFormat::UnifiedDiff);
    assert_eq!(entry.text, "--- a/file\n+++ b/file\n-old\n+new");
}

#[test]
fn session_file_block_renders_download_metadata_as_plain_text() {
    let mut state = state();
    state.transcript.clear();
    state.apply_block(rendered(FrontendBlock {
        id: Some("artifacts/turn/file".into()),
        group: None,
        update: FrontendBlockUpdate::Replace,
        state: FrontendBlockState::Complete,
        role: FrontendBlockRole::Artifact,
        title: "Sent report.xlsx".into(),
        text: String::new(),
        symbol: None,
        format: FrontendBlockFormat::PlainText,
        tone: FrontendTone::Success,
        files: vec![mobius::protocol::SessionFileReference {
            id: "file-a".into(),
            name: "report.xlsx".into(),
            size: 42,
            media_type: "application/octet-stream".into(),
        }],
    }));

    assert_eq!(
        state.transcript.front().map(|entry| entry.text.as_str()),
        Some("Sent report.xlsx\n[file] report.xlsx · application/octet-stream · 42 bytes")
    );
}

#[test]
fn transcript_text_strips_terminal_control_characters() {
    let mut state = state();
    state.push("unsafe \u{1b}[31mred\u{1b}[0m", TranscriptTone::Warning);

    assert_eq!(
        state.transcript.back().expect("entry").text,
        "unsafe [31mred[0m"
    );
}