use super::support::*;
use super::*;
#[test]
fn composer_dispatches_commands_and_active_turn_steering() {
let catalog = default_catalog();
let mut idle = state();
idle.input = "/exit".into();
idle.cursor = idle.input.len();
assert_eq!(idle.submit_input(&catalog), UiAction::Exit);
let mut working = state();
working.active_turn = Some("turn".into());
working.input = "change direction".into();
working.cursor = working.input.len();
assert_eq!(
working.submit_input(&catalog),
UiAction::Submit(Op::ActiveInput {
operation: "steer".into(),
turn_id: "turn".into(),
text: "change direction".into(),
})
);
}
#[test]
fn new_and_clear_keep_distinct_terminal_semantics() {
let catalog = default_catalog();
let mut new = state();
new.input = "/new".into();
new.cursor = new.input.len();
let mut clear = state();
clear.input = "/clear".into();
clear.cursor = clear.input.len();
assert_eq!(
(new.submit_input(&catalog), clear.submit_input(&catalog)),
(UiAction::New, UiAction::Clear)
);
}
#[test]
fn composer_queues_a_new_turn_without_steering_middleware() {
let catalog = UiCatalog::build(
&[],
&[],
std::path::Path::new("/missing-mobius-test-workspace"),
)
.expect("UI catalog");
let mut working = state();
working.active_turn = Some("turn".into());
working.input = "next task".into();
working.cursor = working.input.len();
assert_eq!(
working.submit_input(&catalog),
UiAction::Submit(Op::UserInput {
text: "next task".into(),
attachments: Vec::new(),
})
);
}
#[test]
fn composer_targets_interrupt_at_the_active_turn() {
let catalog = default_catalog();
let mut slash = state();
slash.active_turn = Some("turn-1".into());
slash.input = "/interrupt".into();
slash.cursor = slash.input.len();
let slash_action = slash.submit_input(&catalog);
let mut escape = state();
escape.active_turn = Some("turn-1".into());
let escape_action =
escape.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &catalog);
assert_eq!(
(slash_action, escape_action),
(
UiAction::Submit(Op::Interrupt {
turn_id: "turn-1".into()
}),
UiAction::Submit(Op::Interrupt {
turn_id: "turn-1".into()
})
)
);
}
#[test]
fn generic_picker_submits_the_selected_operation() {
let mut state = state();
state.handle_agent_event(
EventMsg::Frontend(FrontendEvent::Picker {
title: "Resume chat".into(),
options: vec![
mobius::protocol::FrontendPickerOption {
label: "first".into(),
description: "older".into(),
detail: String::new(),
symbol: None,
shows_detail: false,
op: Op::ResumeSession {
session_id: "first".into(),
},
},
mobius::protocol::FrontendPickerOption {
label: "second".into(),
description: "newer".into(),
detail: String::new(),
symbol: None,
shows_detail: false,
op: Op::ResumeSession {
session_id: "second".into(),
},
},
],
}),
Vec::new(),
);
let catalog = default_catalog();
state.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &catalog);
assert_eq!(
state.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &catalog),
UiAction::Submit(Op::ResumeSession {
session_id: "second".into(),
})
);
}
#[tokio::test]
async fn workspace_reference_menu_inserts_a_file() {
let workspace = tempfile::tempdir().expect("workspace");
std::fs::create_dir(workspace.path().join("src")).expect("source directory");
std::fs::write(workspace.path().join("src/lib.rs"), "").expect("source file");
let catalog = catalog(workspace.path());
catalog
.start_workspace_inventory(true)
.await
.expect("workspace inventory");
let mut state = state();
state.input = "review @lib".into();
state.cursor = state.input.len();
state.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &catalog);
assert_eq!(state.input, "review src/lib.rs");
}
#[tokio::test]
async fn remote_workspace_inventory_does_not_read_the_client_filesystem() {
let workspace = tempfile::tempdir().expect("workspace");
std::fs::write(workspace.path().join("client-only.rs"), "").expect("client file");
let catalog = catalog(workspace.path());
catalog
.start_workspace_inventory(false)
.await
.expect("disabled workspace inventory");
assert!(catalog.reference_suggestions('@', "client").is_empty());
}
#[test]
fn composer_rejects_input_over_the_protocol_limit() {
let mut state = state();
state.insert_paste(&"x".repeat(mobius::protocol::MAX_USER_INPUT_BYTES));
state.insert_text("x");
assert_eq!(
(
state.pastes.values().map(String::len).sum::<usize>(),
state.input_limit_reached,
),
(mobius::protocol::MAX_USER_INPUT_BYTES, true)
);
}
#[test]
fn option_backspace_deletes_the_previous_word_and_trailing_space() {
let mut state = state();
state.input = "hello world ".into();
state.cursor = state.input.len();
state.handle_key(
KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT),
&default_catalog(),
);
assert_eq!((state.input.as_str(), state.cursor), ("hello ", 8));
}
#[test]
fn option_backspace_deletes_a_collapsed_paste_atomically() {
let mut state = state();
state.input = "foo.".into();
state.cursor = state.input.len();
state.insert_paste("pasted\ncontent");
state.handle_key(
KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT),
&default_catalog(),
);
assert_eq!((state.input.as_str(), state.pastes.len()), ("foo.", 0));
}
#[test]
fn arrow_up_recalls_composer_history_and_ctrl_t_toggles_transcript() {
let catalog = default_catalog();
let mut state = state();
state.remember_composer_input("previous prompt".into());
state.attachments.push(attachment("draft.png"));
state.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), &catalog);
assert_eq!(state.input, "previous prompt");
assert_eq!(state.attachments, vec![attachment("draft.png")]);
assert!(state.preview.is_none());
state.handle_key(
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
&catalog,
);
assert!(matches!(
state.preview.as_ref().map(|preview| &preview.content),
Some(PreviewContent::LiveTranscript)
));
state.handle_key(
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
&catalog,
);
assert!(state.preview.is_none());
}
#[test]
fn approval_preserves_an_in_progress_draft() {
let mut state = state();
state.input = "steer after approval".into();
state.cursor = state.input.len();
state.attachments.push(attachment("draft.png"));
state.handle_agent_event(
EventMsg::ExecApprovalRequest(mobius::protocol::ExecApprovalRequestEvent {
id: "approval".into(),
turn_id: "turn".into(),
calls: Vec::new(),
reason: "test".into(),
}),
Vec::new(),
);
let mut finished_upload = attachment("finished-upload.png");
finished_upload.id = "471f43e6-6886-483c-bfe4-771db52614c8".into();
state.attachments.push(finished_upload.clone());
assert_eq!(
state.handle_key(
KeyEvent::new_with_kind(
KeyCode::Char('y'),
KeyModifiers::NONE,
ratatui::crossterm::event::KeyEventKind::Repeat,
),
&default_catalog(),
),
UiAction::None
);
assert_eq!(
state.handle_key(
KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT),
&default_catalog(),
),
UiAction::Submit(Op::ExecApproval {
id: "approval".into(),
decision: ReviewDecision::Approved,
})
);
assert_eq!(state.input, "steer after approval");
assert_eq!(
state.attachments,
vec![attachment("draft.png"), finished_upload]
);
assert!(state.is_working());
}
#[test]
fn ctrl_or_alt_v_requests_native_clipboard_paste() {
let catalog = default_catalog();
for modifiers in [
KeyModifiers::CONTROL,
KeyModifiers::ALT,
KeyModifiers::CONTROL | KeyModifiers::ALT,
] {
let mut state = state();
assert_eq!(
state.handle_key(KeyEvent::new(KeyCode::Char('v'), modifiers), &catalog),
UiAction::PasteClipboard
);
}
}
#[test]
fn attachment_only_submission_is_a_user_turn() {
let mut state = state();
state.attachments.push(attachment("photo.png"));
assert_eq!(
state.submit_input(&default_catalog()),
UiAction::Submit(Op::UserInput {
text: String::new(),
attachments: vec![attachment("photo.png")],
})
);
assert!(state.attachments.is_empty());
}
#[test]
fn active_turn_preserves_attachment_draft_instead_of_steering() {
let mut state = state();
state.active_turn = Some("turn".into());
state.input = "next request".into();
state.cursor = state.input.len();
state.attachments.push(attachment("report.pdf"));
assert_eq!(state.submit_input(&default_catalog()), UiAction::None);
assert_eq!(state.input, "next request");
assert_eq!(state.attachments, vec![attachment("report.pdf")]);
}
#[test]
fn upload_in_progress_preserves_the_whole_draft() {
let mut state = state();
state.input = "describe these".into();
state.cursor = state.input.len();
state.attachments.push(attachment("ready.png"));
state.upload_in_progress = true;
assert_eq!(state.submit_input(&default_catalog()), UiAction::None);
assert_eq!(state.input, "describe these");
assert_eq!(state.attachments, vec![attachment("ready.png")]);
}
#[test]
fn backspace_removes_the_last_attachment_from_an_empty_composer() {
let mut state = state();
state.attachments.push(attachment("first.png"));
state.attachments.push(attachment("second.png"));
state.handle_key(
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
&default_catalog(),
);
assert_eq!(state.attachments, vec![attachment("first.png")]);
}
#[test]
fn normal_pasted_paths_remain_text() {
let mut state = state();
state.insert_paste("/tmp/photo.png");
assert_eq!(state.input, "/tmp/photo.png");
assert!(state.attachments.is_empty());
}
fn attachment(name: &str) -> mobius::protocol::SessionFileReference {
mobius::protocol::SessionFileReference {
id: "3d46beff-7e84-46ea-859a-e66b4614a79b".into(),
name: name.into(),
size: 4,
media_type: if name.ends_with(".pdf") {
"application/pdf"
} else {
"image/png"
}
.into(),
}
}