use super::*;
use rpi_tui::Component;
#[test]
fn changelog_command_shows_current_release_and_all_is_explicit() {
let entries = crate::changelog::bundled_entries();
let latest = crate::changelog::latest_release(&entries, crate::VERSION);
let heading = |entry: &crate::changelog::ChangelogEntry| {
format!("{}.{}.{}", entry.major, entry.minor, entry.patch)
};
let latest_heading = heading(&latest[0]);
let historical_heading = heading(entries.last().unwrap());
let chat = Arc::new(Container::new());
add_changelog_message(&chat);
let output = strip_ansi(&chat.render(120).join("\n"));
assert!(output.lines().any(|line| line.trim() == latest_heading));
assert!(!output.lines().any(|line| line.trim() == historical_heading));
assert!(!output.contains("Native parity phase 1"));
let chat = Arc::new(Container::new());
add_changelog_message_with_scope(&chat, true);
let output = strip_ansi(&chat.render(120).join("\n"));
assert!(output.lines().any(|line| line.trim() == historical_heading));
assert!(output.lines().any(|line| line.trim() == latest_heading));
}
#[test]
fn startup_changelog_foreign_profile_is_shown_once_and_persisted() {
const CHILD: &str = "RPI_CHANGELOG_REGRESSION_CHILD";
if std::env::var_os(CHILD).is_none() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("settings.json"),
r#"{"lastChangelogVersion":"0.85.1","custom":{"retained":true}}"#,
)
.unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "interactive_tui::tests::startup_changelog_foreign_profile_is_shown_once_and_persisted", "--nocapture"])
.env(CHILD, "1").env(crate::config::CONFIG_DIR_ENV, dir.path()).output().unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let settings = crate::settings::load_settings().unwrap();
let chat = Arc::new(Container::new());
maybe_add_startup_changelog(&harness, &chat, &settings, false, true).await;
let output = strip_ansi(&chat.render(120).join("\n"));
assert!(output.contains("What's New"));
assert!(output.lines().any(|line| line.trim() == crate::VERSION));
let old = crate::changelog::bundled_entries().pop().unwrap();
let old_version = format!("{}.{}.{}", old.major, old.minor, old.patch);
assert!(!output.lines().any(|line| line.trim() == old_version));
let settings = crate::settings::load_settings().unwrap();
assert_eq!(
settings.last_rpi_changelog_version.as_deref(),
Some(crate::VERSION)
);
assert_eq!(settings.last_changelog_version.as_deref(), Some("0.85.1"));
let chat = Arc::new(Container::new());
maybe_add_startup_changelog(&harness, &chat, &settings, false, true).await;
assert_eq!(chat.child_count(), 0);
let value: serde_json::Value = serde_json::from_slice(
&std::fs::read(crate::config::settings_path().unwrap()).unwrap(),
)
.unwrap();
assert_eq!(value["custom"]["retained"], true);
let mut stale = settings.clone();
let current_version = semver::Version::parse(crate::VERSION).unwrap();
let old = crate::changelog::bundled_entries()
.into_iter()
.find(|entry| {
(entry.major, entry.minor, entry.patch)
< (
current_version.major,
current_version.minor,
current_version.patch,
)
})
.unwrap();
stale.last_rpi_changelog_version =
Some(format!("{}.{}.{}", old.major, old.minor, old.patch));
let mut newer = settings;
let mut next_version = current_version;
next_version.patch += 1;
newer.last_rpi_changelog_version = Some(next_version.to_string());
crate::settings::save_settings(&newer).unwrap();
maybe_add_startup_changelog(&harness, &Arc::new(Container::new()), &stale, false, true)
.await;
assert_eq!(
crate::settings::load_settings()
.unwrap()
.last_rpi_changelog_version,
Some(next_version.to_string())
);
});
}
#[test]
fn collapsed_startup_changelog_links_to_full_notes() {
let chat = Arc::new(Container::new());
add_startup_changelog_message(&chat, "## 0.3.17\n\n- full release detail", "0.3.17", true);
let output = strip_ansi(&chat.render(120).join("\n"));
assert!(output.contains("Updated to v0.3.17"));
assert!(output.contains("/changelog"));
assert!(!output.contains("full release detail"));
let chat = Arc::new(Container::new());
add_startup_changelog_message(&chat, "## 0.3.17\n\n- full release detail", "0.3.17", false);
let output = strip_ansi(&chat.render(120).join("\n"));
assert!(output.contains("What's New"));
assert!(output.contains("full release detail"));
}
#[test]
fn queued_user_message_keeps_images_and_image_only_messages() {
let image = rpi_ai::types::ImageContent {
kind: rpi_ai::types::ImageContentType,
data: "test".into(),
mime_type: "image/png".into(),
};
for text in ["", "inspect this"] {
let message = user_message_with_images(text, vec![image.clone()]);
let AgentMessage::User(user) = message else {
panic!("expected user message")
};
let rpi_ai::types::UserContent::Blocks(blocks) = user.content else {
panic!("expected image blocks")
};
assert!(
matches!(blocks.last(), Some(rpi_ai::types::Content::Image(value)) if value == &image)
);
assert_eq!(blocks.len(), if text.is_empty() { 1 } else { 2 });
}
}
#[test]
fn pasted_images_accept_multiple_quoted_paths_without_consuming_normal_text() {
let dir = tempfile::tempdir().unwrap();
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder
.write_header()
.unwrap()
.write_image_data(&[255, 0, 0, 255])
.unwrap();
}
let first = dir.path().join("first image.png");
let second = dir.path().join("second.png");
std::fs::write(&first, &bytes).unwrap();
std::fs::write(&second, &bytes).unwrap();
let quoted = format!("\"{}\" \"{}\"", first.display(), second.display());
assert_eq!(images_from_pasted_paths("ed).unwrap().len(), 2);
assert_eq!(
images_from_pasted_paths(&format!("{}\n{}", first.display(), second.display()))
.unwrap()
.len(),
2
);
assert!(images_from_pasted_paths("please inspect these images").is_none());
let state = test_tui_state();
let images = images_from_pasted_paths("ed).unwrap();
for image in &images {
state.queue_image(image.clone());
}
let sent = state.take_pending_images();
assert!(state.take_pending_images().is_empty());
state.queue_image(images[0].clone());
state.restore_pending_images(sent);
assert_eq!(state.take_pending_images().len(), 3);
state.editor.set_text("existing draft");
assert!(attach_pasted_images(
&state,
&format!("\"{}\"", first.display())
));
assert_eq!(state.take_pending_images().len(), 1);
assert_eq!(state.editor.get_text(), "existing draft");
assert!(!attach_pasted_images(&state, "normal clipboard text"));
assert!(state.take_pending_images().is_empty());
}
#[test]
#[ignore = "set RPI_STATS_DLL to the monitor DLL"]
fn monitor_function_key_works_with_a_draft_without_changing_text() {
use rpi_extensions::{
dispatch_empty_event, load_session_mixed, NullDiagnostics, RegistrySnapshot,
};
use rpi_plugin_sdk::EventTag;
struct Shutdown(Arc<RegistrySnapshot>);
impl Drop for Shutdown {
fn drop(&mut self) {
dispatch_empty_event(&self.0, EventTag::SessionShutdown);
}
}
let state = test_tui_state();
let session = load_session_mixed(
&[],
&[std::path::PathBuf::from(
std::env::var("RPI_STATS_DLL").unwrap(),
)],
Arc::new(NullDiagnostics),
None,
);
let snapshot = session.snapshot_arc().expect("monitor loaded");
*state.extension_session.lock().unwrap() = session;
let _shutdown = Shutdown(snapshot);
state.editor.set_text("unsent draft");
assert!(dispatch_key_event(
&state,
&KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE),
&state.editor
));
assert_eq!(state.editor.get_text(), "unsent draft");
assert!(!dispatch_key_event(
&state,
&KeyEvent::new(KeyCode::F(8), KeyModifiers::CONTROL),
&state.editor
));
assert!(!dispatch_key_event(
&state,
&KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
&state.editor
));
assert_eq!(state.editor.get_text(), "unsent draft");
}
#[test]
fn modal_inputs_keep_registered_plugin_shortcuts_until_closed() {
use rpi_extensions::{ExtensionRegistry, ExtensionSession, PluginKeepalive};
use rpi_plugin_sdk::{EventTag, StablePluginEvent, EVENT_HANDLER_CLAIMED};
use std::sync::atomic::{AtomicUsize, Ordering};
static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
rpi_plugin_sdk::handles::ToolHandles::new();
struct ContextOwner(usize);
impl Drop for ContextOwner {
fn drop(&mut self) {
drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
}
}
extern "C" fn claim(_: StablePluginEvent, user_data: *mut std::ffi::c_void) -> i32 {
let context = CONTEXTS.get(user_data).expect("live test context");
let calls = context.lock().clone();
calls.fetch_add(1, Ordering::SeqCst);
EVENT_HANDLER_CLAIMED
}
let calls = Arc::new(AtomicUsize::new(0));
let context = CONTEXTS.insert(calls.clone());
let _owner = ContextOwner(context as usize);
let state = test_tui_state();
let mut registry = ExtensionRegistry::new();
for name in ["space", "f8", "escape", "enter"] {
registry.register_shortcut("test".into(), name.into(), "test".into());
}
registry.register_event_handler("test".into(), EventTag::Input, claim, context);
*state.extension_session.lock().unwrap() = ExtensionSession::from_parts(
Arc::new(registry.snapshot()),
PluginKeepalive::empty(),
vec![],
None,
);
let keys = [KeyCode::Char(' '), KeyCode::F(8), KeyCode::Enter];
for code in keys {
assert!(dispatch_key_event(
&state,
&KeyEvent::new(code, KeyModifiers::NONE),
&state.editor
));
assert!(dispatch_key_event(
&state,
&KeyEvent::new_with_kind(code, KeyModifiers::NONE, KeyEventKind::Release),
&state.editor
));
}
let before = calls.load(Ordering::SeqCst);
for modal in 0..4 {
match modal {
0 => *state.active_extension_editor.lock().unwrap() = Some(Arc::new(Editor::simple())),
1 => *state.active_extension_input.lock().unwrap() = Some(Arc::new(Input::new())),
2 => {
*state.active_selector.lock().unwrap() = Some((
SelectorView::List(Arc::new(SelectList::new(vec![], 5))),
SelectorKind::Extension,
))
}
_ => state.search.activate(),
}
for code in keys.into_iter().chain([KeyCode::Esc]) {
for kind in [
KeyEventKind::Press,
KeyEventKind::Repeat,
KeyEventKind::Release,
] {
let key = KeyEvent::new_with_kind(code, KeyModifiers::NONE, kind);
assert!(!dispatch_key_event(&state, &key, &state.editor));
}
}
assert_eq!(calls.load(Ordering::SeqCst), before);
state.active_extension_editor.lock().unwrap().take();
state.active_extension_input.lock().unwrap().take();
state.active_selector.lock().unwrap().take();
state.search.deactivate();
}
assert!(dispatch_key_event(
&state,
&KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE),
&state.editor
));
assert_eq!(calls.load(Ordering::SeqCst), before + 1);
let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
assert!(dispatch_key_event(&state, &space, &state.editor));
state.editor.set_text("injected transcription");
*state.active_extension_input.lock().unwrap() = Some(Arc::new(Input::new()));
let held = calls.load(Ordering::SeqCst);
assert!(dispatch_key_event(
&state,
&KeyEvent::new_with_kind(space.code, space.modifiers, KeyEventKind::Release),
&state.editor
));
assert_eq!(
calls.load(Ordering::SeqCst),
held + 1,
"finish the already-owned hold"
);
assert!(!dispatch_key_event(&state, &space, &state.editor));
}
#[test]
fn auto_send_cancels_when_the_user_edits_the_draft() {
let now = std::time::Instant::now();
let pending = AutoSendPending {
deadline: now + std::time::Duration::from_secs(2),
text: "hello world".to_string(),
};
assert!(matches!(
auto_send_step(Some(&pending), "hello world", true, now),
AutoSendStep::Wait(_)
));
assert_eq!(
auto_send_step(Some(&pending), "hello worlds", true, now),
AutoSendStep::Cancel
);
assert_eq!(
auto_send_step(Some(&pending), "", true, now),
AutoSendStep::Cancel
);
assert_eq!(
auto_send_step(Some(&pending), " ", true, now),
AutoSendStep::Cancel
);
}
#[test]
fn auto_send_submits_at_the_deadline_only_while_idle() {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2000);
let pending = AutoSendPending {
deadline,
text: "go".to_string(),
};
assert!(matches!(
auto_send_step(
Some(&pending),
"go",
true,
deadline - std::time::Duration::from_millis(1)
),
AutoSendStep::Wait(_)
));
assert_eq!(
auto_send_step(Some(&pending), "go", true, deadline),
AutoSendStep::Submit
);
assert_eq!(
auto_send_step(Some(&pending), "go", false, deadline),
AutoSendStep::Cancel
);
assert_eq!(
auto_send_step(None, "go", true, deadline),
AutoSendStep::Idle
);
}
#[test]
fn auto_send_footer_label_is_recognisable() {
let label = format!("{AUTO_SEND_PREFIX}2.0s · any key to edit");
assert!(label.starts_with(AUTO_SEND_PREFIX));
assert!(!"Working…".starts_with(AUTO_SEND_PREFIX));
}
#[test]
fn markdown_flag_only_comes_from_an_explicit_true() {
assert!(tool_result_requests_markdown(Some(
&serde_json::json!({"kind": "todo", "markdown": true})
)));
assert!(!tool_result_requests_markdown(None));
assert!(!tool_result_requests_markdown(Some(
&serde_json::json!({"kind": "todo"})
)));
assert!(!tool_result_requests_markdown(Some(
&serde_json::json!({"markdown": false})
)));
assert!(!tool_result_requests_markdown(Some(
&serde_json::json!({"markdown": "true"})
)));
}
fn test_tui_state() -> Arc<TuiState> {
Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
provider_form: std::sync::Mutex::new(None),
active_provider_input: std::sync::Mutex::new(None),
active_provider_select: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
autocomplete_selection: std::sync::Mutex::new(0),
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_claimed_keys: Mutex::new(Default::default()),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
})
}
#[test]
fn editor_change_source_separates_tui_writes_from_typing() {
use std::sync::atomic::Ordering;
let state = test_tui_state();
assert!(state.take_editor_change().is_none());
state.editor_text.push(rpi_extensions::EditorTextEdit {
text: "你好".to_string(),
mode: rpi_extensions::EditorTextMode::Replace,
auto_send_ms: Some(2000),
});
assert!(state.drain_editor_text(), "the queued draft must land");
assert!(
state.programmatic_editor_write.load(Ordering::SeqCst),
"a mailbox-applied edit must be marked programmatic"
);
let payload = state
.take_editor_change()
.expect("draft applied ⇒ a change");
assert_eq!(payload["source"], serde_json::json!("extension"));
assert_eq!(payload["chars"], serde_json::json!(2));
assert_eq!(payload["empty"], serde_json::json!(false));
assert!(state.take_editor_change().is_none());
state.editor.set_text("你好啊");
let payload = state.take_editor_change().expect("typing ⇒ a change");
assert_eq!(payload["source"], serde_json::json!("user"));
assert_eq!(payload["chars"], serde_json::json!(3));
}
#[test]
fn programmatic_mark_is_consumed_even_with_no_subscriber() {
use std::sync::atomic::Ordering;
let state = test_tui_state();
assert!(
state
.extension_session
.lock()
.unwrap()
.snapshot_arc()
.is_none(),
"this test relies on nobody subscribing"
);
state.editor_text.push(rpi_extensions::EditorTextEdit {
text: "draft".to_string(),
mode: rpi_extensions::EditorTextMode::Replace,
auto_send_ms: None,
});
state.drain_editor_text();
state.sync_editor_change();
assert!(!state.programmatic_editor_write.load(Ordering::SeqCst));
state.editor.set_text("draft +");
let payload = state.take_editor_change().expect("typing ⇒ a change");
assert_eq!(payload["source"], serde_json::json!("user"));
}
#[test]
fn submit_clear_is_attributed_to_the_host_not_the_user() {
let state = test_tui_state();
state.editor.set_text("/voice auto");
assert_eq!(
state.take_editor_change().unwrap()["source"],
serde_json::json!("user")
);
state.clear_editor_programmatically();
let payload = state.take_editor_change().expect("clearing is a change");
assert_eq!(payload["empty"], serde_json::json!(true));
assert_eq!(
payload["source"],
serde_json::json!("extension"),
"a submit-clear must not be reported as the user typing"
);
}
#[test]
fn user_initiated_clear_stays_a_user_change() {
let state = test_tui_state();
state.editor.set_text("draft");
assert_eq!(
state.take_editor_change().unwrap()["source"],
serde_json::json!("user")
);
state.editor.clear();
let payload = state.take_editor_change().expect("clearing is a change");
assert_eq!(payload["source"], serde_json::json!("user"));
}
#[test]
fn live_panel_repaints_while_a_tool_or_bash_is_running() {
let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
assert!(!transcript_has_live_panel(false, &empty));
let mut tools = HashMap::new();
let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
tool.set_running();
tools.insert("tc1".to_string(), tool);
assert!(transcript_has_live_panel(false, &tools));
tools.get("tc1").unwrap().set_result("done", false);
assert!(!transcript_has_live_panel(false, &tools));
assert!(transcript_has_live_panel(true, &tools));
}
#[test]
fn dequeue_merge_preserves_a_typed_draft() {
assert_eq!(merge_queued_into_draft("queued", ""), "queued");
assert_eq!(merge_queued_into_draft("queued", " "), "queued");
assert_eq!(
merge_queued_into_draft("queued", "typed"),
"queued\n\ntyped"
);
}
#[test]
fn dequeue_places_the_caret_at_the_end_of_the_last_line_in_bytes() {
let editor = Arc::new(Editor::simple());
let text = "排队\n第二行";
set_editor_text_caret_at_end(&editor, text);
assert_eq!(editor.get_text(), text);
assert_eq!(
editor.cursor_position(),
(1, "第二行".len()),
"caret must sit at the end of the last line, in bytes"
);
}
#[test]
fn settings_menu_covers_the_wired_settings_surface() {
let state = test_tui_state();
let settings = crate::settings::Settings::default();
let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
assert_eq!(
keys,
[
"theme",
"model",
"thinking",
"scoped-models",
"hide-thinking",
"show-images",
"cache-miss-notices",
"quiet-startup",
"collapse-changelog",
"terminal-progress",
"fullscreen-copy-on-select",
"double-escape-action",
"editor-padding",
"autocomplete-max-items",
"http-idle-timeout",
]
);
for item in items.iter().filter(|item| item.has_submenu) {
assert!(
item.values.is_empty(),
"{} should be submenu-only",
item.key
);
}
for item in items.iter().filter(|item| !item.has_submenu) {
assert!(!item.values.is_empty(), "{} has no values", item.key);
assert!(
item.values.iter().any(|value| value == &item.value),
"{} current value {} not in {:?}",
item.key,
item.value,
item.values
);
}
let value = |key: &str| {
items
.iter()
.find(|item| item.key == key)
.unwrap()
.value
.as_str()
};
assert_eq!(value("model"), "claude-sonnet-5");
assert_eq!(value("cache-miss-notices"), "false");
assert_eq!(value("terminal-progress"), "true");
assert_eq!(value("collapse-changelog"), "false");
}
#[test]
fn apply_setting_change_updates_settings_and_live_state() {
let state = test_tui_state();
let mut settings = crate::settings::Settings::default();
let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
assert!(state.hide_thinking());
assert_eq!(settings.hide_thinking_block, Some(true));
assert!(note.expect("user-visible note").contains("hidden"));
apply_setting_change(&state, "show-images", "false", &mut settings);
assert!(!*state.show_images.lock().unwrap());
assert_eq!(settings.show_images, Some(false));
apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
assert!(*state.cache_miss_notices.lock().unwrap());
assert_eq!(settings.show_cache_miss_notices, Some(true));
assert_eq!(
apply_setting_change(&state, "editor-padding", "3", &mut settings),
None
);
assert_eq!(settings.editor_padding_x, Some(3));
apply_setting_change(&state, "double-escape-action", "none", &mut settings);
assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
assert_eq!(settings.autocomplete_max_visible, Some(20));
apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
assert_eq!(settings.http_idle_timeout_ms(), Some(0));
apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
assert_eq!(
apply_setting_change(&state, "nope", "x", &mut settings),
None
);
}
#[test]
fn add_user_message_renders_a_visible_user_bubble() {
let chat = Arc::new(Container::new());
add_user_message(&chat, "hello from the user");
let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
let plain = strip_ansi(&rendered.join("\n"));
assert!(
plain.contains("hello from the user"),
"user bubble must show the prompt text; got: {plain:?}"
);
}
#[test]
fn verbose_overrides_quiet_startup_listing() {
assert!(should_show_startup_listing(false, false));
assert!(should_show_startup_listing(true, false));
assert!(should_show_startup_listing(true, true));
assert!(!should_show_startup_listing(false, true));
}
#[test]
fn resume_command_omits_the_default_session_dir() {
let cwd = std::path::Path::new("/proj/demo");
assert_eq!(format_resume_command("", cwd, None), None);
assert_eq!(
format_resume_command("abc-123", cwd, None).as_deref(),
Some("rpi --session abc-123")
);
let default = crate::session::default_session_dir(cwd);
assert_eq!(
format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
Some("rpi --session abc-123")
);
let custom = std::path::Path::new("/tmp/my sessions");
assert_eq!(
format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
);
}
#[test]
fn quote_shell_arg_quotes_only_when_needed() {
assert_eq!(quote_shell_arg("plain"), "plain");
assert_eq!(quote_shell_arg("with space"), "\"with space\"");
assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
}
#[test]
fn trust_command_uses_the_session_cwd_after_process_chdir() {
const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
const TEST_NAME: &str =
"interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
if std::env::var_os(CHILD_ENV).is_some() {
let session_cwd = std::path::PathBuf::from(
std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
);
let changed_cwd = std::env::current_dir().unwrap();
set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
assert_eq!(
crate::config::project_trust_decision(&session_cwd).unwrap(),
Some(true)
);
assert_eq!(
crate::config::project_trust_decision(&changed_cwd).unwrap(),
None
);
return;
}
let tmp = tempfile::tempdir().unwrap();
let session_cwd = tmp.path().join("session-project");
let changed_cwd = tmp.path().join("extension-cwd");
let agent_dir = tmp.path().join("agent");
std::fs::create_dir_all(&session_cwd).unwrap();
std::fs::create_dir_all(&changed_cwd).unwrap();
std::fs::create_dir_all(&agent_dir).unwrap();
let status = std::process::Command::new(std::env::current_exe().unwrap())
.arg("--exact")
.arg(TEST_NAME)
.arg("--nocapture")
.env(CHILD_ENV, "1")
.env(SESSION_CWD_ENV, &session_cwd)
.env(crate::config::CONFIG_DIR_ENV, &agent_dir)
.current_dir(&changed_cwd)
.status()
.unwrap();
assert!(status.success(), "child test process failed: {status}");
}
#[test]
fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
let global = crate::settings::Settings {
editor_padding_x: Some(3),
autocomplete_max_visible: Some(6),
hide_thinking_block: Some(false),
quiet_startup: Some(true),
show_terminal_progress: Some(true),
..Default::default()
};
let rpi_project = crate::settings::Settings {
editor_padding_x: Some(0),
hide_thinking_block: Some(true),
quiet_startup: Some(false),
show_terminal_progress: Some(false),
..Default::default()
};
let pi_project = crate::settings::Settings {
editor_padding_x: Some(9),
autocomplete_max_visible: Some(12),
quiet_startup: Some(true),
..Default::default()
};
assert_eq!(
resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
TuiStartupSettings {
editor_padding_x: 0,
autocomplete_max_visible: 12,
hide_thinking: true,
quiet_startup: false,
show_terminal_progress: false,
show_images: true,
cache_miss_notices: false,
}
);
}
#[test]
fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
let global = crate::settings::Settings {
quiet_startup: Some(false),
..Default::default()
};
let rpi_project = crate::settings::Settings::default();
let pi_project = crate::settings::Settings {
quiet_startup: Some(true),
..Default::default()
};
let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
assert!(resolved.quiet_startup);
}
#[test]
fn tui_startup_settings_use_global_values_without_project_fields() {
let global = crate::settings::Settings {
editor_padding_x: Some(7),
autocomplete_max_visible: Some(8),
hide_thinking_block: Some(true),
quiet_startup: Some(true),
show_terminal_progress: Some(false),
show_images: Some(false),
show_cache_miss_notices: Some(true),
..Default::default()
};
assert_eq!(
resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
TuiStartupSettings {
editor_padding_x: 7,
autocomplete_max_visible: 8,
hide_thinking: true,
quiet_startup: true,
show_terminal_progress: false,
show_images: false,
cache_miss_notices: true,
}
);
}
#[test]
fn tui_startup_settings_read_native_nested_terminal_block() {
let global = crate::settings::Settings {
terminal: Some(crate::settings::TerminalSettings {
show_images: Some(false),
show_terminal_progress: Some(false),
clear_on_shrink: None,
}),
..Default::default()
};
let resolved =
resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
assert!(!resolved.show_images);
assert!(!resolved.show_terminal_progress);
}
#[test]
fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
let global = crate::settings::Settings {
show_images: Some(true),
terminal: Some(crate::settings::TerminalSettings {
show_images: Some(false),
show_terminal_progress: None,
clear_on_shrink: None,
}),
..Default::default()
};
let resolved =
resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
assert!(resolved.show_images);
}
#[test]
fn tui_startup_settings_ignore_untrusted_project_values() {
let global = crate::settings::Settings {
quiet_startup: Some(false),
show_terminal_progress: Some(true),
..Default::default()
};
let project = crate::settings::Settings {
quiet_startup: Some(true),
show_terminal_progress: Some(false),
..Default::default()
};
let resolved = resolve_tui_startup_settings(&global, &[project], false);
assert!(!resolved.quiet_startup);
assert!(resolved.show_terminal_progress);
}
#[test]
fn transcript_page_uses_viewport_with_overlap() {
assert_eq!(transcript_page_size(24), 20);
assert_eq!(transcript_page_size(4), 1);
assert_eq!(transcript_page_size(0), 1);
}
#[test]
fn parse_user_bash_distinguishes_single_and_double_bang() {
assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
assert_eq!(parse_user_bash("! echo hi "), Some(("echo hi", false)));
assert_eq!(parse_user_bash("!! echo hi"), Some(("echo hi", true)));
}
#[test]
fn parse_user_bash_ignores_bare_bang_and_plain_text() {
assert_eq!(parse_user_bash("!"), None);
assert_eq!(parse_user_bash("!!"), None);
assert_eq!(parse_user_bash("! "), None);
assert_eq!(parse_user_bash("hello"), None);
assert_eq!(parse_user_bash(""), None);
assert_eq!(parse_user_bash("/model"), None);
}
#[test]
fn user_bash_run_slot_guards_and_cancels() {
let state = test_tui_state();
assert!(!state.user_bash_running());
let cancel = state.begin_user_bash();
assert!(state.user_bash_running());
assert!(state.cancel_user_bash());
assert!(state.user_bash_running());
assert!(cancel.is_cancelled());
state.finish_user_bash();
assert!(!state.user_bash_running());
assert!(!state.cancel_user_bash());
}
#[test]
fn key_repeat_is_dispatched_but_release_is_not() {
assert!(should_dispatch_key(KeyEventKind::Press));
assert!(should_dispatch_key(KeyEventKind::Repeat));
assert!(!should_dispatch_key(KeyEventKind::Release));
}
#[test]
fn test_layout_renders_welcome_message() {
let chat = Arc::new(Container::new());
add_welcome_message(&chat);
let scroll = Arc::new(ScrollView::new(
chat.clone(),
ScrollViewOptions {
follow: FollowMode::End,
primary: true,
..Default::default()
},
));
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
..Default::default()
},
EditorStyle::default(),
Arc::new(rpi_tui::Keybindings::new()),
));
let dock = Arc::new(Container::new());
dock.add_child(editor);
let footer = Arc::new(FooterComponent::new());
let root = VStack::from_children(vec![
StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
StackChild::Entry(StackEntry::new(dock)),
StackChild::Entry(StackEntry::new(footer)),
]);
let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
let all: String = frame.lines.join("\n");
assert!(
all.contains("rpi"),
"Welcome message not found. Rendered: {}",
all
);
assert!(
all.contains("Type your message"),
"Help text not found. Rendered: {}",
all
);
}
#[test]
fn test_chat_container_has_welcome_content() {
let chat = Arc::new(Container::new());
add_welcome_message_with_capabilities(
&chat,
&["read".into(), "bash".into(), "web_fetch".into()],
&["rust-review".into(), "release".into()],
&[
"/project/.rpi/SYSTEM.md".into(),
"/project/AGENTS.md".into(),
],
);
let lines = chat.render(80);
let all: String = lines.join("\n");
let plain = strip_ansi(&all);
assert!(
plain.contains("rpi"),
"Welcome message not in chat container: {:?}",
lines
);
assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
assert!(
plain.contains("read · bash · web_fetch"),
"Tool names missing: {plain}"
);
assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
assert!(plain.contains("Context (2)"));
assert!(plain.contains("/project/.rpi/SYSTEM.md"));
assert!(plain.contains("/project/AGENTS.md"));
assert!(
plain.contains("rust-review · release"),
"Skill names missing: {plain}"
);
}
#[test]
fn reload_updates_context_header_and_context_panel_without_stale_paths() {
use rpi_harness::types::AgentHarnessResources;
let chat = Arc::new(Container::new());
let old = AgentHarnessResources {
context_files: vec!["/old/SYSTEM.md".into()],
..Default::default()
};
let listing = add_welcome_message_with_capabilities(&chat, &[], &[], &old.context_files);
let shared = Arc::new(Mutex::new(old));
let fresh = AgentHarnessResources {
context_files: vec!["/new/APPEND_SYSTEM.md".into(), "/new/CLAUDE.md".into()],
..Default::default()
};
update_resource_listing(&shared, Some(&listing), fresh.clone());
let header = strip_ansi(&chat.render(120).join("\n"));
assert!(header.contains("Context (2)"));
assert!(header.contains("/new/APPEND_SYSTEM.md"));
assert!(!header.contains("/old/SYSTEM.md"));
assert_eq!(*shared.lock().unwrap(), fresh);
let panel = Arc::new(Container::new());
show_context_panel(&panel, &shared.lock().unwrap());
let output = strip_ansi(&panel.render(120).join("\n"));
assert!(output.contains("/new/CLAUDE.md"));
assert!(!output.contains("/old/SYSTEM.md"));
update_resource_listing(&shared, Some(&listing), Default::default());
assert!(!strip_ansi(&chat.render(120).join("\n")).contains("Context"));
let panel = Arc::new(Container::new());
show_context_panel(&panel, &shared.lock().unwrap());
assert!(strip_ansi(&panel.render(120).join("\n")).contains("no context files loaded"));
}
#[test]
fn welcome_header_renders_the_brand_mark() {
let chat = Arc::new(Container::new());
add_welcome_message(&chat);
let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
let all = lines.join("\n");
assert!(
lines.iter().any(|l| l.contains("██ ██ ██")),
"brand mark missing: {all}"
);
assert!(
all.contains("rpi"),
"the mark must carry the product name: {all}"
);
assert!(
!all.contains('π'),
"the header must not use east-asian-ambiguous glyphs: {all}"
);
}
#[test]
fn update_notices_render_inside_the_transcript() {
let chat = Arc::new(Container::new());
let report = crate::updates::UpdateReport {
notices: vec![crate::updates::UpdateNotice {
name: "rpi".into(),
current: "0.1.10".into(),
latest: "0.1.11".into(),
command: "rpi update".into(),
}],
warnings: vec![crate::updates::UpdateWarning {
message: "The previously scheduled rpi update failed: access denied".into(),
command: "rpi update".into(),
}],
};
add_update_notices(&chat, &report);
assert_eq!(chat.child_count(), 1);
let plain = strip_ansi(&chat.render(80).join("\n"));
assert!(plain.contains("Update Failed"), "{plain}");
assert!(
plain.contains("rpi update failed: access denied"),
"{plain}"
);
assert!(plain.contains("Update Available"), "{plain}");
assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
assert!(plain.contains("rpi update"), "{plain}");
}
#[test]
fn empty_update_report_does_not_add_transcript_content() {
let chat = Arc::new(Container::new());
add_update_notices(&chat, &crate::updates::UpdateReport::default());
assert_eq!(chat.child_count(), 0);
}
#[test]
fn package_updates_render_without_an_rpi_update() {
let chat = Arc::new(Container::new());
let package_notice = crate::updates::UpdateNotice {
name: "rpi-im-message".into(),
current: "0.1.9".into(),
latest: "0.1.10".into(),
command: "rpi install rpi-im-message --force".into(),
};
for include_rpi in [false, true] {
chat.clear();
let mut report = crate::updates::UpdateReport {
notices: vec![package_notice.clone()],
warnings: Vec::new(),
};
if include_rpi {
report.notices.push(crate::updates::UpdateNotice {
name: "rpi".into(),
current: "0.3.20".into(),
latest: "0.3.21".into(),
command: "rpi update".into(),
});
}
add_update_notices(&chat, &report);
assert_eq!(chat.child_count(), 1);
let plain = strip_ansi(&chat.render(80).join("\n"));
assert!(plain.contains("Package Update Available"), "{plain}");
assert!(plain.contains("rpi-im-message 0.1.9 -> 0.1.10"), "{plain}");
assert!(plain.contains(&package_notice.command), "{plain}");
assert_eq!(plain.contains("rpi update"), include_rpi, "{plain}");
}
}
#[test]
fn welcome_capabilities_show_empty_state() {
let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
assert_eq!(plain, "Skills (0) none");
}
#[test]
fn completed_stream_reconciles_the_final_tail_before_detaching() {
let component = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
component.set_streaming(true);
component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
let current = Arc::new(Mutex::new(Some(component.clone())));
let cached = Mutex::new("partial response".to_string());
let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
final_message.content = vec![Content::text(
"partial response with the previously missing final tail",
)];
final_message.stop_reason = rpi_ai::types::StopReason::Stop;
reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
assert!(current.lock().unwrap().is_none());
assert_eq!(
cached.lock().unwrap().as_str(),
"partial response with the previously missing final tail"
);
let rendered = strip_ansi(&component.render(100).join("\n"));
assert!(
rendered.contains("previously missing final tail"),
"{rendered}"
);
}
#[test]
fn tab_accept_suggestion_reflects_in_next_render() {
use rpi_tui::render_layout_frame;
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
..Default::default()
},
EditorStyle::default(),
Arc::new(rpi_tui::Keybindings::new()),
));
editor.set_focused(true);
let editor_container = Arc::new(Container::new());
editor_container.add_child(editor.clone());
let autocomplete_container = Arc::new(Container::new());
let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
let dock = Arc::new(VStack::from_children(vec![
StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
StackChild::Entry(
StackEntry::new(editor_container.clone())
.shrink(0)
.min_size(3),
),
StackChild::Entry(StackEntry::new(footer)),
]));
let manager = AutocompleteManager::new();
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(
SlashCommandAutocompleteProvider::with_default_commands(),
));
combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
manager.set_provider(Arc::new(combined));
editor.insert("/mo");
assert_eq!(editor.cursor_position(), (0, 3));
let frame_before = render_layout_frame(dock.clone(), 80, 10);
assert!(
frame_before.lines.iter().any(|l| l.contains("/mo")),
"precondition: editor shows the typed prefix. Frame rows:\n{}",
frame_before
.lines
.iter()
.map(|l| format!(" [{l}]"))
.collect::<Vec<_>>()
.join("\n")
);
let text = editor.get_text();
let cursor = editor_cursor_offset(&editor, &text);
let sugg = manager
.get_suggestions(&text, cursor)
.expect("slash suggestions for /mo");
let top = sugg.items.first().expect("at least one suggestion");
let start = sugg.start.min(text.len());
let end = sugg.end.min(text.len());
let mut replaced = String::new();
replaced.push_str(&text[..start]);
replaced.push_str(&top.text);
replaced.push_str(&text[end..]);
if top.insert_space && !replaced.ends_with('/') {
replaced.push(' ');
}
let new_cursor = start + top.text.len();
editor.set_text(&replaced);
set_editor_cursor_offset(&editor, &replaced, new_cursor);
autocomplete_container.clear();
assert_eq!(editor.get_text(), "/model");
let frame_after = render_layout_frame(dock, 80, 10);
let all: String = frame_after.lines.join("\n");
assert!(
all.contains("/model"),
"completed text missing from next render. Got:\n{all}"
);
let editor_line = frame_after
.lines
.iter()
.find(|l| l.contains("/model"))
.expect("editor row with completed text");
assert!(
editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
"caret must follow the full completed text. Got: {editor_line:?}"
);
}
#[test]
fn multiline_autocomplete_preserves_row_and_column() {
let editor = Arc::new(Editor::simple());
editor.set_text("first\n/mo");
editor.set_cursor(1, 3);
let text = editor.get_text();
assert_eq!(editor_cursor_offset(&editor, &text), 9);
let replaced = "first\n/model";
editor.set_text(replaced);
set_editor_cursor_offset(&editor, replaced, 12);
assert_eq!(editor.cursor_position(), (1, 6));
}
#[test]
fn test_slash_command_dispatch() {
let registry = build_builtin_registry();
let resolves_to = |token: &str, canonical: &str| {
let found = registry.find(token).expect("{token} should resolve");
assert_eq!(
found.name(),
canonical,
"{token} resolved to {} (expected {canonical})",
found.name()
);
};
resolves_to("/help", "/help");
resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
resolves_to("/hotkeys", "/hotkeys");
resolves_to("/model", "/model");
resolves_to("/m", "/model"); resolves_to("/provider", "/provider");
resolves_to("/theme", "/theme");
resolves_to("/session", "/session");
resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
resolves_to("/copy", "/copy");
resolves_to("/thinking", "/thinking");
resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
resolves_to("/images", "/images");
resolves_to("/context", "/context");
resolves_to("/settings", "/settings");
resolves_to("/name", "/name");
resolves_to("/export", "/export");
assert!(registry.find("/nope").is_none(), "/nope should be unknown");
}
#[test]
fn test_registry_visible_entries_cover_dispatch() {
let registry = build_builtin_registry();
let names: Vec<String> = registry
.visible_entries()
.iter()
.map(|c| c.name.clone())
.collect();
for recognized in [
"/help",
"/clear",
"/new",
"/exit",
"/quit",
"/version",
"/changelog",
"/model",
"/provider",
"/session",
"/theme",
"/compact",
"/copy",
"/hotkeys",
"/tools",
"/images",
"/thinking",
"/usage",
] {
assert!(
names.contains(&recognized.to_string()),
"{recognized} missing from autocomplete list"
);
}
for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
assert!(
!names.contains(&hidden.to_string()),
"{hidden} should be hidden from autocomplete"
);
}
}
#[test]
fn test_agent_event_mapping_creates_assistant_and_tool() {
use rpi_ai::types::{
StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType, ToolCall,
ToolCallType, Usage,
};
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
provider_form: std::sync::Mutex::new(None),
active_provider_input: std::sync::Mutex::new(None),
active_provider_select: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
autocomplete_selection: std::sync::Mutex::new(0),
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_claimed_keys: Mutex::new(Default::default()),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
let assistant = AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![
Content::Thinking(ThinkingContent {
kind: ThinkingContentType,
thinking: "Reasoning about the reply.".into(),
thinking_signature: None,
redacted: false,
}),
Content::Text(TextContent {
kind: TextContentType,
text: "Hello.".into(),
text_signature: None,
}),
Content::ToolCall(ToolCall {
kind: ToolCallType,
id: "tc1".into(),
name: "bash".into(),
arguments: serde_json::json!({"command": "echo hi"}),
thought_signature: None,
namespace: None,
}),
],
api: rpi_ai::Api::AnthropicMessages,
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
};
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
comp.set_streaming(true);
comp.update_blocks(&assistant_blocks(&assistant));
let chat = Arc::new(Container::new());
chat.add_child(comp.clone());
*state.current_assistant.lock().unwrap() = Some(comp);
for c in &assistant.content {
if let Content::ToolCall(tc) = c {
let mut tools = state.tool_components.lock().unwrap();
if !tools.contains_key(&tc.id) {
let tc_comp = Arc::new(ToolExecutionComponent::new(
&tc.name,
&tc.arguments.to_string(),
));
tc_comp.set_running();
chat.add_child(tc_comp.clone());
tools.insert(tc.id.clone(), tc_comp);
}
}
}
let rendered = chat.render(80);
let joined: String = rendered.join("\n");
assert!(
joined.contains("Hello."),
"assistant text not rendered: {joined}"
);
assert!(
joined.contains("Reasoning about the reply."),
"thinking block not rendered: {joined}"
);
assert_eq!(state.tool_components.lock().unwrap().len(), 1);
assert!(state.current_assistant.lock().unwrap().is_some());
let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
ended.set_result("hi", false);
assert!(state.tool_components.lock().unwrap().is_empty());
assert!(state.try_start_working());
assert!(
!state.try_start_working(),
"a second submit must be rejected"
);
state.set_status(RunStatus::Idle);
state.set_status(RunStatus::Working);
assert!(state.editor.working().is_some());
assert_eq!(state.status_container.child_count(), 0);
assert!(state.footer.get_status().is_empty());
state.show_retry(3, 10, 8_000);
let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
assert!(retry_status.contains("Retrying (3/10)"));
state.set_status(RunStatus::Working);
{
let mut bash = state.bash_components.lock().unwrap();
bash.insert(
"bash-1".into(),
Arc::new(BashExecutionComponent::new("one")),
);
bash.insert(
"bash-2".into(),
Arc::new(BashExecutionComponent::new("two")),
);
}
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
state.bash_components.lock().unwrap().remove("bash-1");
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
state.bash_components.lock().unwrap().remove("bash-2");
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
assert!(state.editor.working().is_some());
state.set_status(RunStatus::Aborting);
assert_eq!(state.status_container.child_count(), 0);
assert!(!state.loader.is_running());
}
#[test]
fn fresh_launch_does_not_restore_old_history() {
let fresh = Args::default();
assert!(!launch_restores_history(&fresh));
let continued = Args {
continue_session: true,
..Args::default()
};
assert!(launch_restores_history(&continued));
let selected = Args {
session: Some("session-id".into()),
..Args::default()
};
assert!(launch_restores_history(&selected));
}
#[test]
fn context_badge_ignores_aborted_and_error_turns() {
use rpi_ai::types::{Api, StopReason, Usage};
let footer = Arc::new(FooterComponent::new());
footer.set_context_window(100_000);
let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
ok.stop_reason = StopReason::Stop;
ok.usage = Usage {
input: 25_000,
..Usage::zero()
};
record_context_usage(&footer, &ok);
let stats = strip_ansi(&footer.render(120).join("\n"));
assert!(stats.contains("25.0%/100k"), "stats: {stats}");
let mut aborted = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
aborted.stop_reason = StopReason::Aborted;
aborted.usage = Usage {
input: 90_000,
..Usage::zero()
};
record_context_usage(&footer, &aborted);
let stats = strip_ansi(&footer.render(120).join("\n"));
assert!(stats.contains("25.0%/100k"), "stats: {stats}");
let mut errored = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
errored.stop_reason = StopReason::Error;
errored.usage = Usage {
input: 90_000,
..Usage::zero()
};
record_context_usage(&footer, &errored);
let stats = strip_ansi(&footer.render(120).join("\n"));
assert!(stats.contains("25.0%/100k"), "stats: {stats}");
}
#[test]
fn test_short_model_name() {
assert_eq!(
short_model_name("anthropic:claude-sonnet-5"),
"claude-sonnet-5"
);
assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
}
#[test]
fn model_selector_items_are_deduplicated_and_provider_qualified() {
use rpi_ai::{Api, Model};
let mut gateway = Model::new(
"gpt-5.6-sol",
"GPT 5.6 Sol",
Api::OpenaiCompletions,
"routeryo-copy",
"https://gateway.example.com",
);
let duplicate = gateway.clone();
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
gateway.headers = Some(std::collections::BTreeMap::from([(
"authorization".into(),
"Bearer test".into(),
)]));
let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
assert_eq!(items.len(), 2);
assert_eq!(items[0].value, "routeryo-copy/gpt-5.6-sol");
assert_eq!(items[0].label, "GPT 5.6 Sol");
assert_eq!(
items[0].description.as_deref(),
Some("routeryo-copy/gpt-5.6-sol (current)")
);
assert_eq!(
items[1].description.as_deref(),
Some("anthropic/claude-sonnet-5")
);
}
#[test]
fn model_selector_search_text_covers_provider_name_and_qualified_id() {
use rpi_ai::{Api, Model};
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
let items = model_selector_items(&[anthropic], "");
let search = items[0].search_key();
assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
assert!(search.contains("Claude Sonnet 5"), "{search}");
use rpi_tui::fuzzy_filter;
for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
let matched = fuzzy_filter(&items, query, |item| item.search_key());
assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
}
}
#[test]
fn model_selector_match_accepts_bare_and_qualified_ids() {
use rpi_ai::{Api, Model};
let gateway = Model::new(
"gpt-5.6-sol",
"GPT 5.6 Sol",
Api::OpenaiCompletions,
"routeryo-copy",
"https://gateway.example.com",
);
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
let catalog = [gateway, anthropic];
assert_eq!(
find_model_selector_match(&catalog, "gpt-5.6-sol")
.unwrap()
.provider,
"routeryo-copy"
);
assert_eq!(
find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
.unwrap()
.id,
"gpt-5.6-sol"
);
assert_eq!(
find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
.unwrap()
.id,
"claude-sonnet-5"
);
assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
}
#[test]
fn provider_input_canonicalization_exact_then_case_insensitive_unique() {
use rpi_ai::{Api, Model};
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
let gateway = Model::new(
"gpt-5.6-sol",
"GPT 5.6 Sol",
Api::OpenaiCompletions,
"MyGateway",
"https://gateway.example.com",
);
let catalog = [anthropic, gateway];
assert_eq!(
canonicalize_provider_input("anthropic", &catalog).unwrap(),
"anthropic"
);
assert_eq!(
canonicalize_provider_input("ANTHROPIC", &catalog).unwrap(),
"anthropic"
);
assert_eq!(
canonicalize_provider_input("mygateway", &catalog).unwrap(),
"MyGateway"
);
assert!(canonicalize_provider_input("nope", &catalog).is_err());
}
#[test]
fn provider_form_saves_provider_to_models_json() {
with_isolated_config_dir(|| {
let form = ProviderFormState {
step: ProviderFormStep::ModelName,
id: "openrouter".into(),
api: "openai-completions".into(),
base_url: "https://openrouter.ai/api/v1".into(),
api_key: "$OPENROUTER_API_KEY".into(),
model_id: "claude-sonnet".into(),
model_name: "Claude Sonnet".into(),
};
let note = save_provider_from_form(&form).unwrap();
assert!(note.contains("openrouter"), "{note}");
let cfg = crate::config::load_models_config().unwrap();
let provider = cfg.providers.get("openrouter").expect("provider written");
assert_eq!(provider.api.as_deref(), Some("openai-completions"));
assert_eq!(
provider.base_url.as_deref(),
Some("https://openrouter.ai/api/v1")
);
assert_eq!(provider.api_key.as_deref(), Some("$OPENROUTER_API_KEY"));
assert_eq!(provider.models.len(), 1);
assert_eq!(provider.models[0].id, "claude-sonnet");
assert_eq!(provider.models[0].name.as_deref(), Some("Claude Sonnet"));
assert!(provider_id_exists("openrouter"));
assert!(!provider_id_exists("nope"));
});
}
#[test]
fn provider_url_validation_requires_http_scheme() {
assert!(is_valid_provider_url("https://api.example.com/v1"));
assert!(is_valid_provider_url("http://localhost:8080"));
assert!(!is_valid_provider_url("api.example.com/v1"));
assert!(!is_valid_provider_url(""));
}
#[test]
fn remove_provider_deletes_from_models_json() {
with_isolated_config_dir(|| {
let form = ProviderFormState {
step: ProviderFormStep::ModelName,
id: "openrouter".into(),
api: "openai-completions".into(),
base_url: "https://openrouter.ai/api/v1".into(),
api_key: "$OPENROUTER_API_KEY".into(),
model_id: "claude-sonnet".into(),
model_name: String::new(),
};
save_provider_from_form(&form).unwrap();
assert!(provider_id_exists("openrouter"));
let note = remove_provider("openrouter").unwrap();
assert!(note.contains("openrouter"), "{note}");
assert!(!provider_id_exists("openrouter"));
assert!(remove_provider("anthropic").is_err());
assert!(remove_provider("nope").is_err());
});
}
fn with_isolated_config_dir<T>(body: impl FnOnce() -> T) -> T {
let _guard = crate::config::test_support::env_lock().lock().unwrap();
let prev = std::env::var_os(crate::config::CONFIG_DIR_ENV);
let tmp = tempfile::tempdir().unwrap();
std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
let result = body();
match prev {
Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
}
result
}
#[test]
fn provider_selector_items_cover_builtin_providers() {
with_isolated_config_dir(|| {
let items = provider_selector_items();
let ids: Vec<&str> = items.iter().map(|item| item.value.as_str()).collect();
assert!(
ids.contains(&"anthropic"),
"built-in anthropic provider missing: {ids:?}"
);
assert!(
ids.contains(&"openai"),
"built-in openai provider missing: {ids:?}"
);
assert!(items.iter().all(|item| {
item.description
.as_deref()
.is_some_and(|d| d.contains("model(s)") && d.contains("内置") && d.contains("key"))
}));
});
}
#[test]
fn set_default_provider_persists_provider_and_a_matching_model() {
with_isolated_config_dir(|| {
let note = set_default_provider("openai").unwrap();
assert!(
note.contains("openai"),
"note should name the provider: {note}"
);
let settings = crate::settings::load_settings().unwrap();
assert_eq!(settings.default_provider.as_deref(), Some("openai"));
assert!(
settings.default_model.is_some(),
"a matching default model must be saved so the choice resolves on restart"
);
assert!(set_default_provider("does-not-exist").is_err());
});
}
#[test]
fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
let failed = AssistantMessage {
role: AssistantRole,
content: Vec::new(),
api: rpi_ai::Api::AnthropicMessages,
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: StopReason::Error,
deferred: None,
error_message: Some("upstream returned 401".into()),
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
};
assert_eq!(
assistant_error_text(&failed).as_deref(),
Some("upstream returned 401")
);
let mut no_detail = failed;
no_detail.error_message = Some(" ".into());
assert_eq!(
assistant_error_text(&no_detail).as_deref(),
Some("Provider request failed.")
);
let mut aborted = no_detail;
aborted.stop_reason = StopReason::Aborted;
aborted.error_message = Some("abort error: Request aborted".into());
assert_eq!(
assistant_error_text(&aborted).as_deref(),
Some("abort error: Request aborted")
);
aborted.error_message = None;
assert_eq!(
assistant_error_text(&aborted).as_deref(),
Some("Request aborted.")
);
}
#[test]
fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
assert_eq!(
sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
"405\nMethod Not Allowed"
);
assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
assert_eq!(sanitize_error_message(" "), "Provider request failed.");
let long = "x".repeat(20_000);
let cleaned = sanitize_error_message(&long);
assert!(cleaned.chars().count() <= 16 * 1024 + 1);
assert!(cleaned.ends_with('…'));
}
#[test]
fn test_cycle_next_model_wraps_around() {
use rpi_ai::{Api, Model};
let mk = |id: &str| {
Model::new(
id,
id,
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
)
};
let catalog = [mk("a"), mk("b"), mk("c")];
assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
let empty: Vec<Model> = vec![];
assert!(cycle_next_model(&empty, "a").is_none());
}
#[test]
fn test_cycle_prev_model_wraps_around() {
use rpi_ai::{Api, Model};
let mk = |id: &str| {
Model::new(
id,
id,
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
)
};
let catalog = [mk("a"), mk("b"), mk("c")];
assert_eq!(cycle_prev_model(&catalog, "b").unwrap().id, "a");
assert_eq!(cycle_prev_model(&catalog, "a").unwrap().id, "c");
assert_eq!(cycle_prev_model(&catalog, "zzz").unwrap().id, "a");
let empty: Vec<Model> = vec![];
assert!(cycle_prev_model(&empty, "a").is_none());
}
#[test]
fn test_autocomplete_slash_suggestions_render() {
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
provider_form: std::sync::Mutex::new(None),
active_provider_input: std::sync::Mutex::new(None),
active_provider_select: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
autocomplete_selection: std::sync::Mutex::new(0),
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_claimed_keys: Mutex::new(Default::default()),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
build_builtin_registry().visible_entries(),
)));
state.autocomplete.set_provider(Arc::new(combined));
}
let editor = Arc::new(Editor::simple());
editor.set_text("/he");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
let lines = state.autocomplete_container.render(80);
let joined: String = lines.join("\n");
assert!(
joined.contains("/help"),
"slash suggestions not rendered: {joined}"
);
editor.set_text("hello");
editor.set_cursor(0, 5);
refresh_autocomplete(&state, &editor);
assert!(state.autocomplete_container.render(80).is_empty());
}
#[test]
fn test_select_list_swap_restores_editor() {
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
provider_form: std::sync::Mutex::new(None),
active_provider_input: std::sync::Mutex::new(None),
active_provider_select: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
autocomplete_selection: std::sync::Mutex::new(0),
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_claimed_keys: Mutex::new(Default::default()),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
let editor_container = Arc::new(Container::new());
let editor = Arc::new(Editor::simple());
editor_container.add_child(editor.clone());
assert!(!state.selector_open());
let tui_terminal = Box::new(ProcessTerminal::new());
let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
let list = Arc::new(SelectList::new(
vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
5,
));
open_selector(
&state,
&editor_container,
&editor,
&tui,
list,
SelectorKind::Theme,
);
assert!(state.selector_open());
assert_eq!(editor_container.child_count(), 1);
close_selector(&state, &editor_container, &editor, &tui);
assert!(!state.selector_open());
assert_eq!(editor_container.child_count(), 1);
}
#[test]
fn test_message_history_browse_restores_draft() {
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
provider_form: std::sync::Mutex::new(None),
active_provider_input: std::sync::Mutex::new(None),
active_provider_select: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
autocomplete_selection: std::sync::Mutex::new(0),
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_claimed_keys: Mutex::new(Default::default()),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
let editor = Arc::new(Editor::simple());
push_history(&state, "first message");
push_history(&state, "second message");
push_history(&state, "second message");
push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
assert_eq!(state.history.lock().unwrap()[0], "second message");
editor.set_text("half-typed");
editor.set_cursor(0, 11);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "second message");
assert_eq!(*state.history_index.lock().unwrap(), 0);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "first message");
assert_eq!(*state.history_index.lock().unwrap(), 1);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "first message");
navigate_history(&state, &editor, 1);
assert_eq!(editor.get_text(), "second message");
navigate_history(&state, &editor, 1);
assert_eq!(editor.get_text(), "half-typed");
assert_eq!(*state.history_index.lock().unwrap(), -1);
}
#[test]
fn test_accept_top_suggestion_replaces_prefix() {
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
provider_form: std::sync::Mutex::new(None),
active_provider_input: std::sync::Mutex::new(None),
active_provider_select: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
autocomplete_selection: std::sync::Mutex::new(0),
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_claimed_keys: Mutex::new(Default::default()),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
build_builtin_registry().visible_entries(),
)));
state.autocomplete.set_provider(Arc::new(combined));
}
let editor = Arc::new(Editor::simple());
editor.set_text("/he");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
let accepted = accept_top_suggestion(&state, &editor);
assert!(accepted, "should accept the top suggestion");
let text = editor.get_text();
assert!(
text.starts_with("/help"),
"editor text should start with /help, got {text}"
);
}
fn autocomplete_test_state(max_visible: usize) -> Arc<TuiState> {
Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
provider_form: std::sync::Mutex::new(None),
active_provider_input: std::sync::Mutex::new(None),
active_provider_select: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: max_visible,
autocomplete_selection: std::sync::Mutex::new(0),
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_claimed_keys: Mutex::new(Default::default()),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
})
}
#[test]
fn autocomplete_navigation_reaches_beyond_visible_window() {
let state = autocomplete_test_state(5);
let commands: Vec<rpi_tui::SlashCommand> = (0..7)
.map(|i| rpi_tui::SlashCommand {
name: format!("/choice{i}"),
description: format!("choice {i}"),
})
.collect();
state
.autocomplete
.set_provider(Arc::new(SlashCommandAutocompleteProvider::new(commands)));
let editor = Arc::new(Editor::simple());
editor.set_text("/");
editor.set_cursor(0, 1);
refresh_autocomplete(&state, &editor);
for _ in 0..5 {
assert!(navigate_autocomplete(&state, &editor, 1));
}
assert_eq!(*state.autocomplete_selection.lock().unwrap(), 5);
assert!(accept_top_suggestion(&state, &editor));
assert!(
editor.get_text().starts_with("/choice5"),
"Tab should accept the highlighted 6th item, got {}",
editor.get_text()
);
}
#[test]
fn autocomplete_navigation_does_not_swallow_plain_draft_arrows() {
let state = autocomplete_test_state(5);
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("docs")).unwrap();
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
tmp.path().to_path_buf(),
)));
state.autocomplete.set_provider(Arc::new(combined));
let editor = Arc::new(Editor::simple());
editor.set_text("first line\nplease check docs");
editor.set_cursor(1, 18);
assert!(!navigate_autocomplete(&state, &editor, -1));
}
#[test]
fn autocomplete_navigation_handles_leading_at_file_completion() {
let state = autocomplete_test_state(5);
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("docs_a")).unwrap();
std::fs::create_dir_all(tmp.path().join("docs_b")).unwrap();
state
.autocomplete
.set_provider(Arc::new(FilePathAutocompleteProvider::with_root(
tmp.path().to_path_buf(),
)));
let editor = Arc::new(Editor::simple());
editor.set_text("@do");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
assert!(navigate_autocomplete(&state, &editor, 1));
assert_eq!(*state.autocomplete_selection.lock().unwrap(), 1);
assert!(accept_top_suggestion(&state, &editor));
assert!(
editor.get_text().starts_with("@docs_"),
"@ plus the picked candidate must be inserted, got {}",
editor.get_text()
);
}
#[test]
fn autocomplete_navigation_handles_at_file_after_chinese_draft() {
let state = autocomplete_test_state(5);
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("docs_a")).unwrap();
std::fs::create_dir_all(tmp.path().join("docs_b")).unwrap();
state
.autocomplete
.set_provider(Arc::new(FilePathAutocompleteProvider::with_root(
tmp.path().to_path_buf(),
)));
let editor = Arc::new(Editor::simple());
editor.set_text("请查看 @do");
editor.set_cursor(0, 13);
refresh_autocomplete(&state, &editor);
assert!(navigate_autocomplete(&state, &editor, 1));
assert!(accept_top_suggestion(&state, &editor));
assert!(
editor.get_text().starts_with("请查看 @docs_"),
"draft, @, and the picked candidate must be preserved, got {}",
editor.get_text()
);
}
#[test]
fn configured_key_parser_supports_native_notation() {
let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
assert_eq!(combo.code, KeyCode::Char('g'));
assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
assert_eq!(combo.code, KeyCode::BackTab);
}
#[test]
fn double_escape_trigger_has_half_second_window() {
let now = std::time::Instant::now();
assert!(!double_escape_trigger(None, now));
assert!(double_escape_trigger(
Some(now - std::time::Duration::from_millis(500)),
now
));
assert!(!double_escape_trigger(
Some(now - std::time::Duration::from_millis(501)),
now
));
}
#[test]
fn ctrl_c_clears_first_and_only_exits_inside_the_window() {
let now = std::time::Instant::now();
assert_eq!(ctrl_c_action(None, now), CtrlCAction::Clear);
assert_eq!(
ctrl_c_action(Some(now - std::time::Duration::from_millis(500)), now),
CtrlCAction::Exit
);
assert_eq!(
ctrl_c_action(Some(now - std::time::Duration::from_millis(501)), now),
CtrlCAction::Clear
);
}
#[test]
fn pasted_multi_line_block_becomes_one_prompt_not_many() {
use std::sync::atomic::{AtomicUsize, Ordering};
let editor = rpi_tui::Editor::simple();
let submits = Arc::new(AtomicUsize::new(0));
let submits_for_cb = submits.clone();
editor.on_submit(Arc::new(move |_text: &str| {
submits_for_cb.fetch_add(1, Ordering::SeqCst);
}));
let now = std::time::Instant::now();
let mut last_text_key_at: Option<std::time::Instant> = None;
let block = ["l1", "l2", "l3"];
for (index, line) in block.iter().enumerate() {
for ch in line.chars() {
editor.insert(&ch.to_string());
last_text_key_at = Some(now);
}
if index + 1 < block.len() {
let more_queued = true;
assert!(
enter_is_paste_burst(last_text_key_at, now, more_queued),
"pasted newline {index} must not submit"
);
editor.insert("\n");
last_text_key_at = Some(now);
}
}
assert_eq!(editor.get_text(), "l1\nl2\nl3");
assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
editor.handle_key(crossterm::event::KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
));
assert_eq!(
submits.load(Ordering::SeqCst),
1,
"typed Enter submits once"
);
}
#[test]
fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
let editor = rpi_tui::Editor::simple();
editor.insert("alpha\r\nbeta\r\n");
assert_eq!(editor.get_text(), "alpha\nbeta\n");
assert!(!editor.get_text().contains('\r'));
}
#[test]
fn pasted_enter_is_not_a_submit() {
let now = std::time::Instant::now();
assert!(enter_is_paste_burst(None, now, true));
assert!(enter_is_paste_burst(
Some(now - std::time::Duration::from_millis(1)),
now,
false
));
assert!(enter_is_paste_burst(
Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
now,
false
));
assert!(!enter_is_paste_burst(None, now, false));
assert!(!enter_is_paste_burst(
Some(now - PASTE_BURST_GAP),
now,
false
));
assert!(!enter_is_paste_burst(
Some(now - std::time::Duration::from_millis(120)),
now,
false
));
}
#[test]
fn paste_probe_ignores_the_enter_keys_own_release() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
let release =
KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
let mut queued = vec![Event::Key(release)].into_iter();
let (more, stashed) = probe_paste_input(None, || queued.next());
assert!(!more, "a key release alone is not queued paste input");
assert!(stashed.is_none());
let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
let (more, stashed) = probe_paste_input(None, || queued.next());
assert!(more, "a queued key press is paste continuation");
assert!(matches!(stashed, Some(Event::Key(_))));
let (more, stashed) = probe_paste_input(None, || None);
assert!(!more);
assert!(stashed.is_none());
let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
panic!("must not read when an event is already stashed")
});
assert!(more);
assert!(matches!(stashed, Some(Event::Key(_))));
}
#[test]
fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
let flat = rpi_extensions::UiDialogRequest {
request_id: "r1".into(),
tool_call_id: Some("c1".into()),
ui: serde_json::json!({
"kind": "selector",
"id": "proxy_port",
"header": "Proxy",
"question": "端口?",
"options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
"allowMultiple": true,
"allowFreeform": false,
"suggest": "1080"
}),
raw: serde_json::json!({}),
};
let prompt = parse_ask_user_prompt(&flat);
assert_eq!(prompt.question_id, "proxy_port");
assert_eq!(prompt.header.as_deref(), Some("Proxy"));
assert_eq!(prompt.options.len(), 2);
assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
assert!(prompt.allow_multiple);
assert!(!prompt.allow_freeform);
assert_eq!(prompt.suggest.as_deref(), Some("1080"));
let alias = rpi_extensions::UiDialogRequest {
ui: serde_json::json!({
"questions": [{"id": "q", "question": "Q?", "options": []}]
}),
..flat.clone()
};
let prompt = parse_ask_user_prompt(&alias);
assert_eq!(prompt.question_id, "q");
assert!(prompt.allow_freeform, "no options defaults to freeform");
let confirm = rpi_extensions::UiDialogRequest {
ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
..flat.clone()
};
let prompt = parse_ask_user_prompt(&confirm);
assert_eq!(prompt.kind, "confirm");
assert_eq!(prompt.options.len(), 2);
assert_eq!(prompt.options[0].0, "Yes");
}
#[test]
fn ask_user_bridge_answers_and_cancels_by_request_id() {
let mailbox = rpi_extensions::UiDialogMailbox::new();
mailbox.attach();
let bridge = AskUserBridge::new(mailbox.clone());
mailbox
.handle(serde_json::json!({
"op": "open", "requestId": "r1", "toolCallId": "call-r1",
"ui": {"kind": "input", "question": "Port?"}
}))
.unwrap();
mailbox
.handle(serde_json::json!({
"op": "open", "requestId": "r2", "toolCallId": "call-r2",
"ui": {"kind": "input", "question": "Level?"}
}))
.unwrap();
let first = bridge.take_pending().expect("r1 visible");
assert_eq!(first.request_id, "r1");
assert!(
bridge.take_pending().is_none(),
"only one request occupies the input slot"
);
bridge.respond("r1", serde_json::json!({"text": "7897"}));
assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
let second = bridge.take_pending().expect("r2 visible");
assert_eq!(second.request_id, "r2");
bridge.cancel("r2");
assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
mailbox
.handle(serde_json::json!({
"op": "open", "requestId": "r3",
"ui": {"kind": "input", "question": "X?"}
}))
.unwrap();
bridge.cancel_all();
assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
bridge.shutdown();
assert!(mailbox
.handle(serde_json::json!({
"op": "open", "requestId": "r4", "ui": {"kind": "input"}
}))
.is_err());
}