mod auth;
mod bug_report;
mod commands;
mod ext_settings;
mod extensions;
mod giff;
mod history;
mod messages;
mod metrics;
mod mouse;
mod overlay;
mod panels;
mod retry;
mod sessions;
mod settings;
mod skills;
mod steer;
mod suggest;
mod tools;
mod trust;
pub(crate) mod confirms;
pub(crate) mod events;
pub(crate) mod session_repair;
use self::{
commands::{extension_keybinding_handler, is_busy_safe_command},
mouse::handle_mouse_event,
suggest::{complete_path, last_word},
tools::{open_external_editor, run_shell_command},
};
use super::{
app::{App, MsgLevel, SuggestionKind},
line_input::printable_char,
};
use crate::{
cli::file_processor,
core::{
self,
extensions::UserPromptAction,
keybindings, model_resolver, prompt_history,
prompt_templates::expand_prompt_template,
provider::{AgentMessage, ContentBlock},
skills::expand_skill_command,
},
utils::image::ImageResizeLimits,
};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::{
cell::RefCell,
rc::Rc,
time::{Duration, Instant},
};
pub(crate) use self::commands::{register_extension_keybinding, register_slash_command};
pub(crate) use retry::RetryUi;
pub use session_repair::{
stage_session_repair_global, stage_zombie_operations_global, take_staged_session_repair,
take_staged_zombie_operations,
};
pub enum KeyAction {
Continue,
Quit,
CancelPrompt,
}
fn dispatch_extension_keybinding(st: &mut App, key: &KeyEvent) -> bool {
if st.busy {
return false;
}
for ext in core::extensions::registered() {
for binding in ext.keybindings() {
let hit = binding.keys.iter().any(|k| {
keybindings::parse_key_id(k)
.is_some_and(|kid| keybindings::matches_event(key, &kid))
});
if !hit {
continue;
}
if let Some(handler) = extension_keybinding_handler(ext.name(), &binding.id) {
handler(st);
st.dirty = true;
return true;
}
}
}
false
}
fn intercept_user_prompt(text: &str, messages: &[AgentMessage], busy: bool) -> Option<String> {
core::extensions::dispatch_user_submit(text, busy);
match core::extensions::dispatch_user_prompt(text, messages, busy) {
Some(UserPromptAction::Handled) => None,
Some(UserPromptAction::Rewrite(t)) => Some(t),
Some(UserPromptAction::Continue) | None => Some(text.to_string()),
}
}
pub fn handle_event(shared: &Rc<RefCell<App>>, event: Event) -> KeyAction {
if let Event::Resize(w, _) = event {
let mut st = shared.borrow_mut();
st.editor.set_visual_width(w.saturating_sub(2) as usize);
if st.last_resize.elapsed() > Duration::from_millis(100) {
st.dirty = true;
st.last_resize = Instant::now();
}
return KeyAction::Continue;
}
if let Event::Paste(data) = event {
let mut st = shared.borrow_mut();
if st.settings_selector.active {
if st.settings_selector.submenu.is_none() {
st.settings_selector.filter.insert_text(&data);
st.settings_selector.recompute();
}
} else if st.session_selector.active {
if st.session_selector.rename_mode {
st.session_selector.rename_input.insert_text(&data);
} else {
st.session_selector.filter.insert_text(&data);
st.session_selector.recompute();
}
} else if st.overlay.is_some() {
st.handle_overlay_paste(&data);
} else if st.panel.active {
if let Some(layer) = st.panel.top_mut()
&& layer.kind.has_filter_input()
{
layer.filter.insert_text(&data);
layer.selected = 0;
}
} else if st.ext_settings.visible {
} else {
st.editor.paste_text(&data);
}
st.dirty = true;
return KeyAction::Continue;
}
if let Event::Mouse(mouse) = event {
handle_mouse_event(shared, mouse);
return KeyAction::Continue;
}
let Event::Key(key) = event else {
return KeyAction::Continue;
};
if key.kind == KeyEventKind::Release {
return KeyAction::Continue;
}
let mut st = shared.borrow_mut();
if st.overlay.is_some() {
return st.handle_overlay_key(&key);
}
if st.giff.is_some() {
return giff::handle_key(&mut st, &key);
}
if st.panel.active {
return st.handle_panel_key(&key);
}
if st.ext_settings.visible {
return st.handle_ext_settings_key(&key);
}
if st.busy
&& let Some(action) = handle_busy_key(&mut st, &key)
{
return action;
}
if !st.busy
&& keybindings::get_global().matches(&key, "app.interrupt")
&& core::extensions::cancel_background_work() > 0
{
st.status = "cancelled background work".to_string();
st.dirty = true;
return KeyAction::Continue;
}
if st.settings_selector.active {
return st.handle_settings_selector_key(&key);
}
if st.session_selector.active {
return st.handle_session_selector_key(&key);
}
let action = handle_editor_key(&mut st, &key);
match action {
EditorAction::Quit => KeyAction::Quit,
EditorAction::OpenExternal(text) => {
drop(st);
open_external_editor(shared, text);
KeyAction::Continue
}
EditorAction::Continue => KeyAction::Continue,
}
}
fn handle_busy_key(st: &mut App, key: &KeyEvent) -> Option<KeyAction> {
let kb = keybindings::get_global();
if kb.matches(key, "app.interrupt") {
st.status = "interrupting...".to_string();
st.dirty = true;
return Some(KeyAction::CancelPrompt);
}
if kb.matches(key, "app.clear") && !key.modifiers.contains(KeyModifiers::SHIFT) {
if !st.editor.is_empty() {
st.editor.clear();
st.refresh_suggestions(); st.dirty = true;
return Some(KeyAction::Continue);
}
st.status = "interrupting...".to_string();
st.dirty = true;
return Some(KeyAction::CancelPrompt);
}
if kb.matches(key, "app.exit") {
st.status = "exiting...".to_string();
st.dirty = true;
return Some(KeyAction::Quit);
}
if kb.matches(key, "app.thinking.cycle") {
return Some(KeyAction::Continue);
}
if kb.matches(key, "app.model.cycleForward") || kb.matches(key, "app.model.cycleBackward") {
return Some(KeyAction::Continue);
}
if kb.matches(key, "tui.input.submit")
&& !key.modifiers.contains(KeyModifiers::SHIFT)
&& !key.modifiers.contains(KeyModifiers::CONTROL)
{
st.scroll = 0;
st.dirty = true;
if st.suggestion.active {
match st.suggestion.kind {
SuggestionKind::Commands | SuggestionKind::Subcommands => st.apply_suggestion(),
SuggestionKind::Files => {
st.apply_suggestion();
return Some(KeyAction::Continue);
}
SuggestionKind::History => {
if st.suggestion.selected >= st.suggestion.items.len() {
return Some(KeyAction::Continue);
}
st.apply_suggestion();
}
SuggestionKind::HistoryAction => {
execute_history_action(st);
return Some(KeyAction::Continue);
}
}
}
let raw = st.editor.expanded_text();
if let Some(cmd) = raw
.strip_prefix('/')
.map(str::trim)
.filter(|c| is_busy_safe_command(c))
{
let cmd = cmd.to_string();
submit_and_record(st); _ = st.handle_slash_command(&cmd);
} else {
let expanded = expand_user_text(st, &raw);
if let Some(text) = intercept_user_prompt(&expanded, &st.messages, st.busy) {
submit_and_record(st);
st.queue_steer_text(text, false);
} else {
submit_and_record(st);
}
}
return Some(KeyAction::Continue);
}
None
}
enum EditorAction {
Continue,
Quit,
OpenExternal(String),
}
fn handle_editor_key(st: &mut App, key: &KeyEvent) -> EditorAction {
let action = if let Some(action) = handle_app_action_key(st, key) {
Some(action)
} else if let Some(action) = handle_input_submit_key(st, key) {
Some(action)
} else if let Some(action) = handle_input_nav_key(st, key) {
Some(action)
} else if let Some(action) = handle_editor_edit_key(st, key) {
Some(action)
} else {
match printable_char(key) {
Some(c) => st.editor.insert_char(c),
None => {
if key.code == KeyCode::Esc {
st.suggestion.deselect();
}
}
}
None
};
if !matches!(
action,
Some(EditorAction::Quit) | Some(EditorAction::OpenExternal(_))
) {
st.refresh_suggestions();
}
st.dirty = true;
action.unwrap_or(EditorAction::Continue)
}
fn handle_app_action_key(st: &mut App, key: &KeyEvent) -> Option<EditorAction> {
let kb = keybindings::get_global();
if kb.matches(key, "app.exit") && st.editor.is_empty() {
return Some(EditorAction::Quit);
}
if kb.matches(key, "app.editor.external") {
let text = st.editor.expanded_text();
return Some(EditorAction::OpenExternal(text));
}
if kb.matches(key, "app.editor.copy") {
st.copy_editor_content();
} else if kb.matches(key, "app.message.copy") {
st.copy_last_assistant_message();
} else if kb.matches(key, "tui.input.copy") {
if st.selected_text().is_some() {
st.copy_selection();
} else {
st.copy_last_assistant_message();
}
} else if kb.matches(key, "app.clipboard.pasteImage")
|| (matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& key.modifiers.contains(KeyModifiers::SHIFT))
{
st.paste_clipboard(); } else if kb.matches(key, "app.clear") {
let now = Instant::now();
let double = st
.last_ctrl_c
.map(|t| now.duration_since(t) < Duration::from_millis(500))
.unwrap_or(false);
st.last_ctrl_c = Some(now);
if double {
return Some(EditorAction::Quit);
}
if !st.editor.is_empty() {
st.editor.clear();
}
} else if kb.matches(key, "app.suspend") {
#[cfg(unix)]
{
_ = nix::sys::signal::raise(nix::sys::signal::Signal::SIGTSTP);
}
} else if kb.matches(key, "app.model.select") {
st.open_model_panel(); } else if kb.matches(key, "app.tools.expand") {
st.toggle_expand_all();
let expanded = st.expand_all;
st.set_status_msg(if expanded {
"expanded tool output (Ctrl+O to fold)".to_string()
} else {
"folded tool output (Ctrl+O to expand)".to_string()
});
} else if kb.matches(key, "app.thinking.toggle") {
st.toggle_show_thinking();
let shown = st.show_thinking;
st.set_status_msg(if shown {
"thinking blocks visible (Ctrl+T to hide)".to_string()
} else {
"thinking blocks hidden (Ctrl+T to show)".to_string()
});
} else if kb.matches(key, "app.theme.cycle") {
st.cycle_theme(); } else if kb.matches(key, "app.model.cycleBackward")
&& !kb.matches(key, "tui.editor.historyPrevious")
{
st.cycle_model_dir(-1);
} else if kb.matches(key, "app.model.cycleForward")
&& !kb.matches(key, "tui.editor.historyNext")
{
st.cycle_model_dir(1);
} else if kb.matches(key, "app.thinking.cycle") {
st.cycle_thinking(); } else if kb.matches(key, "app.message.dequeue") {
st.dequeue_steers(); } else if kb.matches(key, "app.message.followUp") {
st.queue_steer(true); } else if kb.matches(key, "app.message.scrollToTop") {
st.scroll = st.max_scroll;
st.dirty = true;
} else if kb.matches(key, "app.message.scrollToBottom") {
st.scroll = 0;
st.dirty = true;
} else if kb.matches(key, "app.message.pageUp") {
st.scroll = (st.scroll + message_page(st)).min(st.max_scroll);
st.dirty = true;
} else if kb.matches(key, "app.message.pageDown") {
st.scroll = st.scroll.saturating_sub(message_page(st));
st.dirty = true;
} else {
return None;
}
Some(EditorAction::Continue)
}
fn message_page(st: &App) -> usize {
(st.mouse.log_area_h as usize).max(1)
}
fn submit_and_record(st: &mut App) -> Option<String> {
let text = st.editor.submit()?;
prompt_history::append(&st.cwd, &text, st.history_max_entries);
st.editor.cap_history(st.history_max_entries);
Some(text)
}
pub(crate) fn expand_user_text(st: &App, text: &str) -> String {
let expanded = expand_skill_command(text, &st.skills);
expand_prompt_template(&expanded, &st.prompt_templates)
}
fn execute_history_action(st: &mut App) {
if st.suggestion.selected >= st.suggestion.items.len() {
return;
}
let token = st.suggestion.items[st.suggestion.selected].insert.clone();
st.suggestion.deselect();
st.editor.clear();
st.dirty = true;
st.open_history_clear_confirm(token == "@clear-all");
}
fn handle_input_submit_key(st: &mut App, key: &KeyEvent) -> Option<EditorAction> {
let kb = keybindings::get_global();
if kb.matches(key, "tui.input.submit") {
if st.editor.should_submit_on_backslash() {
st.editor.backslash_enter();
st.dirty = true;
return Some(EditorAction::Continue);
}
st.scroll = 0;
st.dirty = true;
st.banner_hidden = true;
if st.suggestion.active {
match st.suggestion.kind {
SuggestionKind::Commands | SuggestionKind::Subcommands => {
st.apply_suggestion();
if let Some(text) = submit_and_record(st)
&& let Some(cmd) = text.strip_prefix('/')
&& st.handle_slash_command(cmd.trim())
{
return Some(EditorAction::Quit);
}
return Some(EditorAction::Continue);
}
SuggestionKind::Files => {
st.apply_suggestion();
return Some(EditorAction::Continue);
}
SuggestionKind::History => {
if st.suggestion.selected >= st.suggestion.items.len() {
return Some(EditorAction::Continue);
}
st.apply_suggestion();
}
SuggestionKind::HistoryAction => {
execute_history_action(st);
return Some(EditorAction::Continue);
}
}
}
if let Some(text) = submit_and_record(st) {
if let Some(shell_cmd) = text.strip_prefix("!!") {
let output = run_shell_command(shell_cmd.trim());
st.push_msg(
format!("$ {}\n{}", shell_cmd.trim(), output),
MsgLevel::Info,
);
} else if let Some(shell_cmd) = text.strip_prefix('!') {
let output = run_shell_command(shell_cmd.trim());
let final_text = format!("$ {}\n{}", shell_cmd.trim(), output);
let msg = AgentMessage::user_text(&final_text);
st.messages.push(msg.clone());
st.worker.append_message(msg);
} else if let Some(cmd) = text.strip_prefix('/') {
if st.handle_slash_command(cmd.trim()) {
return Some(EditorAction::Quit);
}
} else {
let expanded = expand_user_text(st, &text);
let Some(final_text) = intercept_user_prompt(&expanded, &st.messages, st.busy)
else {
return Some(EditorAction::Continue);
};
let (expanded_text, img_files) = file_processor::expand_at_image_refs(
&final_text,
&st.cwd,
current_image_resize_limits(st),
);
if img_files.is_empty() {
if st.busy {
st.worker.prompt(final_text.clone());
} else {
st.messages.push(AgentMessage::user_text(&final_text));
st.status_start = Some(final_text);
}
} else {
let mut msg = AgentMessage::user_text(&expanded_text);
for (data, mime) in img_files {
msg.content.push(ContentBlock::Image {
data,
mime_type: mime,
});
}
if st.busy {
st.worker.prompt_message(msg);
} else {
st.initial_queue.push_back(msg);
}
}
}
}
} else {
return None;
}
Some(EditorAction::Continue)
}
fn handle_input_nav_key(st: &mut App, key: &KeyEvent) -> Option<EditorAction> {
let kb = keybindings::get_global();
if kb.matches(key, "tui.input.newLine") {
if st.suggestion.active
&& key.code == KeyCode::Char('j')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
st.suggestion_next();
} else {
st.editor.newline();
}
} else if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL) {
st.editor.newline(); } else if kb.matches(key, "tui.input.tab") {
if st.suggestion.active {
st.apply_suggestion();
} else {
let text = st.editor.text();
if !text.trim().is_empty() {
let cwd = st.cwd.clone();
if let Some((prefix, start)) = last_word(&text)
&& let Some(completed) = complete_path(&prefix, &cwd)
{
st.editor.clear();
let new_text = format!("{}{}", &text[..start], completed);
st.editor.insert_text(&new_text);
}
} else {
st.toggle_expand_all();
let expanded = st.expand_all;
st.set_status_msg(if expanded {
"expanded all messages (Ctrl+O to fold)".to_string()
} else {
"folded messages (Ctrl+O to expand)".to_string()
});
}
}
} else {
return None;
}
Some(EditorAction::Continue)
}
fn handle_editor_edit_key(st: &mut App, key: &KeyEvent) -> Option<EditorAction> {
if let Some(action) = handle_kill_yank_key(st, key) {
return Some(action);
}
if let Some(action) = handle_cursor_move_key(st, key) {
return Some(action);
}
if let Some(action) = handle_char_delete_key(st, key) {
return Some(action);
}
None
}
fn handle_kill_yank_key(st: &mut App, key: &KeyEvent) -> Option<EditorAction> {
let kb = keybindings::get_global();
if kb.matches(key, "tui.editor.deleteToLineEnd") {
if st.suggestion.active
&& key.code == KeyCode::Char('k')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
st.suggestion_prev();
} else {
let killed = st.editor.kill_line_end();
if !killed.is_empty() {
st.kill_buffer = killed.clone();
st.kill_ring.push(killed);
st.yank_index = st.kill_ring.len();
st.last_kill_was_word = false;
}
}
} else if kb.matches(key, "tui.editor.deleteToLineStart") {
let killed = st.editor.kill_line_start();
if !killed.is_empty() {
st.kill_buffer = killed.clone();
st.kill_ring.push(killed);
st.yank_index = st.kill_ring.len();
st.last_kill_was_word = false;
}
} else if kb.matches(key, "tui.editor.yank") {
if let Some(buf) = st.kill_ring.last().cloned().or_else(|| {
if st.kill_buffer.is_empty() {
None
} else {
Some(st.kill_buffer.clone())
}
}) {
st.yank_index = st.kill_ring.len();
st.editor.insert_text(&buf);
}
} else if kb.matches(key, "tui.editor.yankPop") {
if !st.kill_ring.is_empty() {
if st.yank_index == 0 {
st.yank_index = st.kill_ring.len();
}
st.yank_index -= 1;
if let Some(buf) = st.kill_ring.get(st.yank_index).cloned() {
st.editor.insert_text(&buf);
}
}
} else if kb.matches(key, "tui.editor.undo") {
st.editor.undo();
} else {
return None;
}
Some(EditorAction::Continue)
}
fn handle_cursor_move_key(st: &mut App, key: &KeyEvent) -> Option<EditorAction> {
let kb = keybindings::get_global();
if kb.matches(key, "tui.editor.cursorUp") {
if st.suggestion.active {
st.suggestion_prev();
} else {
st.editor.move_up_or_history();
}
} else if kb.matches(key, "tui.editor.cursorDown") {
if st.suggestion.active {
st.suggestion_next();
} else {
st.editor.move_down_or_history();
}
} else if kb.matches(key, "tui.editor.historyPrevious") {
st.editor.history_prev();
} else if kb.matches(key, "tui.editor.historyNext") {
st.editor.history_next();
} else if kb.matches(key, "tui.editor.cursorLeft") {
st.editor.move_left();
} else if kb.matches(key, "tui.editor.cursorRight") {
st.editor.move_right();
} else if kb.matches(key, "tui.editor.cursorWordLeft") {
st.editor.move_word_left();
} else if kb.matches(key, "tui.editor.cursorWordRight") {
st.editor.move_word_right();
} else if kb.matches(key, "tui.editor.cursorLineStart") {
st.editor.home();
} else if kb.matches(key, "tui.editor.cursorLineEnd") {
st.editor.end();
} else if kb.matches(key, "tui.editor.pageUp") {
if st.suggestion.active {
for _ in 0..10 {
st.suggestion_prev();
}
} else {
st.editor.move_page_up();
}
} else if kb.matches(key, "tui.editor.pageDown") {
if st.suggestion.active {
for _ in 0..10 {
st.suggestion_next();
}
} else {
st.editor.move_page_down();
}
} else {
return None;
}
Some(EditorAction::Continue)
}
fn handle_char_delete_key(st: &mut App, key: &KeyEvent) -> Option<EditorAction> {
let kb = keybindings::get_global();
if kb.matches(key, "tui.editor.deleteCharBackward") {
st.editor.backspace();
} else if kb.matches(key, "tui.editor.deleteCharForward") {
st.editor.delete();
} else if kb.matches(key, "tui.editor.deleteWordBackward") {
let killed = st.editor.delete_word_backward();
if !killed.is_empty() {
st.kill_buffer = killed.clone();
st.kill_ring.push(killed);
st.yank_index = st.kill_ring.len();
st.last_kill_was_word = true;
}
} else if kb.matches(key, "tui.editor.deleteWordForward") {
let killed = st.editor.delete_word_forward();
if !killed.is_empty() {
st.kill_buffer = killed.clone();
st.kill_ring.push(killed);
st.yank_index = st.kill_ring.len();
st.last_kill_was_word = true;
}
} else if dispatch_extension_keybinding(st, key) {
} else {
return None;
}
Some(EditorAction::Continue)
}
fn current_image_resize_limits(st: &App) -> ImageResizeLimits {
st.current_provider
.as_deref()
.zip(st.current_model.as_deref())
.and_then(|(p, m)| model_resolver::find_model(p, m).ok())
.map(|e| e.input_limits.images.resize)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
#![allow(clippy::arc_with_non_send_sync)]
use super::*;
use crate::modes::interactive::agent_actor::WorkerHandle;
use crate::modes::interactive::app::App;
use crate::modes::interactive::line_input::shift_layer;
use crate::modes::interactive::panel::{PanelItem, PanelKind};
use crossterm::event::KeyEvent;
fn test_agent_dir() -> crate::test_support::AgentDirGuard {
crate::test_support::AgentDirGuard::temp()
}
#[test]
fn tab_completion_marks_input_dirty() {
let shared = Rc::new(RefCell::new(App::new()));
for c in "/mod".chars() {
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)),
);
}
shared.borrow_mut().dirty = false; handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
assert_eq!(st.editor.text(), "/model ", "命令补全生效");
assert!(st.dirty, "Tab 命令补全后必须置脏");
}
let shared = Rc::new(RefCell::new(App::new()));
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("login.txt"), "x").unwrap();
shared.borrow_mut().cwd = dir.path().to_str().unwrap().to_string();
for c in "log".chars() {
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)),
);
}
shared.borrow_mut().dirty = false;
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
assert_eq!(st.editor.text(), "login.txt ", "路径补全生效");
assert!(st.dirty, "Tab 路径补全后必须置脏");
}
}
#[test]
fn release_key_events_are_ignored() {
let shared = Rc::new(RefCell::new(App::new()));
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
);
handle_event(
&shared,
Event::Key(KeyEvent::new_with_kind(
KeyCode::Char('a'),
KeyModifiers::NONE,
KeyEventKind::Release,
)),
);
assert_eq!(
shared.borrow().editor.text(),
"a",
"Release 不应重复插入字符"
);
handle_event(
&shared,
Event::Key(KeyEvent::new_with_kind(
KeyCode::Backspace,
KeyModifiers::NONE,
KeyEventKind::Release,
)),
);
assert_eq!(
shared.borrow().editor.text(),
"a",
"Release 的 Backspace 应被忽略"
);
}
#[test]
fn newline_marks_input_dirty() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.editor.insert_text("abc");
st.dirty = false;
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)),
);
let st = shared.borrow();
assert_eq!(st.editor.line_count(), 2, "Shift+Enter 换行");
assert!(st.dirty, "换行后必须置脏");
}
#[test]
fn deleting_trigger_char_hides_suggestion_panel() {
let shared = Rc::new(RefCell::new(App::new()));
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)),
);
assert!(shared.borrow().suggestion.active, "输入 / 应激活命令候选");
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
assert!(!st.suggestion.active, "删除 / 后面板应隐藏");
assert_eq!(st.editor.text(), "");
}
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.editor.insert_text("@");
st.suggestion.active = true;
st.suggestion.kind = SuggestionKind::Files;
st.suggestion.start = 0;
st.suggestion.query = String::new();
st.suggestion.items = vec![crate::modes::interactive::app::SuggestionItem {
name: "file.rs".to_string(),
description: "file.rs".to_string(),
insert: "file.rs".to_string(),
}];
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
assert!(!st.suggestion.active, "删除 @ 后面板应隐藏");
assert_eq!(st.editor.text(), "");
}
}
struct SubcmdEnterExt;
impl crate::core::extensions::Extension for SubcmdEnterExt {
fn name(&self) -> &str {
"subcmd-enter-ext"
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<crate::core::extensions::ExtensionCommand> {
vec![crate::core::extensions::ExtensionCommand {
name: "subcmd-enter".to_string(),
description: "demo".to_string(),
busy_safe: true,
subcommands: vec![
crate::core::extensions::SubcommandDef {
name: "pause",
description: "Pause it",
},
crate::core::extensions::SubcommandDef {
name: "resume",
description: "Resume it",
},
],
}]
}
fn on_registered(&self) {
register_slash_command("subcmd-enter-ext", "subcmd-enter", |st, raw| {
st.push_msg(format!("EXEC {raw}"), MsgLevel::Info);
false
});
}
}
fn ensure_subcmd_enter_ext() {
static REG: std::sync::Once = std::sync::Once::new();
REG.call_once(|| crate::core::extensions::register_extension(SubcmdEnterExt));
crate::core::extensions::set_extension_enabled("subcmd-enter-ext", true);
}
fn type_text(shared: &Rc<RefCell<App>>, text: &str) {
for c in text.chars() {
handle_event(
shared,
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)),
);
}
}
#[test]
fn subcommand_tab_completes_and_enter_executes() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
ensure_subcmd_enter_ext();
let shared = Rc::new(RefCell::new(App::new()));
type_text(&shared, "/subcmd-enter p");
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
assert_eq!(st.editor.text(), "/subcmd-enter pause ", "Tab 只补全");
assert!(st.system_messages.is_empty(), "Tab 不得执行命令");
}
let shared = Rc::new(RefCell::new(App::new()));
type_text(&shared, "/subcmd-enter p");
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
assert_eq!(st.editor.text(), "", "Enter 提交后清空输入框");
let last = crate::modes::interactive::app::sys_msg_text(
&st.system_messages.last().expect("应执行子命令").1,
);
assert_eq!(last, "EXEC subcmd-enter pause", "Enter 应补全并执行首项");
}
let shared = Rc::new(RefCell::new(App::new()));
type_text(&shared, "/subcmd-enter ");
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
let last = crate::modes::interactive::app::sys_msg_text(
&st.system_messages.last().expect("空查询回车也应执行首项").1,
);
assert_eq!(last, "EXEC subcmd-enter pause");
}
}
fn test_agent_cmd() -> (
WorkerHandle,
crate::modes::interactive::agent_actor::CommandRx,
) {
crate::test_support::pin_test_agent_dir();
let (cmd_tx, cmd_rx) = crate::modes::interactive::agent_actor::channels();
(WorkerHandle::new(cmd_tx), cmd_rx)
}
#[test]
fn panel_paste_goes_to_filter() {
let mut st = App::new();
st.panel.open(
PanelKind::Model,
"model".to_string(),
vec![PanelItem {
label: "model-a".to_string(),
value: "m1".to_string(),
desc: "[p]".to_string(),
name: String::new(),
..Default::default()
}],
);
let shared = Rc::new(RefCell::new(st));
handle_event(&shared, Event::Paste("sk-abc".to_string()));
let st = shared.borrow_mut();
assert_eq!(
st.panel.top().unwrap().filter.value,
"sk-abc",
"面板激活时 Paste 进面板过滤框"
);
drop(st);
let st = App::new();
let shared = Rc::new(RefCell::new(st));
handle_event(&shared, Event::Paste("ed-1".to_string()));
let st = shared.borrow_mut();
assert_eq!(st.editor.text(), "ed-1", "默认 Paste 进主页编辑器");
}
#[test]
fn large_bracketed_paste_collapses_to_marker() {
let st = App::new();
let shared = Rc::new(RefCell::new(st));
let big: String = (0..20)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
handle_event(&shared, Event::Paste(big));
let mut st = shared.borrow_mut();
assert_eq!(st.editor.line_count(), 1, "20 行粘贴折叠为单行 marker");
assert_eq!(st.editor.text(), "[paste #1 +20 lines]");
assert_eq!(st.editor.submit().unwrap().lines().count(), 20);
}
#[test]
fn shift_layer_maps_us_symbols() {
let pairs = [
('1', '!'),
('2', '@'),
('3', '#'),
('4', '$'),
('5', '%'),
('6', '^'),
('7', '&'),
('8', '*'),
('9', '('),
('0', ')'),
('`', '~'),
('-', '_'),
('=', '+'),
('[', '{'),
(']', '}'),
('\\', '|'),
(';', ':'),
('\'', '\"'),
(',', '<'),
('.', '>'),
('/', '?'),
];
for (raw, shifted) in pairs {
assert_eq!(shift_layer(raw), shifted, "shift+{} 应为 {}", raw, shifted);
}
}
#[test]
fn shift_layer_passthrough_and_upper() {
assert_eq!(shift_layer('a'), 'A');
assert_eq!(shift_layer('A'), 'A');
assert_eq!(shift_layer('@'), '@');
assert_eq!(shift_layer('中'), '中');
}
#[test]
fn ctrl_jk_navigate_suggestion_list_when_active() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.editor.insert_text("/o");
st.refresh_suggestions();
assert!(st.suggestion.active);
}
let n = shared.borrow_mut().suggestion.items.len();
assert!(n >= 2);
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)),
);
{
let st = shared.borrow_mut();
assert_eq!(st.suggestion.selected, 1, "Ctrl+J 下移");
assert!(st.suggestion.active, "内容未变列表仍激活");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)),
);
assert_eq!(shared.borrow_mut().suggestion.selected, 0, "Ctrl+K 上移");
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)),
);
assert_eq!(
shared.borrow_mut().suggestion.selected,
n - 1,
"顶部 Ctrl+K 回绕到末尾"
);
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)),
);
assert_eq!(
shared.borrow_mut().suggestion.selected,
0,
"末尾 Ctrl+J 回绕到顶部"
);
}
#[test]
fn ctrl_jk_fall_back_when_no_suggestion_list() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.editor.insert_text("abcd");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)),
);
assert_eq!(shared.borrow_mut().editor.line_count(), 2, "Ctrl+J 换行");
{
let mut st = shared.borrow_mut();
st.editor.move_up();
st.editor.home();
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)),
);
{
let st = shared.borrow_mut();
assert_eq!(st.editor.lines[0], "", "Ctrl+K 删到行尾");
assert_eq!(st.kill_buffer, "abcd", "删掉内容进 kill-ring");
}
}
#[test]
fn up_at_multiline_top_switches_history() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.editor.history.push("prev".to_string());
st.editor.insert_text("a\nb");
}
let up = || Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
handle_event(&shared, up());
{
let st = shared.borrow();
assert_eq!((st.editor.cursor_line, st.editor.cursor_col), (0, 1));
assert_eq!(st.editor.text(), "a\nb", "尚未切换历史");
}
handle_event(&shared, up());
{
let st = shared.borrow();
assert_eq!((st.editor.cursor_line, st.editor.cursor_col), (0, 0));
assert_eq!(st.editor.text(), "a\nb", "归位行首不切换历史");
}
handle_event(&shared, up());
assert_eq!(shared.borrow().editor.text(), "prev");
}
#[test]
fn caps_lock_uppercases_main_editor_input() {
use crossterm::event::{KeyEventKind, KeyEventState};
let shared = Rc::new(RefCell::new(App::new()));
handle_event(
&shared,
Event::Key(KeyEvent::new_with_kind_and_state(
KeyCode::Char('a'),
KeyModifiers::NONE,
KeyEventKind::Press,
KeyEventState::CAPS_LOCK,
)),
);
handle_event(
&shared,
Event::Key(KeyEvent::new_with_kind_and_state(
KeyCode::Char('b'),
KeyModifiers::SHIFT,
KeyEventKind::Press,
KeyEventState::CAPS_LOCK,
)),
);
assert_eq!(shared.borrow_mut().editor.text(), "Ab");
}
#[test]
fn enter_resets_scroll_to_bottom_when_empty() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.scroll = 42; }
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
let st = shared.borrow_mut();
assert_eq!(st.scroll, 0, "空回车也把滚动条滚回底部");
assert!(st.status_start.is_none(), "空内容不提交消息");
}
#[test]
fn enter_submit_resets_scroll_to_bottom() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.scroll = 42;
st.editor.insert_text("hello");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
let st = shared.borrow_mut();
assert_eq!(st.scroll, 0, "发送消息后滚动条滚回底部");
assert!(st.status_start.is_some(), "有内容应提交");
}
fn press(shared: &Rc<RefCell<App>>, code: KeyCode, mods: KeyModifiers) {
handle_event(shared, Event::Key(KeyEvent::new(code, mods)));
}
fn history_app_with(entries: &[&str]) -> Rc<RefCell<App>> {
let shared = Rc::new(RefCell::new(App::new()));
shared.borrow_mut().editor.history = entries.iter().map(|s| s.to_string()).collect();
shared
}
#[test]
fn history_enter_sends_selected_entry_directly() {
let shared = history_app_with(&["fix the bug"]);
type_text(&shared, "/history");
assert!(shared.borrow().suggestion.active, "打完 /history 即出面板");
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
let st = shared.borrow();
assert!(!st.suggestion.active, "发送后面板关闭");
assert!(st.editor.text().is_empty(), "编辑器已清空");
assert_eq!(
st.status_start.as_deref(),
Some("fix the bug"),
"Enter 直接发送选中条目(不是重新打开搜索)"
);
assert_eq!(st.messages.len(), 1, "作为普通 prompt 发出一条用户消息");
}
#[test]
fn history_tab_fills_input_without_sending() {
let shared = history_app_with(&["fix the bug"]);
type_text(&shared, "/history");
press(&shared, KeyCode::Tab, KeyModifiers::NONE);
let st = shared.borrow();
assert!(!st.suggestion.active, "Tab 填入后面板关闭");
assert_eq!(st.editor.text(), "fix the bug", "Tab 只填入输入框");
assert!(st.status_start.is_none(), "Tab 不发送");
assert!(st.messages.is_empty(), "Tab 不产生消息");
}
#[test]
fn history_action_tab_fills_command_without_executing() {
let _ad = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.cwd = cwd.clone();
st.editor.insert_text("remember me");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE); assert_eq!(crate::core::prompt_history::load(&cwd, 500).len(), 1);
type_text(&shared, "/history @");
assert_eq!(
shared.borrow().suggestion.kind,
SuggestionKind::HistoryAction,
"`/history @` 应弹控制词候选"
);
press(&shared, KeyCode::Tab, KeyModifiers::NONE);
{
let st = shared.borrow();
assert_eq!(
st.editor.text(),
"/history @clear",
"Tab 应把选中控制词补进输入框"
);
assert_eq!(st.suggestion.kind, SuggestionKind::HistoryAction);
assert_eq!(st.suggestion.items[0].insert, "@clear");
assert!(st.pending_history_clear.is_none(), "Tab 不得弹确认面板");
assert_ne!(
st.panel.top().map(|l| l.kind),
Some(PanelKind::HistoryClearConfirm),
"Tab 不得执行删除"
);
}
assert_eq!(
crate::core::prompt_history::load(&cwd, 500).len(),
1,
"Tab 之后文件必须还在"
);
}
#[test]
fn history_clear_confirm_ignores_typing() {
let _ad = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.cwd = cwd.clone();
st.editor.insert_text("keep me");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
type_text(&shared, "/history @clear");
press(&shared, KeyCode::Enter, KeyModifiers::NONE); assert_eq!(
shared.borrow().panel.top().map(|l| l.kind),
Some(PanelKind::HistoryClearConfirm)
);
type_text(&shared, "xyz");
{
let st = shared.borrow();
assert_eq!(
st.panel.top().unwrap().filter.value,
"",
"确认面板不接受文本输入"
);
assert_eq!(st.panel.filtered_items().len(), 2, "Yes/No 应仍在");
}
handle_event(&shared, Event::Paste("pasted".to_string()));
assert_eq!(shared.borrow().panel.top().unwrap().filter.value, "");
press(&shared, KeyCode::Down, KeyModifiers::NONE);
assert_eq!(shared.borrow().panel.top().unwrap().selected, 1);
}
#[test]
fn history_zero_match_enter_is_swallowed() {
let shared = history_app_with(&["aa"]);
type_text(&shared, "/history zz");
{
let st = shared.borrow();
assert!(st.suggestion.active, "零匹配也保持面板");
assert!(st.suggestion.items.is_empty());
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
let st = shared.borrow();
assert_eq!(
st.editor.text(),
"/history zz",
"Enter 被吞掉:不清行、不发送"
);
assert!(st.status_start.is_none());
assert!(st.messages.is_empty(), "绝不能落到 cmd_unknown 发给模型");
}
#[test]
fn history_clear_confirm_yes_deletes_file_and_memory() {
let _ad = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.cwd = cwd.clone();
st.editor.history = vec!["old prompt".to_string()];
st.editor.insert_text("remember me");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE); assert_eq!(crate::core::prompt_history::load(&cwd, 500).len(), 1);
type_text(&shared, "/history @clear");
press(&shared, KeyCode::Enter, KeyModifiers::NONE); {
let st = shared.borrow();
assert_eq!(
st.panel.top().map(|l| l.kind),
Some(PanelKind::HistoryClearConfirm),
"@clear 必须弹确认面板"
);
assert!(st.pending_history_clear.is_some());
assert!(st.editor.text().is_empty(), "执行命令后命令行被清掉");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE); let st = shared.borrow();
assert!(st.pending_history_clear.is_none());
assert!(st.editor.history.is_empty(), "内存列表同步清空");
assert!(
crate::core::prompt_history::load(&cwd, 500).is_empty(),
"文件应已删除"
);
}
#[test]
fn history_clear_confirm_no_keeps_everything() {
let _ad = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.cwd = cwd.clone();
st.editor.insert_text("keep me");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
type_text(&shared, "/history @clear");
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
press(&shared, KeyCode::Down, KeyModifiers::NONE); press(&shared, KeyCode::Enter, KeyModifiers::NONE);
let st = shared.borrow();
assert!(st.pending_history_clear.is_none());
assert_eq!(st.editor.history.len(), 1, "取消不得动内存历史");
assert_eq!(
crate::core::prompt_history::load(&cwd, 500).len(),
1,
"取消不得删文件"
);
}
#[test]
fn history_clear_without_file_only_hints() {
let _ad = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let shared = Rc::new(RefCell::new(App::new()));
shared.borrow_mut().cwd = dir.path().to_string_lossy().to_string();
type_text(&shared, "/history @clear");
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
let st = shared.borrow();
assert!(st.pending_history_clear.is_none());
assert_ne!(
st.panel.top().map(|l| l.kind),
Some(PanelKind::HistoryClearConfirm),
"无文件可删时不弹空确认框"
);
assert!(
st.system_messages
.iter()
.any(|(_, m)| crate::modes::interactive::app::sys_msg_text(m)
.contains("No prompt history")),
"应给一句友好提示"
);
}
#[test]
fn history_clear_confirm_escape_cancels_and_keeps_file() {
let _ad = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.cwd = cwd.clone();
st.editor.insert_text("keep me");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
type_text(&shared, "/history @clear-all");
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
assert!(shared.borrow().pending_history_clear.is_some());
press(&shared, KeyCode::Esc, KeyModifiers::NONE);
let st = shared.borrow();
assert!(st.pending_history_clear.is_none(), "Esc 必须清掉待确认状态");
assert!(!st.panel.active, "Esc 关闭面板");
assert_eq!(st.editor.history.len(), 1, "内存历史保留");
assert_eq!(
crate::core::prompt_history::load(&cwd, 500).len(),
1,
"Esc 之后文件必须还在"
);
}
#[test]
fn history_esc_then_enter_reopens_panel() {
let shared = history_app_with(&["aa"]);
type_text(&shared, "/history");
assert!(shared.borrow().suggestion.active);
press(&shared, KeyCode::Esc, KeyModifiers::NONE);
assert!(!shared.borrow().suggestion.active, "Esc 关闭面板");
assert_eq!(shared.borrow().editor.text(), "/history");
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
let st = shared.borrow();
assert_eq!(st.suggestion.kind, SuggestionKind::History);
assert_eq!(st.suggestion.items.len(), 2, "重新打开搜索");
assert!(st.messages.is_empty(), "兜底路径不发送任何内容");
}
#[test]
fn submit_records_prompt_to_prompt_history_file() {
let _ad = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let shared = Rc::new(RefCell::new(App::new()));
let cwd = dir.path().to_string_lossy().to_string();
{
let mut st = shared.borrow_mut();
st.cwd = cwd.clone();
st.history_max_entries = 500;
st.editor.insert_text("remember me");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
let loaded = crate::core::prompt_history::load(&cwd, 500);
assert_eq!(loaded.len(), 1, "提交的 prompt 应落盘: {loaded:?}");
assert_eq!(loaded[0].text, "remember me");
}
#[test]
fn ctrl_home_scrolls_message_area_to_top() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.max_scroll = 24;
st.scroll = 3;
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL)),
);
let st = shared.borrow_mut();
assert_eq!(st.scroll, 24, "ctrl+home 滚动到顶部");
}
#[test]
fn ctrl_end_scrolls_message_area_to_bottom() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.max_scroll = 24;
st.scroll = 24;
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL)),
);
let st = shared.borrow_mut();
assert_eq!(st.scroll, 0, "ctrl+end 滚动到底部(跟随最新)");
}
#[test]
fn shift_page_up_scrolls_message_area_by_viewport() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.max_scroll = 100;
st.scroll = 5;
st.mouse.log_area_h = 20;
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT)),
);
let st = shared.borrow_mut();
assert_eq!(st.scroll, 25, "shift+pageup 向上翻一页(视口高度)");
}
#[test]
fn shift_page_down_scrolls_message_area_by_viewport() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.max_scroll = 100;
st.scroll = 25;
st.mouse.log_area_h = 20;
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)),
);
let st = shared.borrow_mut();
assert_eq!(st.scroll, 5, "shift+pagedown 向下翻一页(视口高度)");
}
#[test]
fn page_scroll_clamps_to_bounds() {
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.max_scroll = 24;
st.scroll = 20;
st.mouse.log_area_h = 20;
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT)),
);
assert_eq!(shared.borrow().scroll, 24, "翻页在顶部封顶");
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.max_scroll = 24;
st.scroll = 3;
st.mouse.log_area_h = 20;
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)),
);
assert_eq!(shared.borrow().scroll, 0, "翻页在底部不越界");
}
#[test]
fn alt_up_routes_to_dequeue() {
use crossterm::event::KeyEvent;
let _g = test_agent_dir();
let shared = Rc::new(RefCell::new(App::new()));
{
let st = shared.borrow_mut();
st.runtime_steer_inbox
.lock()
.unwrap()
.push_back(crate::core::provider::AgentMessage::user_text("recover me"));
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)),
);
let st = shared.borrow_mut();
assert!(st.runtime_followup_inbox.is_empty(), "Alt+Up 清空队列");
assert_eq!(st.editor.text(), "recover me", "取回编辑器");
}
#[test]
fn extension_user_prompt_claim_and_rewrite() {
let _g = test_agent_dir();
let _reg = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
use crate::core::extensions::{
Extension, ExtensionHook, ExtensionTool, UserPromptAction, register_extension,
set_extension_enabled, unregister_extension,
};
struct MentionStub;
impl Extension for MentionStub {
fn name(&self) -> &str {
"mention-stub"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::UserPrompt]
}
fn on_user_prompt(&self, text: &str) -> Option<UserPromptAction> {
if text.starts_with("@claim") {
Some(UserPromptAction::Handled)
} else {
text.strip_prefix("@main ")
.map(|rest| UserPromptAction::Rewrite(rest.to_string()))
}
}
}
register_extension(MentionStub);
set_extension_enabled("mention-stub", true);
let _sub = crate::extensions::subagent::test_lock();
let shared = Rc::new(RefCell::new(App::new()));
shared.borrow_mut().editor.insert_text("@claim do it");
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
assert!(st.messages.is_empty(), "认领的提交不得启动回合");
assert_eq!(st.editor.text(), "");
}
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("@claim again");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
assert!(
st.runtime_steer_inbox.lock().unwrap().is_empty(),
"认领的 mention 不得入主 steer 队列"
);
assert_eq!(st.editor.text(), "");
}
shared.borrow_mut().editor.insert_text("@main hello there");
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow();
let inbox = st.runtime_steer_inbox.lock().unwrap();
assert_eq!(inbox.len(), 1);
assert_eq!(inbox[0].text(), "hello there", "忙碌时应排队改写后的文本");
}
unregister_extension("mention-stub");
}
#[test]
fn shift_tab_cycles_thinking_level_and_refreshes_status_text() {
let _g = test_agent_dir();
let (agent, mut cmd_rx) = test_agent_cmd();
let shared = Rc::new(RefCell::new(App::new()));
shared.borrow_mut().worker = agent;
shared.borrow_mut().model_reasoning = true;
shared.borrow_mut().thinking_levels = vec!["off".to_string(), "high".to_string()];
for _ in 0..3 {
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE)),
);
let cmd = cmd_rx.try_recv().expect("应发 SetThinking");
assert!(
matches!(
cmd,
crate::modes::interactive::agent_actor::AgentCommand::SetThinking { .. }
),
"应发 SetThinking: {cmd:?}"
);
}
let st = shared.borrow_mut();
assert!(st.model_reasoning);
}
#[test]
fn alt_p_toggles_plan_mode() {
let _g = test_agent_dir();
let _reg = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::extensions::plan_mode::ensure_registered();
crate::core::extensions::set_extension_enabled("plan-mode", true);
crate::extensions::plan_mode::set_enabled(false);
let dir = crate::core::settings_manager::agent_dir();
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
let shared = Rc::new(RefCell::new(App::new()));
let key = |mods: KeyModifiers| Event::Key(KeyEvent::new(KeyCode::Char('p'), mods));
handle_event(&shared, key(KeyModifiers::ALT));
assert!(
crate::extensions::plan_mode::is_enabled(),
"Alt+P 开启 plan mode"
);
let last = shared
.borrow()
.system_messages
.last()
.map(|(_, s)| crate::modes::interactive::app::sys_spans_text(&s.spans));
assert!(
last.as_deref().unwrap_or("").contains("Plan mode enabled"),
"got: {last:?}"
);
handle_event(&shared, key(KeyModifiers::ALT));
assert!(!crate::extensions::plan_mode::is_enabled(), "再次按下关闭");
crate::extensions::plan_mode::set_enabled(true);
let mut st = shared.borrow_mut();
st.busy = true;
drop(st);
handle_event(&shared, key(KeyModifiers::ALT));
assert!(
crate::extensions::plan_mode::is_enabled(),
"busy 时不应切换"
);
shared.borrow_mut().busy = false;
crate::extensions::plan_mode::set_enabled(false);
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
while crate::core::extensions::take_pending_ui().is_some() {}
}
#[test]
fn alt_p_noop_when_extension_disabled() {
let _g = test_agent_dir();
let _reg = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::extensions::plan_mode::ensure_registered();
crate::extensions::plan_mode::set_enabled(false);
crate::core::extensions::set_extension_enabled("plan-mode", false);
let dir = crate::core::settings_manager::agent_dir();
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
let shared = Rc::new(RefCell::new(App::new()));
let key = |mods: KeyModifiers| Event::Key(KeyEvent::new(KeyCode::Char('p'), mods));
handle_event(&shared, key(KeyModifiers::ALT));
assert!(!crate::extensions::plan_mode::is_enabled(), "禁用后不切换");
let msgs = shared.borrow_mut();
let has_plan_msg = msgs.system_messages.iter().any(|(_, s)| {
crate::modes::interactive::app::sys_spans_text(&s.spans).contains("Plan mode")
});
assert!(!has_plan_msg, "禁用后不弹 plan 提示");
drop(msgs);
crate::core::extensions::set_extension_enabled("plan-mode", true);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
while crate::core::extensions::take_pending_ui().is_some() {}
}
#[test]
fn busy_submit_dispatches_busy_safe_todos_cmd() {
let _g = test_agent_dir();
let _reg = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::extensions::plan_mode::ensure_registered();
crate::core::extensions::set_extension_enabled("plan-mode", true);
crate::extensions::plan_mode::reset_state_for_tests();
crate::extensions::plan_mode::set_enabled(true);
let dir = crate::core::settings_manager::agent_dir();
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
let shared = Rc::new(RefCell::new(App::new()));
assert!(!crate::core::extensions::command_busy_safe("todos"));
assert!(!crate::core::extensions::command_busy_safe("plan"));
assert!(!crate::core::extensions::command_busy_safe("nonexistent"));
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("/todos");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow_mut();
assert!(
st.runtime_followup_inbox.is_empty(),
"无 todos 时 /todos 不应进 followup 队列"
);
assert_eq!(st.runtime_steer_inbox.lock().unwrap().len(), 1);
assert_eq!(st.runtime_steer_inbox.lock().unwrap()[0].text(), "/todos");
assert!(!st.dock_visible, "空计划 /todos 不应打开面板");
}
shared
.borrow_mut()
.runtime_steer_inbox
.lock()
.unwrap()
.clear();
{
let mut st = shared.borrow_mut();
use crate::core::extensions::Extension;
let ext = crate::extensions::plan_mode::PlanMode::new();
ext.on_agent_event(&serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "assistant", "content": [{ "type": "text", "text": "Plan:\n1. Inspect code\n2. Verify result" }] }
]
}));
assert!(
crate::core::extensions::command_busy_safe("todos"),
"有 todos 时 /todos 应恢复 busy_safe"
);
st.busy = true;
st.editor.insert_text("/todos");
}
let _ = crate::core::extensions::take_pending_ui();
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow_mut();
assert!(st.dock_visible, "busy 时 /todos 应打开面板");
assert!(
st.runtime_followup_inbox.is_empty(),
"busy 时 /todos 不应入 followup 队列"
);
assert!(
st.runtime_steer_inbox.lock().unwrap().is_empty(),
"busy 时 /todos 不应入 steer 队列"
);
assert_eq!(st.editor.text(), "", "命令消费后编辑器应清空");
}
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("/plan");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow_mut();
assert_eq!(
st.runtime_followup_inbox.len(),
0,
"/plan 非 busy_safe 应进注入箱而非本地队列"
);
assert_eq!(st.runtime_steer_inbox.lock().unwrap().len(), 1);
assert_eq!(st.runtime_steer_inbox.lock().unwrap()[0].text(), "/plan");
}
shared.borrow_mut().busy = false;
crate::extensions::plan_mode::set_enabled(false);
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
while crate::core::extensions::take_pending_ui().is_some() {}
}
#[test]
fn busy_submit_dispatches_busy_safe_dock_cmd() {
let _g = test_agent_dir();
let _reg = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::extensions::plan_mode::ensure_registered();
crate::core::extensions::set_extension_enabled("plan-mode", true);
crate::extensions::plan_mode::reset_state_for_tests();
crate::extensions::plan_mode::set_enabled(true);
let dir = crate::core::settings_manager::agent_dir();
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
let shared = Rc::new(RefCell::new(App::new()));
assert!(is_busy_safe_command("dock"));
assert!(!is_busy_safe_command("go to the moon"));
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("/dock");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow_mut();
assert!(
st.runtime_followup_inbox.is_empty(),
"busy 时 /dock 不应入 followup 队列"
);
assert!(
st.runtime_steer_inbox.lock().unwrap().is_empty(),
"busy 时 /dock 不应入 steer 队列"
);
assert_eq!(st.editor.text(), "", "命令消费后编辑器应清空");
assert!(!st.dock_visible, "无内容不应打开面板");
let has_hint = st.system_messages.iter().any(|(_, s)| {
crate::modes::interactive::app::sys_spans_text(&s.spans).contains("No dock content")
});
assert!(has_hint, "busy 时 /dock 应显示无内容提示");
}
{
let mut st = shared.borrow_mut();
use crate::core::extensions::Extension;
let ext = crate::extensions::plan_mode::PlanMode::new();
ext.on_agent_event(&serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "assistant", "content": [{ "type": "text", "text": "Plan:\n1. Inspect code\n2. Verify result" }] }
]
}));
_ = st.handle_slash_command("todos");
assert!(st.dock_visible, "/todos 应打开面板");
_ = st.handle_slash_command("dock");
assert!(!st.dock_visible);
st.busy = true;
st.editor.insert_text("/dock");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow_mut();
assert!(st.dock_visible, "busy 时 /dock 应打开面板");
assert!(
st.runtime_steer_inbox.lock().unwrap().is_empty(),
"busy 时 /dock 不应入 steer 队列"
);
}
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("/dock");
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow_mut();
assert!(!st.dock_visible, "busy 时 /dock 应关闭面板");
assert!(
st.runtime_steer_inbox.lock().unwrap().is_empty(),
"busy 时 /dock 不应入 steer 队列"
);
}
shared.borrow_mut().busy = false;
crate::extensions::plan_mode::reset_state_for_tests();
crate::extensions::plan_mode::set_enabled(false);
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
while crate::core::extensions::take_pending_ui().is_some() {}
}
#[test]
fn busy_submit_applies_active_suggestion_before_busy_safe_check() {
let _g = test_agent_dir();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("/comp");
st.refresh_suggestions();
assert!(st.suggestion.active, "`/comp` 应弹出命令候选");
assert_eq!(st.suggestion.kind, SuggestionKind::Commands);
assert_eq!(
st.suggestion.items[st.suggestion.selected].name, "compact",
"默认选中项应为 compact"
);
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow_mut();
assert_eq!(
st.runtime_steer_inbox.lock().unwrap().len(),
1,
"非 busy_safe 命令应入 steer 队列"
);
let text = st.runtime_steer_inbox.lock().unwrap()[0].text();
assert_eq!(
text.trim(),
"/compact",
"入队文本应为面板选中的完整命令,而非前缀 /comp: {text:?}"
);
assert_eq!(st.editor.text(), "", "提交后编辑器应清空");
}
shared
.borrow_mut()
.runtime_steer_inbox
.lock()
.unwrap()
.clear();
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("/doc");
st.refresh_suggestions();
assert!(st.suggestion.active, "`/doc` 应弹出命令候选");
assert_eq!(
st.suggestion.items[st.suggestion.selected].name, "dock",
"默认选中项应为 dock"
);
}
handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
{
let st = shared.borrow_mut();
assert!(
st.runtime_steer_inbox.lock().unwrap().is_empty(),
"busy_safe 命令应立即执行,不入 steer 队列"
);
assert_eq!(st.editor.text(), "", "命令消费后编辑器应清空");
}
shared.borrow_mut().busy = false;
}
#[test]
fn busy_ctrl_c_clears_input_instead_of_interrupting() {
let _g = test_agent_dir();
let shared = Rc::new(RefCell::new(App::new()));
let ctrl_c = |shared: &Rc<RefCell<App>>| {
handle_event(
shared,
Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
)
};
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("follow-up to send");
}
assert!(
matches!(ctrl_c(&shared), KeyAction::Continue),
"有内容时 Ctrl+C 只清空输入,不中断"
);
{
let st = shared.borrow();
assert_eq!(st.editor.text(), "", "输入框应被清空");
assert!(st.busy, "busy 状态不应被终止");
assert!(!st.suggestion.active, "候选面板应随清空关闭");
assert!(st.dirty, "清空后必须置脏");
}
assert!(
matches!(ctrl_c(&shared), KeyAction::CancelPrompt),
"空输入框 Ctrl+C 中断 busy"
);
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.insert_text("/mod");
st.suggestion.active = true;
}
ctrl_c(&shared);
{
let st = shared.borrow();
assert_eq!(st.editor.text(), "");
assert!(!st.suggestion.active, "Ctrl+C 清空后候选应关闭");
assert!(st.busy);
}
shared.borrow_mut().busy = false;
}
#[test]
fn busy_submit_expands_skill_command() {
let _g = test_agent_dir();
let dir = tempfile::tempdir().unwrap();
let skill_md = dir.path().join("SKILL.md");
std::fs::write(&skill_md, "---\nname: demo\ndescription: d\n---\n# Body\n").unwrap();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.busy = true;
st.skills = vec![crate::core::skills::Skill {
name: "demo".to_string(),
description: "d".to_string(),
path: skill_md,
base_dir: dir.path().to_path_buf(),
disable_model_invocation: false,
source: "test".to_string(),
}];
st.editor.insert_text("/skill:demo do it");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
{
let st = shared.borrow();
let inbox = st.runtime_steer_inbox.lock().unwrap();
assert_eq!(inbox.len(), 1);
let text = inbox[0].text();
assert!(text.contains("<skill name=\"demo\""), "got: {text}");
assert!(text.contains("# Body"), "got: {text}");
assert!(text.ends_with("do it"), "got: {text}");
}
shared.borrow_mut().busy = false;
}
#[test]
fn busy_submit_expands_paste_marker() {
let _g = test_agent_dir();
let shared = Rc::new(RefCell::new(App::new()));
let big: String = (0..20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
{
let mut st = shared.borrow_mut();
st.busy = true;
st.editor.paste_text(&big);
assert_eq!(st.editor.text(), "[paste #1 +20 lines]");
}
press(&shared, KeyCode::Enter, KeyModifiers::NONE);
{
let st = shared.borrow();
let inbox = st.runtime_steer_inbox.lock().unwrap();
assert_eq!(inbox.len(), 1);
assert_eq!(inbox[0].text(), big, "steer 应是展开后的全文");
}
shared.borrow_mut().busy = false;
}
#[test]
fn esc_during_retry_requests_cancel() {
let _g = test_agent_dir();
let shared = Rc::new(RefCell::new(App::new()));
{
let mut st = shared.borrow_mut();
st.busy = true;
st.retry_ui = Some(RetryUi {
attempt: 1,
max: 3,
deadline: std::time::Instant::now() + std::time::Duration::from_secs(5),
last_refresh: std::time::Instant::now(),
ts: 1,
});
}
let action = handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
);
assert!(
matches!(action, KeyAction::CancelPrompt),
"重试期间 Esc 应返回 CancelPrompt"
);
shared.borrow_mut().busy = false;
}
#[test]
fn idle_escape_cancels_background_work() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = test_agent_dir();
let shared = Rc::new(RefCell::new(App::new()));
let token = tokio_util::sync::CancellationToken::new();
let guard = core::extensions::register_background_cancel("test-login", token.clone());
let action = handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
);
assert!(matches!(action, KeyAction::Continue));
assert!(token.is_cancelled(), "空闲时 Esc 应取消后台工作");
drop(guard);
let action = handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
);
assert!(matches!(action, KeyAction::Continue));
}
#[test]
fn busy_panel_takes_keys_before_busy_shortcuts() {
let _ad = test_agent_dir();
let shared = Rc::new(RefCell::new(App::new()));
let open_replace_panel = |id: u64| {
let mut st = shared.borrow_mut();
st.busy = true;
st.panel.open(
PanelKind::Custom(id),
"Replace goal?".to_string(),
vec![PanelItem::new("Replace".to_string(), "Replace".to_string())],
);
};
open_replace_panel(7);
let action = handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
assert!(matches!(action, KeyAction::Continue));
{
let st = shared.borrow();
assert!(!st.panel.active, "busy 态 Enter 应确认并关闭面板");
assert!(st.busy, "确认面板不应复位 busy");
assert_eq!(st.editor.text(), "", "不应把面板确认当成 steer 输入");
}
open_replace_panel(8);
let action = handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
);
assert!(
matches!(action, KeyAction::Continue),
"busy 态面板 Esc 应关面板而非中断"
);
assert!(!shared.borrow().panel.active, "Esc 应关闭面板");
open_replace_panel(9);
let action = handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
);
assert!(
matches!(action, KeyAction::Continue),
"busy 态面板 Ctrl+C 应关面板而非中断"
);
assert!(!shared.borrow().panel.active, "Ctrl+C 应关闭面板");
shared.borrow_mut().busy = false;
}
struct MenuOverlayExt;
impl crate::core::extensions::Extension for MenuOverlayExt {
fn name(&self) -> &str {
"menu-overlay-test-ext"
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<crate::core::extensions::ExtensionHook> {
vec![crate::core::extensions::ExtensionHook::Overlay]
}
fn overlay_view(
&self,
id: u64,
_cols: u16,
) -> Option<crate::core::extensions::OverlayView> {
(id == 4242)
.then(|| crate::core::extensions::OverlayView::inline("Workflows", Vec::new(), 6))
}
fn on_overlay_event(&self, _id: u64, _ev: &crate::core::extensions::OverlayEvent) -> bool {
true
}
fn on_ui_choice(&self, id: u64, choice: Option<String>) -> Option<String> {
if id == 4241 && choice.is_some() {
crate::core::extensions::request_ui(
crate::core::extensions::ExtensionUiRequest::ShowOverlay { id: 4242 },
);
}
None
}
}
#[test]
fn overlay_opened_from_menu_choice_closes_the_menu_panel() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
crate::core::extensions::register_extension(MenuOverlayExt);
let shared = Rc::new(RefCell::new(App::new()));
crate::core::extensions::request_ui(crate::core::extensions::ExtensionUiRequest::Select {
id: 4241,
title: "Sub-agents".to_string(),
options: vec![crate::core::extensions::SelectOption::new(
"workflows Workflows (1 runs)",
)],
});
crate::modes::interactive::maybe_handle_extension_ui(&shared);
assert!(shared.borrow().panel.active, "一级菜单应打开");
_ = handle_event(
&shared,
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
);
crate::modes::interactive::maybe_handle_extension_ui(&shared);
assert!(
!shared.borrow().panel.active,
"打开覆盖层后一级菜单必须关闭"
);
assert!(shared.borrow().overlay.is_some(), "覆盖层应打开");
crate::core::extensions::unregister_extension("menu-overlay-test-ext");
}
#[test]
fn show_overlay_closes_any_active_panel() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
while crate::core::extensions::take_pending_ui().is_some() {}
crate::core::extensions::register_extension(MenuOverlayExt);
let shared = Rc::new(RefCell::new(App::new()));
crate::core::extensions::request_ui(crate::core::extensions::ExtensionUiRequest::Select {
id: 4241,
title: "Sub-agents".to_string(),
options: vec![crate::core::extensions::SelectOption::new(
"view Fleet view",
)],
});
crate::modes::interactive::maybe_handle_extension_ui(&shared);
assert!(shared.borrow().panel.active, "面板应打开");
crate::core::extensions::request_ui(
crate::core::extensions::ExtensionUiRequest::ShowOverlay { id: 4242 },
);
crate::modes::interactive::maybe_handle_extension_ui(&shared);
assert!(!shared.borrow().panel.active, "覆盖层打开时面板必须已收起");
assert!(shared.borrow().overlay.is_some(), "覆盖层应打开");
crate::core::extensions::unregister_extension("menu-overlay-test-ext");
}
}