use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use pretty_assertions::assert_eq;
use super::*;
use crate::{
questionnaire::{QuestionnaireDefaultSelection, QuestionnaireQuestionKind},
tui::{
goal::GoalState,
inline_shell::{InlineShellMode, PendingShellTask},
questionnaire::{
QuestionnaireComposer, QuestionnaireQuestion, QuestionnaireRequest,
QuestionnaireResponseChannel,
},
tests::test_app,
App, PickerAction, PickerItem, QueuedPrompt, UiPicker,
},
};
fn follow_up(text: &str) -> QueuedPrompt {
QueuedPrompt {
prompt: text.into(),
display_prompt: text.into(),
paste_segments: Vec::new(),
media: Vec::new(),
}
}
fn pending_approval() -> rho_sdk::PendingApproval {
let request = rho_sdk::ApprovalRequest::new(
rho_sdk::CapabilityRequest::read_path(
"/workspace/file",
rho_sdk::PathScope::PrimaryWorkspace,
rho_sdk::CapabilitySource::built_in_tool("read_file"),
),
"approval required",
);
rho_sdk::PendingApproval::new(request).0
}
fn model_picker() -> UiPicker {
UiPicker::new(
"select model",
vec![PickerItem {
section: None,
label: "model-a".into(),
detail: None,
preview: None,
badge: None,
value: "model-a".into(),
selection_verb: None,
}],
PickerAction::SelectModel,
)
}
fn questionnaire_composer() -> QuestionnaireComposer {
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
QuestionnaireComposer::new(
QuestionnaireRequest {
title: None,
reason: None,
questions: vec![QuestionnaireQuestion {
id: "choice".into(),
question: "choice?".into(),
header: None,
help: None,
default: None,
default_selection: QuestionnaireDefaultSelection::Selected,
kind: QuestionnaireQuestionKind::Choice,
required: true,
choices: vec!["alpha".into(), "beta".into()],
allow_other: false,
}],
},
QuestionnaireResponseChannel::new(reply_tx),
)
}
fn focus_pending_input(app: &mut App) {
app.pending.push_follow_up(follow_up("queued"));
assert!(app.handle_pending_input_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT,)));
}
#[tokio::test]
async fn running_escape_action_picks_one_owner() {
struct Case {
name: &'static str,
setup: fn(&mut App),
expected: Option<RunningEscapeAction>,
}
let cases = [
Case {
name: "idle composer does not abort",
setup: |_| {},
expected: None,
},
Case {
name: "empty provider turn aborts",
setup: |app| app.begin_provider_turn_ui(),
expected: Some(RunningEscapeAction::AbortTurn),
},
Case {
name: "goal evaluation wait aborts",
setup: |app| {
app.goal = Some(GoalState::new("tests pass".into()));
app.begin_cancellable_wait_ui();
},
expected: Some(RunningEscapeAction::AbortTurn),
},
Case {
name: "limits overlay intercepts during a wait",
setup: |app| {
app.begin_cancellable_wait_ui();
app.start_limits_command();
},
expected: Some(RunningEscapeAction::Overlay),
},
Case {
name: "command palette intercepts",
setup: |app| {
app.begin_provider_turn_ui();
app.input_ui.set_text("/".into());
app.input_ui.set_cursor(1);
},
expected: Some(RunningEscapeAction::Overlay),
},
Case {
name: "pending-input panel intercepts",
setup: |app| {
app.begin_provider_turn_ui();
focus_pending_input(app);
},
expected: Some(RunningEscapeAction::Overlay),
},
Case {
name: "shell mode intercepts",
setup: |app| {
app.begin_provider_turn_ui();
app.input_ui
.set_shell_mode(Some(InlineShellMode::IncludeInContext));
},
expected: Some(RunningEscapeAction::ExitShellMode),
},
Case {
name: "running inline shells intercept",
setup: |app| {
app.begin_provider_turn_ui();
app.pending_inline_shells
.push(PendingShellTask::test_task("hello"));
},
expected: Some(RunningEscapeAction::CancelInlineShells),
},
Case {
name: "pending inline shell + limits overlay",
setup: |app| {
app.begin_provider_turn_ui();
app.pending_inline_shells
.push(PendingShellTask::test_task("hello"));
app.start_limits_command();
},
expected: Some(RunningEscapeAction::Overlay),
},
Case {
name: "pending inline shell + picker overlay",
setup: |app| {
app.begin_provider_turn_ui();
app.pending_inline_shells
.push(PendingShellTask::test_task("hello"));
app.input_ui
.set_composer(ComposerMode::Picker(model_picker()));
},
expected: Some(RunningEscapeAction::Overlay),
},
Case {
name: "pending inline shell + questionnaire",
setup: |app| {
app.begin_provider_turn_ui();
app.pending_inline_shells
.push(PendingShellTask::test_task("hello"));
app.input_ui
.set_composer(ComposerMode::Questionnaire(questionnaire_composer()));
},
expected: Some(RunningEscapeAction::Overlay),
},
Case {
name: "pending inline shell + command palette",
setup: |app| {
app.begin_provider_turn_ui();
app.pending_inline_shells
.push(PendingShellTask::test_task("hello"));
app.input_ui.set_text("/".into());
app.input_ui.set_cursor(1);
},
expected: Some(RunningEscapeAction::Overlay),
},
Case {
name: "pending inline shell + focused pending-input panel",
setup: |app| {
app.begin_provider_turn_ui();
app.pending_inline_shells
.push(PendingShellTask::test_task("hello"));
focus_pending_input(app);
},
expected: Some(RunningEscapeAction::Overlay),
},
Case {
name: "shell mode + focused pending-input panel",
setup: |app| {
app.begin_provider_turn_ui();
app.input_ui
.set_shell_mode(Some(InlineShellMode::IncludeInContext));
focus_pending_input(app);
},
expected: Some(RunningEscapeAction::Overlay),
},
];
for case in cases {
let mut app = test_app();
(case.setup)(&mut app);
assert_eq!(app.running_escape_action(), case.expected, "{}", case.name);
let _ = app.cancel_inline_shells();
}
}
#[tokio::test]
async fn approval_escape_denies_and_aborts_first() {
let mut app = test_app();
app.begin_provider_turn_ui();
app.open_approval(pending_approval()).await;
assert_eq!(
app.running_escape_action(),
Some(RunningEscapeAction::DenyApprovalAndAbort)
);
let mut app = test_app();
app.begin_provider_turn_ui();
app.pending_inline_shells
.push(PendingShellTask::test_task("hello"));
app.open_approval(pending_approval()).await;
assert_eq!(
app.running_escape_action(),
Some(RunningEscapeAction::DenyApprovalAndAbort)
);
let _ = app.cancel_inline_shells();
}
#[tokio::test]
async fn empty_composer_abort_hint_follows_esc_policy() {
struct Case {
name: &'static str,
setup: fn(&mut App),
expected: bool,
}
let cases = [
Case {
name: "idle",
setup: |_| {},
expected: false,
},
Case {
name: "provider turn",
setup: |app| app.begin_provider_turn_ui(),
expected: true,
},
Case {
name: "cancellable wait",
setup: |app| app.begin_cancellable_wait_ui(),
expected: true,
},
Case {
name: "compact",
setup: |app| app.begin_compact_ui(),
expected: false,
},
Case {
name: "shell mode",
setup: |app| {
app.begin_provider_turn_ui();
app.input_ui
.set_shell_mode(Some(InlineShellMode::IncludeInContext));
},
expected: false,
},
Case {
name: "pending inline shells",
setup: |app| {
app.begin_provider_turn_ui();
app.pending_inline_shells
.push(PendingShellTask::test_task("hello"));
},
expected: false,
},
];
for case in cases {
let mut app = test_app();
(case.setup)(&mut app);
assert_eq!(
app.composer_shows_abort_hint(),
case.expected,
"{}",
case.name
);
let _ = app.cancel_inline_shells();
}
}