bcode_tui 0.0.1-alpha.0

Terminal user interface for bcode
//! Composer submission flow for the TUI.

use super::effects::{SubmitMessageRequest, TuiEffect};
use super::session_flow::ActiveChat;
use super::slash_registry;

fn defer_submission_while_session_opens(chat: &mut ActiveChat) -> bool {
    if chat.opening_session_id().is_none() {
        return false;
    }
    chat.app
        .set_status("Session is still opening; message kept in composer".to_owned());
    true
}

pub enum RootSubmission {
    MessageStaged(bool),
    SlashCommand(String),
}

pub fn stage_root_submission(
    launch_working_directory: &std::path::Path,
    launch_options: super::TuiLaunchOptions,
    chat: &mut ActiveChat,
    placement: bcode_ipc::PromptPlacement,
) -> RootSubmission {
    if chat.opening_session_id().is_some() {
        chat.app
            .set_status("Session is still opening; message kept in composer".to_owned());
        return RootSubmission::MessageStaged(false);
    }
    let message = chat.app.take_pending_submission();
    if slash_registry::slash_command_name(&message).is_some() {
        chat.app.clear_pending_submission(&message);
        return RootSubmission::SlashCommand(message);
    }
    chat.app.restore_pending_submission(&message);
    chat.app.stage_submission();
    RootSubmission::MessageStaged(stage_session_message(
        launch_working_directory,
        launch_options,
        chat,
        placement,
    ))
}

pub fn stage_session_message(
    launch_working_directory: &std::path::Path,
    launch_options: super::TuiLaunchOptions,
    chat: &mut ActiveChat,
    placement: bcode_ipc::PromptPlacement,
) -> bool {
    if defer_submission_while_session_opens(chat) {
        return false;
    }
    let session_id = chat.attached_session_id();
    let message = chat.app.take_pending_submission();
    if message.trim().is_empty() {
        chat.app.clear_pending_submission(&message);
        return false;
    }
    if slash_registry::slash_command_name(&message).is_some() {
        chat.app.clear_pending_submission(&message);
        chat.app
            .set_status("slash command pending root navigation migration".to_owned());
        return false;
    }
    let agent_id = Some(chat.app.display_agent_id().to_owned());
    let draft_provider_plugin_id = session_id
        .is_none()
        .then(|| {
            chat.app
                .selected_provider_plugin_id()
                .map(ToOwned::to_owned)
        })
        .flatten();
    let draft_model_id = session_id
        .is_none()
        .then(|| chat.app.selected_model_id().map(ToOwned::to_owned))
        .flatten();
    chat.start_effect(TuiEffect::SubmitMessage {
        request: Box::new(SubmitMessageRequest {
            submission_id: chat
                .app
                .pending_submissions()
                .last()
                .expect("staged submission")
                .id()
                .clone(),
            session_id,
            launch_working_directory: launch_working_directory.to_path_buf(),
            message,
            placement,
            provider_plugin_id: draft_provider_plugin_id,
            model_id: draft_model_id,
            agent_id,
            reasoning_effort: chat.app.reasoning_effort().map(ToOwned::to_owned),
            reasoning_summary: chat.app.reasoning_summary().map(ToOwned::to_owned),
            reasoning_effort_generation: chat.app.pending_reasoning_effort_generation(),
            execution: launch_options.turn_execution_options(),
            event_sender: chat.event_sender.clone(),
        }),
    });
    chat.app.set_status("sending message…".to_owned());
    true
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn submission_preserves_observed_connection_for_new_and_existing_sessions() {
        use super::super::app::{BmuxApp, DaemonConnectionState};
        use super::super::session_flow::{ActiveChat, AgentCatalog, ChatSessionAttachment};
        for attached in [false, true] {
            for connection in [
                DaemonConnectionState::Connected,
                DaemonConnectionState::Connecting,
                DaemonConnectionState::IdleOffline,
                DaemonConnectionState::Unavailable,
            ] {
                let (event_sender, event_receiver) = crate::history_flow::session_stream_channel();
                let session_id = attached.then(bcode_session_models::SessionId::new);
                let mut chat = ActiveChat {
                    app: BmuxApp::new_with_history(session_id, &[], &[], false),
                    agents: AgentCatalog::default(),
                    attachment: session_id.map_or(ChatSessionAttachment::Draft, |session_id| {
                        ChatSessionAttachment::Attached { session_id }
                    }),
                    event_sender,
                    event_receiver,
                    event_task: None,
                    opening_session_progress: None,
                    pending_effects: super::super::effects::TuiEffectQueue::default(),
                };
                chat.app.set_daemon_connection(connection);
                chat.app.replace_composer_with("fresh message");
                chat.app.stage_submission();
                assert!(stage_session_message(
                    std::path::Path::new("."),
                    crate::TuiLaunchOptions::default(),
                    &mut chat,
                    bcode_ipc::PromptPlacement::FollowUp
                ));
                assert_eq!(chat.app.daemon_connection(), connection);
                assert_eq!(chat.app.status(), "sending message…");
                assert_eq!(chat.pending_effects.queued_agent_id(), Some("build"));
                assert!(chat.pending_effects.queued_execution_options().is_some());
                drop(chat);
            }
        }
    }

    #[test]
    fn opening_session_defers_message_without_consuming_composer_text() {
        let (event_sender, event_receiver) = crate::history_flow::session_stream_channel();
        let session_id = bcode_session_models::SessionId::new();
        let mut chat = super::super::session_flow::ActiveChat {
            app: super::super::app::BmuxApp::new_with_history(Some(session_id), &[], &[], false),
            agents: super::super::session_flow::AgentCatalog::default(),
            attachment: super::super::session_flow::ChatSessionAttachment::Opening {
                session_id,
                anchor_sequence: None,
            },
            event_sender,
            event_receiver,
            event_task: None,
            opening_session_progress: None,
            pending_effects: super::super::effects::TuiEffectQueue::default(),
        };
        chat.app.replace_composer_with("message after migration");
        chat.app.stage_submission();

        assert!(defer_submission_while_session_opens(&mut chat));
        assert_eq!(
            chat.app.take_pending_submission(),
            "message after migration"
        );
        assert!(chat.app.status().contains("still opening"));
    }

    #[test]
    fn active_plugin_status_does_not_block_ordinary_composer_staging() {
        let mut app = super::super::app::BmuxApp::new_with_history(None, &[], &[], false);
        app.set_plugin_status(vec![bcode_session_view_models::PluginStatusView {
            plugin_id: "bcode.loop".to_owned(),
            note_id: "loop-active".to_owned(),
            text: "Loop active".to_owned(),
            priority: 1,
            metadata: std::collections::BTreeMap::new(),
        }]);
        app.paste_composer_text("manual steering");
        app.stage_submission();

        assert_eq!(app.take_pending_submission(), "manual steering");
        let status = app.plugin_status().next().map(|status| status.text.clone());
        drop(app);
        assert_eq!(status.as_deref(), Some("Loop active"));
    }
}