use super::super::{tests::test_app, CommandChoice, CommandChoiceKind, InputSubmissionMode};
use crate::tui::composer_buffer::ComposerEditKey;
use pretty_assertions::assert_eq;
#[test]
fn completing_goal_command_reveals_lifecycle_actions() {
let mut app = test_app();
app.input_ui.set_text("/goal".to_string());
app.input_ui.set_cursor(app.input_ui.text().chars().count());
app.clamp_command_selection();
let goal = app.selected_command().unwrap();
app.complete_command_choice(&goal);
assert_eq!(app.input_ui.text(), "/goal ");
assert!(app.command_palette_visible());
let matches = app.command_matches();
assert_eq!(
matches
.iter()
.map(|choice| choice.usage.as_str())
.collect::<Vec<_>>(),
vec!["/goal resume", "/goal clear"]
);
app.input_ui
.set_text("/goal release is published".to_string());
app.input_ui.set_cursor(app.input_ui.text().chars().count());
app.input_changed();
assert!(!app.command_palette_visible());
}
#[test]
fn goal_lifecycle_action_completion_replaces_placeholder() {
let mut app = test_app();
app.input_ui.set_text("/goal ".to_string());
app.input_ui.set_cursor(app.input_ui.text().chars().count());
app.clamp_command_selection();
app.input_ui.move_command_selection(1);
let clear = app.selected_command().unwrap();
app.complete_command_choice(&clear);
assert_eq!(app.input_ui.text(), "/goal clear");
assert_eq!(app.input_ui.cursor(), "/goal clear".chars().count());
assert_eq!(
app.input_ui.submission_mode(),
InputSubmissionMode::ParseCommands
);
}
#[test]
fn exact_template_match_precedes_builtin_prefix_match() {
let mut app = test_app();
app.info
.runtime
.config_prompt_templates
.insert("mod".into(), "custom template".into());
app.input_ui.set_text("/mod argument".to_string());
app.input_ui.set_cursor(4);
let matches = app.command_matches();
assert_eq!(matches[0].name, "prompt:mod");
assert_eq!(matches[1].name, "model");
}
#[test]
fn template_completion_expands_pasted_arguments_and_clears_segments() {
let mut app = test_app();
app.info
.runtime
.config_prompt_templates
.insert("review".into(), "Review this:".into());
app.insert_input_text("/review ");
app.insert_pasted_input_text("alpha\nbeta");
let choice = app.selected_command().unwrap();
app.complete_command_choice(&choice);
assert_eq!(app.input_ui.text(), "Review this: alpha\nbeta ");
assert_eq!(app.expanded_input(), "Review this: alpha\nbeta ");
assert!(app.input_ui.paste_segments().is_empty());
}
#[test]
fn template_completion_marks_slash_prefixed_contents_as_prompt() {
let mut app = test_app();
app.info
.runtime
.config_prompt_templates
.insert("review".into(), "/diff literally".into());
app.input_ui.set_text("/review".to_string());
app.input_ui.set_cursor(app.input_char_len());
let choice = app.selected_command().unwrap();
app.complete_command_choice(&choice);
assert_eq!(app.input_ui.text(), "/diff literally ");
assert_eq!(app.input_ui.submission_mode(), InputSubmissionMode::Prompt);
}
#[test]
fn template_with_placeholders_completes_command_until_arguments_are_typed() {
let mut app = test_app();
app.info
.runtime
.config_prompt_templates
.insert("review".into(), "Review $1 for $2.".into());
app.input_ui.set_text("/rev".to_string());
app.input_ui.set_cursor(app.input_char_len());
let choice = app.selected_command().unwrap();
app.complete_command_choice(&choice);
assert_eq!(app.input_ui.text(), "/prompt:review ");
assert_eq!(
app.input_ui.submission_mode(),
InputSubmissionMode::ParseCommands
);
app.insert_input_text("src/lib.rs \"error handling\"");
app.input_ui.set_cursor("/prompt:review".chars().count());
let choice = app.selected_command().unwrap();
app.complete_command_choice(&choice);
assert_eq!(
app.input_ui.text(),
"Review src/lib.rs for error handling. "
);
}
#[test]
fn edited_template_files_reload_without_restart() {
let project = tempfile::tempdir().unwrap();
let prompts = project.path().join(".rho/prompts");
std::fs::create_dir_all(project.path().join(".git")).unwrap();
std::fs::create_dir_all(&prompts).unwrap();
let mut app = test_app();
app.info.runtime.cwd = project.path().to_path_buf();
let descriptions = |app: &mut crate::tui::App| {
app.command_matches()
.into_iter()
.filter(|choice| choice.name == "prompt:zz-reload")
.map(|choice| choice.description)
.collect::<Vec<_>>()
};
app.input_ui.set_text("/prompt:zz".to_string());
app.input_ui.set_cursor(app.input_char_len());
assert_eq!(descriptions(&mut app), Vec::<String>::new());
std::fs::write(prompts.join("zz-reload.md"), "first draft").unwrap();
app.palette_caches.expire_prompt_templates();
assert_eq!(descriptions(&mut app), vec!["first draft".to_string()]);
std::fs::write(prompts.join("zz-reload.md"), "second draft").unwrap();
assert_eq!(
app.resolve_prompt_template("PrOmPt:ZZ-RELOAD", "details"),
Some("second draft details".to_string())
);
assert_eq!(descriptions(&mut app), vec!["second draft".to_string()]);
assert_eq!(app.resolve_prompt_template("prompt:missing", ""), None);
let listed = app.selected_command().unwrap();
std::fs::write(prompts.join("zz-reload.md"), "third draft").unwrap();
app.complete_command_choice(&listed);
assert_eq!(app.input_ui.text(), "third draft ");
}
#[test]
fn recalling_a_command_keeps_the_palette_closed_until_edit() {
let mut app = test_app();
app.push_input_history("/info");
app.push_input_history("/model");
app.input_ui.set_text("/c".to_string());
app.input_ui.set_cursor(2);
app.input_changed();
assert!(app.command_palette_visible());
app.apply_input_edit_key(ComposerEditKey::Up);
assert_eq!(app.input_ui.text(), "/model");
assert!(!app.command_palette_visible());
app.apply_input_edit_key(ComposerEditKey::Up);
assert_eq!(app.input_ui.text(), "/info");
assert!(!app.command_palette_visible());
app.apply_input_edit_key(ComposerEditKey::Down);
app.apply_input_edit_key(ComposerEditKey::Down);
assert_eq!(app.input_ui.text(), "/c");
assert!(app.command_palette_visible());
app.apply_input_edit_key(ComposerEditKey::Up);
assert!(!app.command_palette_visible());
app.apply_input_edit_key(ComposerEditKey::Backspace);
assert_eq!(app.input_ui.text(), "/mode");
assert!(app.command_palette_visible());
}
#[test]
fn dismiss_on_esc_clears_bare_slash_composer() {
let mut app = test_app();
app.input_ui.set_text("/".to_string());
app.input_ui.set_cursor(1);
assert!(app.command_palette_visible());
app.dismiss_command_palette_on_esc();
assert_eq!(app.input_ui.text(), "");
assert_eq!(app.input_ui.cursor(), 0);
assert!(app.input_ui.command_palette_dismissed());
}
#[test]
fn dismiss_on_esc_preserves_partial_slash_command() {
let mut app = test_app();
app.input_ui.set_text("/mo".to_string());
app.input_ui.set_cursor(3);
assert!(app.command_palette_visible());
app.dismiss_command_palette_on_esc();
assert_eq!(app.input_ui.text(), "/mo");
assert_eq!(app.input_ui.cursor(), 3);
assert!(app.input_ui.command_palette_dismissed());
}
#[test]
fn enter_keeps_bare_command_until_an_argument_row_is_picked() {
let mut app = test_app();
app.input_ui.set_text("/goal ".to_string());
app.input_ui.set_cursor(app.input_ui.text().chars().count());
app.clamp_command_selection();
let matches = app.command_matches();
assert!(!app.enter_completes_choice(&matches[0]));
app.input_ui.move_command_selection(1);
assert!(app.enter_completes_choice(&matches[1]));
app.input_ui.set_text("/goal".to_string());
app.input_ui.set_cursor(app.input_ui.text().chars().count());
app.input_changed();
assert!(!app.input_ui.command_selection_explicit());
}
#[test]
fn clamping_below_a_pick_drops_the_explicit_flag() {
let mut app = test_app();
app.input_ui.set_text("/goal ".to_string());
app.input_ui.set_cursor(app.input_ui.text().chars().count());
app.clamp_command_selection();
app.input_ui.move_command_selection(1);
assert!(app.input_ui.command_selection_explicit());
app.input_ui.set_text("/agents ".to_string());
app.input_ui.set_cursor(app.input_ui.text().chars().count());
app.input_changed();
assert_eq!(app.input_ui.command_selection(), 0);
assert!(!app.input_ui.command_selection_explicit());
}
#[test]
fn enter_on_mcp_argument_rows_follows_the_pick_rule() {
let choice = CommandChoice {
name: "alice".into(),
usage: "alice".into(),
description: String::new(),
kind: CommandChoiceKind::McpPromptArgument { value: 0..0 },
};
let mut app = test_app();
assert!(!app.enter_completes_choice(&choice));
app.input_ui.move_command_selection(1);
assert!(app.enter_completes_choice(&choice));
let mut app = test_app();
app.mcp_catalog.insert_offline_prompt(
crate::tools::mcp::catalog::McpPrompt {
server: "tickets".into(),
name: "triage".into(),
title: None,
description: None,
arguments: vec![
crate::tools::mcp::catalog::McpPromptArgument {
name: "severity".into(),
description: None,
required: false,
},
crate::tools::mcp::catalog::McpPromptArgument {
name: "owner".into(),
description: None,
required: false,
},
],
},
true,
);
let typed = "/mcp:tickets:triage owner=al";
app.input_ui.set_text(typed.to_string());
app.input_ui.set_cursor(typed.chars().count());
assert!(!app.input_ui.command_selection_explicit());
assert!(app
.mcp_argument_cursor()
.is_some_and(|cursor| cursor.key.typed == "al"));
assert!(app.enter_completes_choice(&choice));
let empty = "/mcp:tickets:triage owner=";
app.input_ui.set_text(empty.to_string());
app.input_ui.set_cursor(empty.chars().count());
assert!(app
.mcp_argument_cursor()
.is_some_and(|cursor| cursor.key.typed.is_empty()));
assert!(!app.enter_completes_choice(&choice));
}