vtcode 0.140.2

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use anyhow::{Context, Result};
use hashbrown::HashMap;
use serde_json::{Value, json};
use std::sync::Arc;
use tokio::sync::Notify;
use vtcode_ui::tui::app::{
    InlineHandle, InlineListItem, InlineListSelection, InlineMessageKind, InlineSegment, InlineSession,
    InlineTextStyle, WizardStep,
};

use super::super::state::CtrlCState;
use super::super::wizard_modal::{WizardModalOutcome, show_wizard_modal_and_wait};
use super::options::{ensure_recommended_first, resolve_question_options};
use super::schema::{
    NormalizedRequestUserInput, RequestUserInputAnswer, RequestUserInputOption, RequestUserInputQuestion,
    RequestUserInputResponse, normalize_request_user_input_args,
};
#[cfg(test)]
use super::suggestions::generate_suggested_options;

#[cfg_attr(feature = "profiling", hotpath::measure)]
pub(crate) async fn execute_request_user_input_tool(
    handle: &InlineHandle,
    session: &mut InlineSession,
    args: &Value,
    ctrl_c_state: &Arc<CtrlCState>,
    ctrl_c_notify: &Arc<Notify>,
) -> Result<Value> {
    let NormalizedRequestUserInput {
        args: parsed,
        wizard_mode,
        current_step,
        title_override,
        allow_freeform,
        freeform_label,
        freeform_placeholder,
    } = normalize_request_user_input_args(args).context("Invalid request_user_input arguments")?;

    if parsed.questions.is_empty() {
        return Ok(json!({
            "cancelled": true,
            "error": "No questions provided"
        }));
    }

    let resolved_options = resolve_question_options(&parsed.questions);
    let steps: Vec<WizardStep> = parsed
        .questions
        .iter()
        .zip(resolved_options)
        .map(|(q, options)| {
            let items = build_question_items_with_options(q, options);

            WizardStep {
                title: q.header.clone(),
                question: q.question.clone(),
                items,
                completed: false,
                answer: None,
                allow_freeform,
                freeform_label: freeform_label.clone(),
                freeform_placeholder: freeform_placeholder.clone(),
                freeform_default: None,
            }
        })
        .collect();

    let title = title_override.unwrap_or_else(|| {
        if steps.len() == 1 {
            steps[0].title.clone()
        } else {
            "Questions".to_string()
        }
    });

    let safe_current_step = current_step.min(steps.len().saturating_sub(1));
    match show_wizard_modal_and_wait(
        handle,
        session,
        title,
        steps,
        safe_current_step,
        None,
        wizard_mode,
        ctrl_c_state,
        ctrl_c_notify,
    )
    .await?
    {
        WizardModalOutcome::Submitted(selections) => {
            let mut answers: HashMap<String, RequestUserInputAnswer> = HashMap::new();

            for selection in selections {
                if let InlineListSelection::RequestUserInputAnswer { question_id, selected, other } = selection {
                    answers.insert(question_id, RequestUserInputAnswer { selected, other });
                }
            }

            append_summary_lines(handle, &parsed.questions, &answers, wizard_mode);

            let response = RequestUserInputResponse { answers };
            serde_json::to_value(response).map_err(|e| anyhow::anyhow!("Failed to serialize response: {e}"))
        }
        WizardModalOutcome::Cancelled { signal } => {
            if let Some(signal) = signal {
                Ok(json!({"cancelled": true, "signal": signal}))
            } else {
                Ok(json!({"cancelled": true}))
            }
        }
    }
}

#[cfg(test)]
pub(super) fn build_question_items(question: &RequestUserInputQuestion) -> Vec<InlineListItem> {
    let options = question.options.clone().or_else(|| generate_suggested_options(question));
    build_question_items_with_options(question, options)
}

pub(super) fn build_question_items_with_options(
    question: &RequestUserInputQuestion,
    options: Option<Vec<RequestUserInputOption>>,
) -> Vec<InlineListItem> {
    let options = options.map(ensure_recommended_first);

    if let Some(options) = options {
        let mut items: Vec<InlineListItem> = options
            .iter()
            .enumerate()
            .map(|(index, opt)| InlineListItem {
                title: format!("{}. {}", index + 1, opt.label),
                subtitle: Some(opt.description.clone()),
                badge: None,
                indent: 0,
                selection: Some(InlineListSelection::RequestUserInputAnswer {
                    question_id: question.id.clone(),
                    selected: vec![opt.label.clone()],
                    other: None,
                }),
                search_value: Some(format!("{} {}", opt.label, opt.description)),
            })
            .collect();

        items.push(InlineListItem {
            title: format!("{}. Custom note (inline)", options.len() + 1),
            subtitle: Some("Type your custom response inline, then press Enter to continue".to_string()),
            badge: None,
            indent: 0,
            selection: Some(InlineListSelection::RequestUserInputAnswer {
                question_id: question.id.clone(),
                selected: vec![],
                other: Some(String::new()),
            }),
            search_value: Some("custom note other custom response free text".to_string()),
        });
        items
    } else {
        vec![InlineListItem {
            title: "Enter your response...".to_string(),
            subtitle: Some("Type your answer in the input field".to_string()),
            badge: None,
            indent: 0,
            selection: Some(InlineListSelection::RequestUserInputAnswer {
                question_id: question.id.clone(),
                selected: vec![],
                other: Some(String::new()),
            }),
            search_value: None,
        }]
    }
}

fn append_summary_lines(
    handle: &InlineHandle,
    questions: &[RequestUserInputQuestion],
    answers: &HashMap<String, RequestUserInputAnswer>,
    wizard_mode: vtcode_ui::tui::app::WizardModalMode,
) {
    let summary_style = Arc::new(InlineTextStyle::default());
    let summary_segment = |text: String| InlineSegment { text, style: summary_style.clone() };

    if wizard_mode == vtcode_ui::tui::app::WizardModalMode::TabbedList {
        handle.append_line(InlineMessageKind::Info, vec![summary_segment("• Selection captured".to_string())]);
        return;
    }

    let answered_count = answers.len();
    let total_count = questions.len();
    handle.append_line(
        InlineMessageKind::Info,
        vec![summary_segment(format!(
            "• Questions {answered_count}/{total_count} answered"
        ))],
    );

    for question in questions {
        handle.append_line(InlineMessageKind::Info, vec![summary_segment(format!("  • {}", question.question))]);
        let answer_text = answers
            .get(&question.id)
            .map(|answer| {
                let mut parts = Vec::new();
                if !answer.selected.is_empty() {
                    parts.push(answer.selected.join(", "));
                }
                if let Some(other) = answer.other.as_ref().map(|text| text.trim()).filter(|text| !text.is_empty()) {
                    if parts.is_empty() {
                        parts.push(other.to_string());
                    } else {
                        parts.push(format!("notes: {other}"));
                    }
                }
                if parts.is_empty() {
                    "(unanswered)".to_string()
                } else {
                    parts.join(" — ")
                }
            })
            .unwrap_or_else(|| "(unanswered)".to_string());
        handle.append_line(InlineMessageKind::Info, vec![summary_segment(format!("    answer: {answer_text}"))]);
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use serde_json::json;
    use tokio::sync::{Notify, mpsc};

    use super::execute_request_user_input_tool;
    use crate::agent::runloop::unified::state::CtrlCState;
    use vtcode_ui::tui::app::{
        InlineCommand, InlineEvent, InlineHandle, InlineListSelection, InlineSession, TransientEvent, TransientRequest,
        TransientSubmission,
    };

    fn next_transient_request(command_rx: &mut mpsc::UnboundedReceiver<InlineCommand>) -> TransientRequest {
        loop {
            match command_rx.try_recv() {
                Ok(InlineCommand::ShowTransient { request }) => return *request,
                Ok(_) => {}
                Err(error) => panic!("expected a transient request, got {error:?}"),
            }
        }
    }

    fn test_handles() -> (
        InlineHandle,
        mpsc::UnboundedReceiver<InlineCommand>,
        mpsc::UnboundedSender<InlineEvent>,
        InlineSession,
    ) {
        let (command_tx, command_rx) = mpsc::unbounded_channel();
        let (event_tx, event_rx) = mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(command_tx);
        let session = InlineSession { handle: handle.clone(), events: event_rx };
        (handle, command_rx, event_tx, session)
    }

    #[tokio::test]
    async fn request_user_input_shows_wizard_and_returns_selected_answer() {
        let (handle, mut command_rx, event_tx, mut session) = test_handles();
        let ctrl_c_state = Arc::new(CtrlCState::new());
        let ctrl_c_notify = Arc::new(Notify::new());
        let args = json!({
            "questions": [{
                "id": "goal",
                "header": "Goal",
                "question": "What should this change deliver?",
                "options": [{
                    "label": "Small scope",
                    "description": "Keep the implementation focused."
                }, {
                    "label": "Broader scope",
                    "description": "Cover adjacent behavior in the same change."
                }]
            }]
        });

        event_tx
            .send(InlineEvent::Transient(TransientEvent::Submitted(TransientSubmission::Wizard(vec![
                InlineListSelection::RequestUserInputAnswer {
                    question_id: "goal".to_string(),
                    selected: vec!["Small scope".to_string()],
                    other: None,
                },
            ]))))
            .expect("send wizard answer");

        let output = execute_request_user_input_tool(&handle, &mut session, &args, &ctrl_c_state, &ctrl_c_notify)
            .await
            .expect("request_user_input result");

        assert_eq!(output["answers"]["goal"]["selected"], json!(["Small scope"]));
        match next_transient_request(&mut command_rx) {
            TransientRequest::Wizard(request) => {
                assert_eq!(request.title, "Goal");
                assert_eq!(request.steps.len(), 1);
                assert_eq!(request.steps[0].question, "What should this change deliver?");
                assert!(request.steps[0].items.iter().any(|item| {
                    item.title.contains("Small scope")
                        && matches!(
                            item.selection.as_ref(),
                            Some(InlineListSelection::RequestUserInputAnswer { question_id, .. })
                                if question_id == "goal"
                        )
                }));
            }
            other => panic!("expected wizard request, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn request_user_input_cancellation_returns_cancelled_without_answers() {
        let (handle, mut command_rx, event_tx, mut session) = test_handles();
        let ctrl_c_state = Arc::new(CtrlCState::new());
        let ctrl_c_notify = Arc::new(Notify::new());
        let args = json!({
            "questions": [{
                "id": "goal",
                "header": "Goal",
                "question": "What should this change deliver?"
            }]
        });

        event_tx
            .send(InlineEvent::Transient(TransientEvent::Cancelled))
            .expect("send wizard cancellation");

        let output = execute_request_user_input_tool(&handle, &mut session, &args, &ctrl_c_state, &ctrl_c_notify)
            .await
            .expect("cancelled request_user_input result");

        assert_eq!(output["cancelled"], json!(true));
        assert!(output.get("answers").is_none());
        assert!(matches!(next_transient_request(&mut command_rx), TransientRequest::Wizard(_)));
        loop {
            match command_rx.try_recv() {
                Ok(InlineCommand::CloseTransient) => break,
                Ok(_) => {}
                Err(error) => panic!("expected close command, got {error:?}"),
            }
        }
    }
}