orchestral-cli 0.4.0

A runtime for reliable, interactive AI agents.
Documentation
//! Render the real projection and exercise its controls at constrained sizes.
use ratatui::backend::TestBackend;
use ratatui::style::Color;
use ratatui::Terminal;

use super::render;
use super::tests::render_to_string;
use crate::tui::menu::{Choice, Menu, MenuKind};
use crate::tui::{update, ApprovalChoice, UiEffect, UiMsg, UiPhase, UiState};

#[test]
fn welcome_identifies_the_running_build_and_disappears_into_the_conversation() {
    let mut state = UiState::new("session", "local-model");
    state.workspace_path = "/work/project".into();
    let text = render_to_string(&state, 100, 28);
    for label in [
        "ORCHESTRAL",
        env!("CARGO_PKG_VERSION"),
        "A runtime for reliable, interactive AI agents.",
        "local-model",
        "/work/project",
        "Type a task below.",
    ] {
        assert!(text.contains(label), "missing {label}: {text}");
    }
    update(&mut state, UiMsg::InsertText("Inspect the project".into()));
    update(&mut state, UiMsg::Submit);
    update(
        &mut state,
        UiMsg::RunStarted {
            run_id: "run".into(),
        },
    );
    let text = render_to_string(&state, 100, 28);
    assert!(text.contains("Inspect the project"), "{text}");
    assert!(!text.contains("Type a task below."), "{text}");
}

#[test]
fn footer_distinguishes_last_input_estimates_and_unknown_capacity() {
    let mut state = UiState::new("session", "model");
    state.context_input_tokens = Some((9000, true));
    let text = render_to_string(&state, 140, 24);
    assert!(
        text.contains("last input: ≈9000 · limit: unknown"),
        "{text}"
    );
    state.context_input_tokens = Some((8194, false));
    state.context_budget = Some(12288);
    let text = render_to_string(&state, 140, 24);
    assert!(text.contains("last input: 8194 · limit: 12288"), "{text}");
    update(
        &mut state,
        UiMsg::RunStarted {
            run_id: "run".into(),
        },
    );
    state.model = "a-long-provider/model-name".repeat(8);
    state.context_input_tokens = Some((8194, false));
    for width in [64, 80, 100] {
        let text = render_to_string(&state, width, 24);
        assert!(text.contains("last input: 8194 · limit: 12288"), "{text}");
        assert!(text.lines().last().unwrap().contains("stop"), "{text}");
    }
}

#[test]
fn model_or_tool_progress_replaces_a_stale_recovery_notice() {
    let mut state = UiState::new("session", "model");
    update(
        &mut state,
        UiMsg::RunStarted {
            run_id: "run".into(),
        },
    );
    update(
        &mut state,
        UiMsg::ProgressReported {
            summary: "Retrying after context rejection".into(),
        },
    );
    update(
        &mut state,
        UiMsg::StreamDelta {
            delta_id: "delta".into(),
            output_id: "output".into(),
            order: 1,
            text: "Hello".into(),
        },
    );
    assert!(state.working_detail.is_none());
    state.ui_notice = Some("queued input accepted".into());
    update(
        &mut state,
        UiMsg::ProgressReported {
            summary: "Retrying after context rejection".into(),
        },
    );
    update(
        &mut state,
        UiMsg::ToolActivity {
            activity_id: "read".into(),
            tool_name: "file_read".into(),
            state: orchestral_core::agent_protocol::wire::ToolActivityState::Running,
            evidence: vec![],
        },
    );
    assert!(state.working_detail.is_none());
    assert!(state.ui_notice.is_none());
}

#[test]
fn minimum_terminal_approval_keeps_operation_and_each_choice_visible() {
    let mut state = UiState::new("session", "local-model");
    update(
        &mut state,
        UiMsg::RunStarted {
            run_id: "run".into(),
        },
    );
    update(
        &mut state,
        UiMsg::WaitingApproval {
            run_id: "run".into(),
            request_id: "approval".into(),
            summary: "Write src/lib.rs".into(),
            session_approval_available: true,
        },
    );
    update(&mut state, UiMsg::SelectApproval(ApprovalChoice::Deny));
    let text = render_to_string(&state, 20, 6);
    for visible in [
        "approval",
        "Write src/lib.rs",
        "a  Allow once",
        "s  Session",
        "› d  Deny",
    ] {
        assert!(text.contains(visible), "{text}");
    }
    let selected = state.approval_choice;
    assert!(
        matches!(update(&mut state, UiMsg::Approval(selected)).as_slice(),
        [UiEffect::ResolveApproval { request_id, choice: ApprovalChoice::Deny, .. }]
        if request_id == "approval")
    );
    assert_eq!(state.phase, UiPhase::WaitingApproval);
}

#[test]
fn minimum_terminal_keeps_question_answer_and_cursor_available() {
    let mut state = UiState::new("session", "local-model");
    update(
        &mut state,
        UiMsg::RunStarted {
            run_id: "run".into(),
        },
    );
    update(
        &mut state,
        UiMsg::WaitingInput {
            run_id: "run".into(),
            request_id: "question".into(),
            prompt: "Which package should receive the change?".into(),
        },
    );
    update(&mut state, UiMsg::InsertText("runtime".into()));

    let mut terminal = Terminal::new(TestBackend::new(20, 6)).unwrap();
    terminal
        .draw(|frame| {
            render(frame, &state);
        })
        .unwrap();
    let text = render_to_string(&state, 20, 6);
    assert!(text.contains("Which package"), "{text}");
    assert!(text.contains("› runtime"), "{text}");
    assert!(text.contains("enter · ^C stop"), "{text}");
    let cursor = terminal.backend().cursor_position();
    assert!(cursor.y > 0 && cursor.y < 5);
    assert_eq!(
        terminal.backend().buffer()[(cursor.x - 1, cursor.y)].symbol(),
        "e"
    );
    assert!(matches!(update(&mut state, UiMsg::Submit).as_slice(),
        [UiEffect::ResolveInput { request_id, value, .. }]
        if request_id == "question" && value == "runtime"));
    assert_eq!(
        state.phase,
        UiPhase::WaitingInput,
        "Rendering and submit do not invent a resolution"
    );
}

#[test]
fn minimum_terminal_menu_keeps_selected_choice_visible_and_actionable() {
    let mut state = UiState::new("session", "local-model");
    update(
        &mut state,
        UiMsg::RunStarted {
            run_id: "run".into(),
        },
    );
    update(
        &mut state,
        UiMsg::OpenMenu(Menu::new(
            MenuKind::Models,
            "Choose a model",
            vec![
                Choice::new("Model A", "model-a", "First local model"),
                Choice::new("Model B", "model-b", "Second local model"),
            ],
        )),
    );
    update(&mut state, UiMsg::SelectCandidate { up: false });
    let text = render_to_string(&state, 20, 6);
    assert!(text.contains("› Model B"), "{text}");
    assert!(text.lines().next().unwrap().contains("running"), "{text}");
    assert!(text.contains("↑↓ enter · esc"), "{text}");
    assert!(matches!(update(&mut state, UiMsg::Submit).as_slice(),
        [UiEffect::MenuChoice { kind: MenuKind::Models, value }] if value == "model-b"));
}

#[test]
fn narrow_header_preserves_complete_mark_and_phase() {
    for (phase, label) in [
        (UiPhase::Idle, "ready"),
        (UiPhase::Running, "running"),
        (UiPhase::WaitingInput, "input"),
        (UiPhase::WaitingApproval, "approval"),
        (UiPhase::Cancelling, "stopping"),
        (UiPhase::Completed, "replied"),
        (UiPhase::Incomplete, "incomplete"),
        (UiPhase::Failed, "failed"),
        (UiPhase::Cancelled, "cancelled"),
    ] {
        let mut state = UiState::new("session", "model");
        state.phase = phase;
        let text = render_to_string(&state, 20, 6);
        let header = text.lines().next().unwrap();
        assert!(header.contains("_>."), "{header}");
        assert!(header.contains(label), "{header}");
    }
}

#[test]
fn long_model_metadata_does_not_hide_steer_or_cancel_controls() {
    let mut state = UiState::new("session", "provider/".repeat(30));
    update(
        &mut state,
        UiMsg::RunStarted {
            run_id: "run".into(),
        },
    );
    update(
        &mut state,
        UiMsg::StreamDelta {
            delta_id: "delta".into(),
            output_id: "answer".into(),
            order: 1,
            text: "Latest answer".into(),
        },
    );
    for width in [20, 40, 64, 100] {
        let text = render_to_string(&state, width, 12);
        let footer = text.lines().last().unwrap();
        assert!(footer.contains("enter"), "{footer}");
        assert!(footer.contains("stop"), "{footer}");
        assert!(text.contains("Latest answer"), "{text}");
    }
    assert!(matches!(update(&mut state, UiMsg::Cancel).as_slice(),
        [UiEffect::CancelRun { run_id }] if run_id == "run"));
    assert_eq!(state.phase, UiPhase::Cancelling);
    let text = render_to_string(&state, 20, 6);
    assert!(text.contains("Stopping"), "{text}");
    assert!(text.contains("stopping…"), "{text}");
    assert!(update(&mut state, UiMsg::Submit).is_empty());
}

#[test]
fn brand_palette_keeps_conversation_clean_and_panel_boundaries_visible() {
    let mut state = UiState::new("session", "local-model");
    update(&mut state, UiMsg::InsertText("Hello".into()));
    let mut terminal = Terminal::new(TestBackend::new(60, 16)).unwrap();
    terminal
        .draw(|frame| {
            render(frame, &state);
        })
        .unwrap();
    let buffer = terminal.backend().buffer();
    assert_eq!(buffer[(2, 0)].symbol(), "_");
    assert_eq!(buffer[(2, 0)].bg, Color::Rgb(131, 255, 107));
    assert_eq!(buffer[(2, 1)].fg, Color::Rgb(131, 255, 107));
    assert_eq!(buffer[(2, 1)].bg, Color::Rgb(29, 32, 39));
    assert!(buffer
        .content
        .iter()
        .any(|cell| cell.symbol() == "" && cell.fg == Color::Rgb(85, 91, 107)));
    let cursor = terminal.backend().cursor_position();
    assert_eq!(buffer[(cursor.x - 1, cursor.y)].bg, Color::Rgb(39, 43, 53));
}

#[test]
fn terminal_and_no_color_modes_preserve_readable_user_colors() {
    let mut state = UiState::new("session", "local-model");
    state.theme = "terminal".into();
    let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();
    terminal
        .draw(|frame| {
            render(frame, &state);
        })
        .unwrap();
    let buffer = terminal.backend().buffer();
    assert_eq!(buffer[(39, 0)].bg, Color::Reset);
    assert_eq!(buffer[(39, 11)].bg, Color::Reset);
    assert!(!buffer.content.iter().any(|cell| cell.fg == Color::DarkGray));

    state.color_enabled = false;
    terminal
        .draw(|frame| {
            render(frame, &state);
        })
        .unwrap();
    assert!(terminal
        .backend()
        .buffer()
        .content
        .iter()
        .all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset));
    assert!(render_to_string(&state, 40, 12).contains("_>."));

    state.color_enabled = true;
    state.theme = "light".into();
    terminal
        .draw(|frame| {
            render(frame, &state);
        })
        .unwrap();
    let mark = &terminal.backend().buffer()[(2, 0)];
    assert_eq!(mark.fg, Color::White);
    assert_eq!(mark.bg, Color::Rgb(36, 124, 72));
}