Files
galaxy/app/src/terminal/view_tests.rs
T

8233 lines
315 KiB
Rust

use std::any::Any;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::pin::pin;
use std::rc::Rc;
use std::str::FromStr;
use std::sync::Arc;
use chrono::Local;
use galaxy_cli::agent::Harness;
use galaxy_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START, C0};
use galaxyui::notification::UserNotification;
use galaxyui::platform::WindowStyle;
use galaxyui::{App, EntityIdSet, Presenter, ReadModel, WindowInvalidation};
use parking_lot::FairMutex;
use session_sharing_protocol::common::CLIAgentSessionState;
use super::*;
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, AIAgentExchange,
AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, RequestCommandOutputResult,
UserQueryMode,
};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::agent_view::toolbar_item::AgentToolbarItemKind;
use crate::ai::blocklist::agent_view::{
AgentViewEntryBlock, AgentViewEntryOrigin, AgentViewState, EnterAgentBlockAction,
ExitAgentViewError,
};
use crate::ai::blocklist::block::cli_controller::UserTakeOverReason;
use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarAction;
use crate::ai::blocklist::{
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, InputConfig, InputType, ResponseStream,
ResponseStreamId,
};
use crate::ai::cloud_environments::{
AmbientAgentEnvironment, CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel,
};
use crate::ai::llms::LLMId;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{CloudObjectMetadata, CloudObjectPermissions};
use crate::context_chips::prompt::Prompt;
use crate::editor::{AutosuggestionLocation, AutosuggestionType, CrdtOperation};
use crate::features::FeatureFlag;
use crate::pane_group::focus_state::PaneGroupFocusState;
use crate::pane_group::pane::PaneStack;
use crate::pane_group::{BackingView, TerminalPaneId};
use crate::server::ids::{ClientId, SyncId};
use crate::server::server_api::ai::SpawnAgentRequest;
use crate::settings::import::model::ImportedConfigModel;
use crate::settings::{AISettings, AppEditorSettings, WarpPromptSeparator};
use crate::terminal::alt_screen::should_intercept_mouse;
use crate::terminal::block_list_element::{SnackbarPoint, SnackbarTranslationMode};
use crate::terminal::block_list_viewport::{ClampingMode, ScrollLines};
use crate::terminal::cli_agent_sessions::event::{
CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType,
};
use crate::terminal::cli_agent_sessions::listener::CLIAgentSessionListener;
use crate::terminal::cli_agent_sessions::{
CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession,
CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel,
};
use crate::terminal::model::ansi::{self, BootstrappedValue, InitShellValue, PreexecValue};
use crate::terminal::model::block::AgentViewVisibility;
use crate::terminal::model::blocks::{insert_block, TotalIndex};
use crate::terminal::model::grid::Dimensions as _;
use crate::terminal::model::terminal_model::WithinBlock;
use crate::terminal::session_settings::AgentToolbarChipSelection;
use crate::terminal::shared_session::shared_handlers::{
apply_cli_agent_state_update, RemoteUpdateGuard,
};
use crate::terminal::shared_session::{SharedSessionSource, SharedSessionStatus};
use crate::terminal::view::agent_view::ENTER_AGAIN_TO_SEND_MESSAGE_ID;
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
use crate::terminal::view::load_ai_conversation::{
RestoreConversationEntryBehavior, RestoredAIConversation,
};
use crate::terminal::view::shared_session::ConversationEndedTombstoneView;
use crate::terminal::{CLIAgent, MockTerminalManager, TerminalManager, TerminalModel};
use crate::test_util::terminal::{
add_window_with_id_and_terminal, initialize_app_for_terminal_view,
};
use crate::test_util::{add_window_with_terminal, assert_eventually};
use crate::view_components::find::FindWithinBlockState;
use crate::workspace::ToastStack;
use crate::ActiveAgentViewsModel;
fn add_window_with_cloud_mode_terminal(app: &mut App) -> ViewHandle<TerminalView> {
let tips_model = app.add_model(|_| Default::default());
let (_, terminal) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
TerminalView::new_for_test_with_cloud_mode(tips_model, None, true, ctx)
});
terminal.update(app, |view, _| {
view.model.lock().set_is_dummy_cloud_mode_session(true);
});
terminal
}
fn has_pending_user_query_block(view: &TerminalView) -> bool {
let Some(view_id) = view.pending_user_query_view_id else {
return false;
};
view.rich_content_views.iter().any(|rich_content| {
rich_content.view_id() == view_id && rich_content.is_pending_user_query()
})
}
#[test]
fn automatic_command_monitor_start_is_deduplicated_per_block() {
let mut pending_starts = HashSet::new();
let block_id = BlockId::new();
assert!(super::register_command_monitor_start(
&mut pending_starts,
&block_id
));
assert!(!super::register_command_monitor_start(
&mut pending_starts,
&block_id
));
pending_starts.remove(&block_id);
assert!(super::register_command_monitor_start(
&mut pending_starts,
&block_id
));
}
#[test]
fn command_monitor_start_status_handles_timer_races() {
assert_eq!(
super::command_monitor_start_status(true, true, true, true),
super::CommandMonitorStartStatus::AlreadyMonitoring
);
assert_eq!(
super::command_monitor_start_status(false, false, true, false),
super::CommandMonitorStartStatus::WaitingForThreshold
);
assert_eq!(
super::command_monitor_start_status(false, true, false, false),
super::CommandMonitorStartStatus::Ready
);
assert_eq!(
super::command_monitor_start_status(false, false, false, false),
super::CommandMonitorStartStatus::NoLongerEligible
);
}
#[test]
fn agent_view_lifecycle_updates_input_mode() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.read(&app, |view, ctx| {
assert_eq!(
view.ai_input_model().as_ref(ctx).input_type(),
InputType::Shell
);
});
terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("agent view entry should succeed");
});
});
terminal.read(&app, |view, ctx| {
assert_eq!(
view.ai_input_model().as_ref(ctx).input_type(),
InputType::AI
);
});
terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller.exit_agent_view_without_confirmation(ctx)
});
});
terminal.read(&app, |view, ctx| {
assert_eq!(
view.ai_input_model().as_ref(ctx).input_type(),
InputType::Shell
);
});
});
}
#[test]
fn focus_reporting_writes_focus_events_in_normal_screen() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let pty_writes: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
let writes = pty_writes.clone();
app.update(|ctx| {
ctx.subscribe_to_view(&terminal, move |_, event, _| {
if let Event::WriteBytesToPty { bytes } = event {
writes.borrow_mut().push(bytes.to_vec());
}
});
});
terminal.update(&mut app, |view, ctx| {
let mut model = view.model.lock();
model.simulate_long_running_block("python3 /tmp/warp_focus_test.py", "");
assert!(!model.is_alt_screen_active());
ansi::Handler::set_mode(&mut *model, ansi::Mode::ReportFocusInOut);
assert!(model.is_term_mode_set(TermMode::FOCUS_IN_OUT));
drop(model);
assert!(view.should_report_focus(ctx));
view.maybe_report_focus_out(ctx);
view.maybe_report_focus_in(ctx);
});
assert_eq!(
*pty_writes.borrow(),
vec![
escape_sequences::EscCodes::FOCUS_OUT.to_vec(),
escape_sequences::EscCodes::FOCUS_IN.to_vec(),
]
);
})
}
fn input_operations_for_buffer_content(app: &mut App, content: &str) -> Vec<CrdtOperation> {
let terminal = add_window_with_terminal(app, None);
terminal.update(app, |view, ctx| {
view.input().update(ctx, |input, ctx| {
input.replace_buffer_content(content, ctx);
});
});
terminal.read(app, |view, ctx| {
view.input()
.as_ref(ctx)
.latest_buffer_operations()
.cloned()
.collect()
})
}
fn exchange_with_inputs(inputs: Vec<AIAgentInput>) -> AIAgentExchange {
AIAgentExchange {
id: AIAgentExchangeId::new(),
input: inputs,
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: HashSet::new(),
start_time: Local::now(),
finish_time: None,
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
response_initiator: None,
}
}
fn append_exchange_and_handle_event(
view: &mut TerminalView,
input: AIAgentInput,
ctx: &mut ViewContext<TerminalView>,
) -> (
AIConversationId,
TaskId,
AIAgentExchangeId,
ResponseStreamId,
) {
append_exchange_with_inputs_and_handle_event(view, vec![input], ctx)
}
fn append_exchange_with_inputs_and_handle_event(
view: &mut TerminalView,
inputs: Vec<AIAgentInput>,
ctx: &mut ViewContext<TerminalView>,
) -> (
AIConversationId,
TaskId,
AIAgentExchangeId,
ResponseStreamId,
) {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let (conversation_id, task_id, exchange_id, response_stream_id) =
history_model.update(ctx, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(view.view_id, false, false, false, ctx);
let task_id = history_model
.conversation(&conversation_id)
.expect("conversation should exist")
.get_root_task_id()
.clone();
let response_stream_id = ResponseStreamId::new_for_test();
let exchange = exchange_with_inputs(inputs);
let exchange_id = exchange.id;
history_model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_reassigned_exchange(&response_stream_id, exchange, view.view_id, ctx)
.expect("exchange should append");
(conversation_id, task_id, exchange_id, response_stream_id)
});
view.handle_ai_history_model_event(
history_model,
&BlocklistAIHistoryEvent::AppendedExchange {
exchange_id,
task_id: task_id.clone(),
terminal_surface_id: view.view_id,
conversation_id,
is_hidden: false,
response_stream_id: Some(response_stream_id.clone()),
},
ctx,
);
(conversation_id, task_id, exchange_id, response_stream_id)
}
fn update_exchange_input_and_handle_event(
view: &mut TerminalView,
conversation_id: AIConversationId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
inputs: Vec<AIAgentInput>,
ctx: &mut ViewContext<TerminalView>,
) {
let history_model = BlocklistAIHistoryModel::handle(ctx);
history_model.update(ctx, |history_model, ctx| {
let conversation = history_model
.conversation_mut(&conversation_id)
.expect("conversation should exist");
let mut exchange = conversation
.remove_exchange(exchange_id)
.expect("exchange should exist");
exchange.input = inputs;
conversation
.append_reassigned_exchange(&response_stream_id, exchange, view.view_id, ctx)
.expect("exchange should append");
});
view.handle_ai_history_model_event(
history_model,
&BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id,
terminal_surface_id: view.view_id,
conversation_id,
is_hidden: false,
},
ctx,
);
}
fn ai_block_count(view: &TerminalView) -> usize {
view.rich_content_views
.iter()
.filter(|rich_content| {
matches!(
rich_content.metadata(),
Some(RichContentMetadata::AIBlock(_))
)
})
.count()
}
fn agent_view_entry_count_for_conversation(
view: &TerminalView,
conversation_id: AIConversationId,
) -> usize {
view.rich_content_views
.iter()
.filter(|rich_content| {
matches!(
rich_content.metadata(),
Some(RichContentMetadata::AgentViewEntry(params))
if params.conversation_id == conversation_id
)
})
.count()
}
fn command_block_count_for_conversation(
view: &TerminalView,
conversation_id: AIConversationId,
) -> usize {
view.model
.lock()
.block_list()
.blocks()
.iter()
.filter(|block| {
matches!(
block.agent_view_visibility(),
AgentViewVisibility::Agent {
origin_conversation_id,
..
} if *origin_conversation_id == conversation_id
)
})
.count()
}
/// Bootstraps the terminal model with one completed block and one active long-running block.
fn bootstrap_with_long_running_block(view: &mut TerminalView) {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "zsh".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "zsh".to_owned(),
..Default::default()
});
model.simulate_block("ls", "file.txt");
model.simulate_long_running_block("long-command", "output");
}
/// Places the active block in agent-driving-but-not-monitoring state:
/// `requested_command_action_id` is set but `long_running_control_state` is None.
/// This simulates the window between when the agent writes the command to the
/// PTY and when `BlocklistAIHistoryEvent::CreatedSubtask` fires.
fn set_active_block_agent_driving(view: &mut TerminalView, conversation_id: AIConversationId) {
let action_id = AIAgentActionId::from("test-action".to_owned());
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_requested_command(action_id, None, conversation_id);
}
#[test]
fn automatic_monitor_delay_is_separate_from_long_running_classification() {
assert_eq!(COMMAND_AUTO_MONITOR_DELAY, Duration::from_secs(3));
assert_eq!(LONG_RUNNING_COMMAND_DURATION_MS, 50);
assert!(Duration::from_millis(LONG_RUNNING_COMMAND_DURATION_MS) < COMMAND_AUTO_MONITOR_DELAY);
}
#[test]
fn cli_subagent_exchange_creates_right_side_conversation_view() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let (block_id, conversation_id, task_id) = terminal.update(&mut app, |view, ctx| {
bootstrap_with_long_running_block(view);
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.start_new_conversation(view.view_id, false, false, false, ctx)
});
set_active_block_agent_driving(view, conversation_id);
let block_id = view.model.lock().active_block_id().clone();
let task_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history
.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
view.view_id,
ctx,
)
.expect("CLI monitor task should be created")
});
(block_id, conversation_id, task_id)
});
assert!(
!terminal.read(&app, |view, _| view
.cli_subagent_views
.contains_key(&block_id)),
"a task without an exchange must not construct a conversation view"
);
terminal.update(&mut app, |view, ctx| {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_task_exchange_for_test(
&task_id,
exchange_with_inputs(vec![AIAgentInput::ActionResult {
result: AIAgentActionResult {
id: AIAgentActionId::from("request-command-output".to_string()),
task_id: task_id.clone(),
result: AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot {
block_id: block_id.clone(),
command: "long-command".to_string(),
grid_contents: "output".to_string(),
cursor: String::new(),
is_alt_screen_active: false,
},
),
},
context: Default::default(),
}]),
view.view_id,
ctx,
)
.expect("CLI monitor exchange should be appended");
});
});
assert_eventually!(
terminal.read(&app, |view, _| {
view.cli_subagent_views.contains_key(&block_id)
&& view
.model
.lock()
.block_list()
.block_with_id(&block_id)
.is_some_and(|block| block.is_agent_monitoring())
}),
"CLI monitor exchange should construct the right-side conversation view"
);
});
}
#[test]
fn updated_conversation_metadata_refreshes_selected_conversation_pane_title() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(false);
let terminal = add_window_with_terminal(&mut app, None);
let conversation_id = AIConversationId::new();
terminal.update(&mut app, |view, ctx| {
let conversation = AIConversation::new_restored(
conversation_id,
vec![warp_multi_agent_api::Task {
id: "root-task".to_string(),
messages: vec![],
dependencies: None,
description: "Original title".to_string(),
summary: String::new(),
server_data: String::new(),
}],
None,
)
.expect("conversation should restore");
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.restore_conversations(view.view_id, vec![conversation], ctx);
});
view.ai_context_model.update(ctx, |context_model, ctx| {
context_model.set_pending_query_state_for_existing_conversation(
conversation_id,
AgentViewEntryOrigin::AgentViewBlock,
ctx,
);
});
view.update_pane_configuration(ctx);
assert_eq!(
view.pane_configuration.as_ref(ctx).title(),
"Original title"
);
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.apply_conversation_title(conversation_id, "Renamed title".to_string(), ctx)
});
view.handle_ai_history_model_event(
BlocklistAIHistoryModel::handle(ctx),
&BlocklistAIHistoryEvent::UpdatedConversationTitle {
terminal_surface_id: Some(view.view_id),
conversation_id,
title: "Renamed title".to_string(),
},
ctx,
);
assert_eq!(view.pane_configuration.as_ref(ctx).title(), "Renamed title");
});
})
}
struct TestTerminalManager {
model: Arc<FairMutex<TerminalModel>>,
_view: ViewHandle<TerminalView>,
}
impl TerminalManager for TestTerminalManager {
fn model(&self) -> Arc<FairMutex<TerminalModel>> {
self.model.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
/// Test to verify that blocks created through normal execution
/// have the correct local status set
#[test]
fn test_create_new_block_with_local_status() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// Set up a terminal with a local session
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
// Initialize a local session
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "bash".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "bash".to_owned(),
..Default::default()
});
});
assert_eventually!(
terminal.read(&app, |view, ctx| !view
.active_block_is_considered_remote(ctx)),
"Block should be local"
);
// No remote blocks should exist
assert_eventually!(
terminal.read(&app, |view, _ctx| !view.contains_restored_remote_blocks()),
"No remote blocks should exist"
);
// Update the view's flags
// view.update_focused_terminal_info(ctx);
assert_eventually!(
terminal.read(&app, |view, _ctx| !view.any_session_contains_remote_blocks),
"No remote blocks should exist"
);
// Now test with a remote session
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
// Create a new block with a remote session ID and remote_shell
model.init_shell(InitShellValue {
session_id: 1.into(),
shell: "bash".to_owned(),
user: "user".to_owned(),
hostname: "remote".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "bash".to_owned(),
..Default::default()
});
// Create a block in the remote session
model.simulate_block("echo remote", "remote output");
});
// Verify block is non-local (remote)
assert_eventually!(
terminal.read(&app, |view, ctx| view
.active_block_is_considered_remote(ctx)),
"Block should be non-local (remote)"
);
// Remote blocks should be detected
assert_eventually!(
terminal.read(&app, |view, _ctx| view.any_session_contains_remote_blocks),
"Remote blocks should be detected"
);
})
}
#[test]
fn submit_cli_agent_rich_input_restores_unlocked_input_config() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_agent_rich_input = FeatureFlag::CLIAgentRichInput.override_enabled(true);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
let _ = settings
.auto_dismiss_rich_input_after_submit
.set_value(true, ctx);
});
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.input.update(ctx, |input, ctx| {
input.ai_input_model().update(ctx, |ai_input, ctx| {
ai_input.set_input_config(
InputConfig {
input_type: InputType::Shell,
is_locked: false,
},
true,
None,
ctx,
);
});
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Droid,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
view.submit_cli_agent_rich_input("hello!".to_owned(), ctx);
assert!(!view.has_active_cli_agent_input_session(ctx));
});
terminal.read(&app, |view, ctx| {
let input = view.input.as_ref(ctx);
let ai_input_model = input.ai_input_model().as_ref(ctx);
assert_eq!(
ai_input_model.input_config(),
InputConfig {
input_type: InputType::Shell,
is_locked: false,
}
);
assert!(input.editor().as_ref(ctx).buffer_text(ctx).is_empty());
});
})
}
#[test]
fn unregister_cli_agent_session_restores_unlocked_input_config() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_agent_rich_input = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.input.update(ctx, |input, ctx| {
input.ai_input_model().update(ctx, |ai_input, ctx| {
ai_input.set_input_config(
InputConfig {
input_type: InputType::Shell,
is_locked: false,
},
true,
None,
ctx,
);
});
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.remove_session(view.view_id, ctx);
});
assert!(!view.has_active_cli_agent_input_session(ctx));
assert!(CLIAgentSessionsModel::as_ref(ctx)
.session(view.view_id)
.is_none());
});
terminal.read(&app, |view, ctx| {
let input = view.input.as_ref(ctx);
let ai_input_model = input.ai_input_model().as_ref(ctx);
assert_eq!(
ai_input_model.input_config(),
InputConfig {
input_type: InputType::Shell,
is_locked: false,
}
);
assert!(input.editor().as_ref(ctx).buffer_text(ctx).is_empty());
});
})
}
#[test]
fn clear_buffer_action_in_fullscreen_agent_view_starts_new_conversation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let original_conversation_id = terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("Should be able to enter agent view")
})
});
terminal.update(&mut app, |view, ctx| {
view.handle_action(&TerminalAction::ClearBuffer, ctx);
});
terminal.update(&mut app, |view, ctx| {
let new_conversation_id = view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
.expect("agent view should still be active");
assert_ne!(new_conversation_id, original_conversation_id);
});
})
}
fn agent_jump_user_query(query: &str) -> AIAgentInput {
AIAgentInput::UserQuery {
query: query.to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
}
}
#[test]
fn jump_to_latest_agent_message_no_ops_when_agent_view_disabled() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// Create a conversation while the agent view feature is enabled...
let agent_view = FeatureFlag::AgentView.override_enabled(true);
terminal.update(&mut app, |view, ctx| {
append_exchange_and_handle_event(view, agent_jump_user_query("hi"), ctx);
});
drop(agent_view);
// ...then turn the feature off: the action must be inert even though a
// conversation with a visible exchange exists.
let _agent_view_off = FeatureFlag::AgentView.override_enabled(false);
terminal.update(&mut app, |view, ctx| {
view.jump_to_latest_agent_message(ctx);
});
terminal.read(&app, |view, ctx| {
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
assert_eq!(view.pending_agent_scroll_target, None);
});
})
}
#[test]
fn jump_to_latest_agent_message_no_ops_without_conversations() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
// No conversations exist, so there is nothing to jump to.
terminal.update(&mut app, |view, ctx| {
view.jump_to_latest_agent_message(ctx);
});
terminal.read(&app, |view, ctx| {
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
assert_eq!(view.pending_agent_scroll_target, None);
});
})
}
#[test]
fn jump_to_latest_agent_message_enters_agent_view_and_records_pending_scroll() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let (conversation_id, _task_id, exchange_id, _stream_id) = terminal
.update(&mut app, |view, ctx| {
append_exchange_and_handle_event(view, agent_jump_user_query("hi"), ctx)
});
terminal.update(&mut app, |view, ctx| {
view.jump_to_latest_agent_message(ctx);
});
terminal.read(&app, |view, ctx| {
let controller = view.agent_view_controller().as_ref(ctx);
match controller.agent_view_state() {
AgentViewState::Active { origin, .. } => {
assert_eq!(
controller.agent_view_state().active_conversation_id(),
Some(conversation_id)
);
assert_eq!(*origin, AgentViewEntryOrigin::JumpToLatestAgentMessage);
}
state => panic!("expected an active agent view, got {state:?}"),
}
// Entering from the terminal mounts the target block on a later frame,
// so the scroll target is recorded for `after_terminal_view_layout`.
assert_eq!(view.pending_agent_scroll_target, Some(exchange_id));
});
})
}
#[test]
fn jump_to_latest_agent_message_targets_latest_visible_exchange() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let (conversation_id, _task_id, _first_exchange_id, _stream_id) = terminal
.update(&mut app, |view, ctx| {
append_exchange_and_handle_event(view, agent_jump_user_query("first"), ctx)
});
// Append a second, newer exchange to the same conversation.
let second_exchange_id = terminal.update(&mut app, |view, ctx| {
let history_model = BlocklistAIHistoryModel::handle(ctx);
history_model.update(ctx, |history_model, ctx| {
let response_stream_id = ResponseStreamId::new_for_test();
let exchange = exchange_with_inputs(vec![agent_jump_user_query("second")]);
let exchange_id = exchange.id;
history_model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_reassigned_exchange(&response_stream_id, exchange, view.view_id, ctx)
.expect("exchange should append");
exchange_id
})
});
terminal.update(&mut app, |view, ctx| {
view.jump_to_latest_agent_message(ctx);
});
terminal.read(&app, |view, _ctx| {
// The jump targets the latest visible exchange, not the first one.
assert_eq!(view.pending_agent_scroll_target, Some(second_exchange_id));
});
})
}
#[test]
fn jump_to_latest_agent_message_scrolls_without_re_entering_when_already_in_view() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let (conversation_id, _task_id, _exchange_id, _stream_id) = terminal
.update(&mut app, |view, ctx| {
append_exchange_and_handle_event(view, agent_jump_user_query("hi"), ctx)
});
// First jump enters the agent view from the terminal and records a pending
// scroll target; simulate the layout pass consuming it.
terminal.update(&mut app, |view, ctx| {
view.jump_to_latest_agent_message(ctx);
view.pending_agent_scroll_target = None;
});
// Second jump: already in this conversation's agent view, so it scrolls
// directly without re-entering or recording a new pending target.
terminal.update(&mut app, |view, ctx| {
view.jump_to_latest_agent_message(ctx);
});
terminal.read(&app, |view, ctx| {
let active_conversation_id = view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
.expect("agent view should be active");
assert_eq!(active_conversation_id, conversation_id);
assert_eq!(view.pending_agent_scroll_target, None);
});
})
}
#[test]
fn restoring_conversation_to_new_pane_transfers_blocks_from_previous_terminal_surface() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let original_view = add_window_with_terminal(&mut app, None);
let restored_view = add_window_with_terminal(&mut app, None);
let original_view_id = original_view.read(&app, |view, _| view.view_id);
let restored_view_id = restored_view.read(&app, |view, _| view.view_id);
let conversation_id = original_view.update(&mut app, |view, ctx| {
let (conversation_id, _, _, _) = append_exchange_with_inputs_and_handle_event(
view,
vec![AIAgentInput::UserQuery {
query: "first query".to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
}],
ctx,
);
view.insert_agent_view_entry_block(
AgentViewEntryBlockParams {
conversation_id,
is_new: false,
is_restored: false,
origin: AgentViewEntryOrigin::AgentViewBlock,
agent_view_controller: view.agent_view_controller().clone(),
},
RichContentInsertionPosition::Append {
insert_below_long_running_block: false,
},
ctx,
);
{
let mut model = view.model.lock();
model.simulate_block("agent command", "agent output");
let command_block_index = model.block_list().blocks().len() - 2;
model.block_list_mut().blocks_mut()[command_block_index]
.set_conversation_id(conversation_id);
}
conversation_id
});
original_view.read(&app, |view, _| {
assert_eq!(ai_block_count(view), 1);
assert_eq!(
agent_view_entry_count_for_conversation(view, conversation_id),
1
);
assert_eq!(
command_block_count_for_conversation(view, conversation_id),
1
);
});
let restored_conversation =
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
history
.conversation(&conversation_id)
.cloned()
.expect("conversation should exist")
});
restored_view.update(&mut app, |view, ctx| {
view.restore_conversation_after_view_creation(
RestoredAIConversation::new(restored_conversation),
true,
RestoreConversationEntryBehavior::EnterRestoredConversation,
ctx,
);
});
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
let original_view_live_conversation_ids = history
.all_live_conversations_for_terminal_surface(original_view_id)
.map(|conversation| conversation.id())
.collect::<Vec<_>>();
let restored_view_live_conversation_ids = history
.all_live_conversations_for_terminal_surface(restored_view_id)
.map(|conversation| conversation.id())
.collect::<Vec<_>>();
assert_eq!(
history.terminal_surface_id_for_conversation(&conversation_id),
Some(restored_view_id)
);
assert!(original_view_live_conversation_ids.is_empty());
assert_eq!(restored_view_live_conversation_ids, vec![conversation_id]);
});
original_view.read(&app, |view, _| {
assert_eq!(ai_block_count(view), 0);
assert_eq!(
agent_view_entry_count_for_conversation(view, conversation_id),
1
);
assert_eq!(
command_block_count_for_conversation(view, conversation_id),
0
);
});
restored_view.read(&app, |view, _| {
assert_eq!(ai_block_count(view), 1);
});
})
}
#[test]
fn clicking_old_banner_for_open_conversation_focuses_current_terminal_surface_without_transferring_blocks(
) {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let original_view = add_window_with_terminal(&mut app, None);
let restored_view = add_window_with_terminal(&mut app, None);
let original_window_id = app.read(|ctx| original_view.window_id(ctx));
let original_view_id = original_view.read(&app, |view, _| view.view_id);
let restored_view_id = restored_view.read(&app, |view, _| view.view_id);
let conversation_id = original_view.update(&mut app, |view, ctx| {
let (conversation_id, _, _, _) = append_exchange_with_inputs_and_handle_event(
view,
vec![AIAgentInput::UserQuery {
query: "first query".to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
}],
ctx,
);
view.insert_agent_view_entry_block(
AgentViewEntryBlockParams {
conversation_id,
is_new: false,
is_restored: false,
origin: AgentViewEntryOrigin::AgentViewBlock,
agent_view_controller: view.agent_view_controller().clone(),
},
RichContentInsertionPosition::Append {
insert_below_long_running_block: false,
},
ctx,
);
conversation_id
});
let restored_conversation =
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
history
.conversation(&conversation_id)
.cloned()
.expect("conversation should exist")
});
restored_view.update(&mut app, |view, ctx| {
view.restore_conversation_after_view_creation(
RestoredAIConversation::new(restored_conversation),
true,
RestoreConversationEntryBehavior::PreserveAgentViewState,
ctx,
);
assert_eq!(
view.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id(),
None
);
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
Some(conversation_id),
AgentViewEntryOrigin::AgentViewBlock,
ctx,
)
.expect("restored view should enter agent view");
});
});
let restored_agent_view_controller =
restored_view.read(&app, |view, _| view.agent_view_controller().clone());
let restored_active_session =
restored_view.read(&app, |view, _| view.active_session().clone());
ActiveAgentViewsModel::handle(&app).update(&mut app, |active_views, ctx| {
active_views.register_agent_view_controller(
&restored_agent_view_controller,
&restored_active_session,
restored_view_id,
ctx,
);
});
ActiveAgentViewsModel::handle(&app).read(&app, |active_views, ctx| {
assert_eq!(
active_views.terminal_view_id_for_conversation(conversation_id, ctx),
Some(restored_view_id)
);
});
original_view.read(&app, |view, _| {
assert_eq!(ai_block_count(view), 0);
assert_eq!(
agent_view_entry_count_for_conversation(view, conversation_id),
1
);
});
restored_view.read(&app, |view, _| {
assert_eq!(ai_block_count(view), 1);
});
let entry_blocks = app
.views_of_type::<AgentViewEntryBlock>(original_window_id)
.expect("original window should contain agent entry block");
assert_eq!(entry_blocks.len(), 1);
entry_blocks[0].update(&mut app, |block, ctx| {
block.handle_action(
&EnterAgentBlockAction::EnterAgentMode { conversation_id },
ctx,
);
});
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
assert_eq!(
history.terminal_surface_id_for_conversation(&conversation_id),
Some(restored_view_id)
);
assert!(history
.all_live_conversations_for_terminal_surface(original_view_id)
.next()
.is_none());
});
original_view.read(&app, |view, _| {
assert_eq!(ai_block_count(view), 0);
assert_eq!(
agent_view_entry_count_for_conversation(view, conversation_id),
1
);
});
restored_view.read(&app, |view, _| {
assert_eq!(ai_block_count(view), 1);
});
})
}
#[test]
fn appended_exchange_renders_in_current_terminal_surface_after_conversation_transfer() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let original_view = add_window_with_terminal(&mut app, None);
let transferred_view = add_window_with_terminal(&mut app, None);
let original_view_id = original_view.read(&app, |view, _| view.view_id);
let transferred_view_id = transferred_view.read(&app, |view, _| view.view_id);
let conversation_id = original_view.update(&mut app, |view, ctx| {
let (conversation_id, _, _, _) = append_exchange_with_inputs_and_handle_event(
view,
vec![AIAgentInput::UserQuery {
query: "first query".to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
}],
ctx,
);
conversation_id
});
let restored_conversation =
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
history
.conversation(&conversation_id)
.cloned()
.expect("conversation should exist")
});
transferred_view.update(&mut app, |view, ctx| {
view.restore_conversation_after_view_creation(
RestoredAIConversation::new(restored_conversation),
true,
RestoreConversationEntryBehavior::EnterRestoredConversation,
ctx,
);
});
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
assert_eq!(
history.terminal_surface_id_for_conversation(&conversation_id),
Some(transferred_view_id)
);
});
let original_view_block_count_after_restore =
original_view.read(&app, |view, _| ai_block_count(view));
let transferred_view_block_count_after_restore =
transferred_view.read(&app, |view, _| ai_block_count(view));
assert_eq!(transferred_view_block_count_after_restore, 1);
let (task_id, exchange_id, response_stream_id) = BlocklistAIHistoryModel::handle(&app)
.update(&mut app, |history, ctx| {
let conversation = history
.conversation_mut(&conversation_id)
.expect("conversation should exist");
let task_id = conversation.get_root_task_id().clone();
let response_stream_id = ResponseStreamId::new_for_test();
let exchange = exchange_with_inputs(vec![AIAgentInput::UserQuery {
query: "follow up".to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
}]);
let exchange_id = exchange.id;
conversation
.append_reassigned_exchange(
&response_stream_id,
exchange,
original_view_id,
ctx,
)
.expect("exchange should append");
(task_id, exchange_id, response_stream_id)
});
original_view.update(&mut app, |view, ctx| {
view.handle_ai_history_model_event(
BlocklistAIHistoryModel::handle(ctx),
&BlocklistAIHistoryEvent::AppendedExchange {
exchange_id,
task_id: task_id.clone(),
terminal_surface_id: original_view_id,
conversation_id,
is_hidden: false,
response_stream_id: Some(response_stream_id.clone()),
},
ctx,
);
});
transferred_view.update(&mut app, |view, ctx| {
view.handle_ai_history_model_event(
BlocklistAIHistoryModel::handle(ctx),
&BlocklistAIHistoryEvent::AppendedExchange {
exchange_id,
task_id,
terminal_surface_id: original_view_id,
conversation_id,
is_hidden: false,
response_stream_id: Some(response_stream_id),
},
ctx,
);
});
original_view.read(&app, |view, _| {
assert_eq!(
ai_block_count(view),
original_view_block_count_after_restore
);
});
transferred_view.read(&app, |view, _| {
assert_eq!(
ai_block_count(view),
transferred_view_block_count_after_restore + 1
);
});
})
}
#[test]
fn command_first_word_and_suffix_preserves_leading_whitespace() {
assert_eq!(
command_first_word_and_suffix(" myssh arg"),
Some(("myssh", " arg"))
);
}
#[test]
fn command_first_word_and_suffix_handles_alias_without_args() {
assert_eq!(
command_first_word_and_suffix(" myssh"),
Some(("myssh", ""))
);
}
#[test]
fn escape_pops_nested_cloud_agent_view_with_long_running_command() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let parent_terminal = add_window_with_terminal(&mut app, None);
let cloud_terminal = add_window_with_cloud_mode_terminal(&mut app);
let parent_view = parent_terminal.clone();
let cloud_view = cloud_terminal.clone();
let parent_model = parent_terminal.read(&app, |view, _| view.model.clone());
let cloud_model = cloud_terminal.read(&app, |view, _| view.model.clone());
let pane_stack = app.update(move |ctx| {
let parent_manager = ctx.add_model(|_| {
let manager: Box<dyn TerminalManager> = Box::new(TestTerminalManager {
model: parent_model,
_view: parent_view.clone(),
});
manager
});
let cloud_manager = ctx.add_model(|_| {
let manager: Box<dyn TerminalManager> = Box::new(TestTerminalManager {
model: cloud_model,
_view: cloud_view.clone(),
});
manager
});
let pane_stack = ctx.add_model(|ctx| PaneStack::new(parent_manager, parent_view, ctx));
pane_stack.update(ctx, |stack, ctx| {
stack.push(cloud_manager, cloud_view, ctx);
});
pane_stack
});
cloud_terminal.update(&mut app, |view, ctx| {
view.enter_agent_view_for_new_conversation(None, AgentViewEntryOrigin::CloudAgent, ctx);
view.model
.lock()
.simulate_long_running_block("sleep 10", "running");
assert!(view.is_ambient_agent_session(ctx));
assert!(view.is_nested_cloud_mode(ctx));
assert_eq!(
view.agent_view_controller()
.as_ref(ctx)
.can_exit_agent_view(),
Ok(())
);
});
assert_eq!(
app.read_model(&pane_stack, |stack, _| stack.active_view().id()),
cloud_terminal.id()
);
cloud_terminal.update(&mut app, |view, ctx| {
view.handle_input_event(&InputEvent::Escape, ctx);
});
assert_eq!(
app.read_model(&pane_stack, |stack, _| stack.active_view().id()),
parent_terminal.id()
);
})
}
#[test]
fn escape_does_not_exit_root_cloud_agent_view_with_long_running_command() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.update(&mut app, |view, ctx| {
view.enter_agent_view_for_new_conversation(None, AgentViewEntryOrigin::CloudAgent, ctx);
view.model
.lock()
.simulate_long_running_block("claude", "running");
view.handle_input_event(&InputEvent::Escape, ctx);
// Root cloud-mode pane has no parent terminal to return to,
// so Escape is a no-op and agent view stays active.
assert!(view.agent_view_controller().as_ref(ctx).is_active());
});
})
}
#[test]
fn escape_does_not_exit_local_agent_view_with_long_running_command() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.enter_agent_view_for_new_conversation(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
);
view.model
.lock()
.simulate_long_running_block("sleep 10", "running");
assert!(matches!(
view.agent_view_controller()
.as_ref(ctx)
.can_exit_agent_view(),
Err(ExitAgentViewError::LongRunningCommand)
));
view.handle_input_event(&InputEvent::Escape, ctx);
assert!(view.agent_view_controller().as_ref(ctx).is_active());
});
})
}
#[test]
fn root_cloud_mode_pane_sets_root_cloud_mode_context_key() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
app.add_singleton_model(ImportedConfigModel::new);
FeatureFlag::AgentView.set_enabled(true);
FeatureFlag::CloudMode.set_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
let nested_terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.read(&app, |view, ctx| {
assert!(view
.keymap_context(ctx)
.set
.contains(init::ROOT_CLOUD_MODE_PANE_KEY));
});
let root_view = terminal.clone();
let nested_view = nested_terminal.clone();
let root_model = terminal.read(&app, |view, _| view.model.clone());
let nested_model = nested_terminal.read(&app, |view, _| view.model.clone());
let _pane_stack = app.update(move |ctx| {
let root_manager = ctx.add_model(|_| {
let manager: Box<dyn TerminalManager> = Box::new(TestTerminalManager {
model: root_model,
_view: root_view.clone(),
});
manager
});
let nested_manager = ctx.add_model(|_| {
let manager: Box<dyn TerminalManager> = Box::new(TestTerminalManager {
model: nested_model,
_view: nested_view.clone(),
});
manager
});
let pane_stack = ctx.add_model(|ctx| PaneStack::new(root_manager, root_view, ctx));
pane_stack.update(ctx, |stack, ctx| {
stack.push(nested_manager, nested_view, ctx);
});
pane_stack
});
terminal.read(&app, |view, ctx| {
assert!(view
.keymap_context(ctx)
.set
.contains(init::ROOT_CLOUD_MODE_PANE_KEY));
});
nested_terminal.read(&app, |view, ctx| {
assert!(!view
.keymap_context(ctx)
.set
.contains(init::ROOT_CLOUD_MODE_PANE_KEY));
});
});
}
#[test]
fn set_input_mode_agent_does_not_enter_local_agent_from_root_cloud_mode_pane() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
FeatureFlag::CloudMode.set_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.update(&mut app, |view, ctx| {
view.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.update(ctx, |model, ctx| {
model.enter_setup(ctx);
});
view.model
.lock()
.set_shared_session_status(SharedSessionStatus::FinishedViewer);
});
terminal.update(&mut app, |view, ctx| {
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
view.handle_action(&TerminalAction::SetInputModeAgent, ctx);
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
});
});
}
#[test]
fn cloud_mode_v1_agent_prefixed_query_spawns_cloud_agent() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_mode = FeatureFlag::AgentMode.override_enabled(true);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let _cloud_mode_input_v2 = FeatureFlag::CloudModeInputV2.override_enabled(false);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
let input = terminal.read(&app, |view, _| view.input.clone());
input.update(&mut app, |input, ctx| {
input.ai_input_model().update(ctx, |ai_input, ctx| {
ai_input.set_input_config(
InputConfig {
input_type: InputType::AI,
is_locked: false,
},
true,
None,
ctx,
);
});
assert!(!input.is_cloud_mode_input_v2_composing(ctx));
input.replace_buffer_content("/agent fix the tests", ctx);
input.input_enter(ctx);
});
terminal.read(&app, |view, ctx| {
let ambient_model = view
.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.as_ref(ctx);
let request = ambient_model
.request()
.expect("enter should submit through the cloud agent spawn path");
assert_eq!(request.prompt.as_deref(), Some("/agent fix the tests"));
assert_eq!(request.mode, UserQueryMode::Normal);
assert!(input.as_ref(ctx).buffer_text(ctx).is_empty());
});
});
}
#[test]
fn cloud_mode_v2_agent_prefixed_query_spawns_cloud_agent() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_mode = FeatureFlag::AgentMode.override_enabled(true);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let _cloud_mode_input_v2 = FeatureFlag::CloudModeInputV2.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
let input = terminal.read(&app, |view, _| view.input.clone());
// The cloud mode v2 submit path now opens a create-environment modal if
// no environment is selected. Register a stub environment and select it
// so the test exercises the spawn path instead of the modal-open path.
let env_id = register_test_cloud_environment(&mut app);
terminal.update(&mut app, |view, ctx| {
view.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.update(ctx, |model, ctx| {
model.set_environment_id(Some(env_id), ctx);
});
});
input.update(&mut app, |input, ctx| {
assert!(input.is_cloud_mode_input_v2_composing(ctx));
input.replace_buffer_content("/agent fix the tests", ctx);
input.input_enter(ctx);
});
terminal.read(&app, |view, ctx| {
let ambient_model = view
.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.as_ref(ctx);
let request = ambient_model
.request()
.expect("enter should submit through the cloud agent spawn path");
assert_eq!(request.prompt.as_deref(), Some("/agent fix the tests"));
assert_eq!(request.mode, UserQueryMode::Normal);
assert!(input.as_ref(ctx).buffer_text(ctx).is_empty());
});
});
}
/// Registers a stub `CloudAmbientAgentEnvironment` in the test `CloudModel` and
/// returns its `SyncId` so the caller can attach it to an ambient view model.
fn register_test_cloud_environment(app: &mut App) -> SyncId {
let sync_id = SyncId::ClientId(ClientId::new());
app.update(|ctx| {
let environment = AmbientAgentEnvironment::new(
"Test Environment".to_string(),
None,
vec![],
"ubuntu:latest".to_string(),
vec![],
);
let object = CloudAmbientAgentEnvironment::new(
sync_id,
CloudAmbientAgentEnvironmentModel::new(environment),
CloudObjectMetadata::mock(),
CloudObjectPermissions::mock_personal(),
);
CloudModel::handle(ctx).update(ctx, |model, ctx| {
model.create_object(sync_id, object, ctx);
});
});
sync_id
}
#[test]
fn fresh_cloud_mode_setup_enters_agent_view_when_view_pending() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.update(&mut app, |view, ctx| {
view.model
.lock()
.set_shared_session_status(SharedSessionStatus::ViewPending);
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
view.enter_ambient_agent_setup(Some("write the tests".to_string()), ctx);
assert!(view.agent_view_controller().as_ref(ctx).is_active());
assert_eq!(view.input().as_ref(ctx).buffer_text(ctx), "write the tests");
});
});
}
#[test]
fn legacy_cloud_mode_waiting_for_session_keeps_input_visible() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(false);
let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(false);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.update(&mut app, |view, ctx| {
view.model
.lock()
.set_shared_session_status(SharedSessionStatus::ViewPending);
view.enter_ambient_agent_setup(Some("write the tests".to_string()), ctx);
view.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.update(ctx, |model, ctx| {
model.spawn_agent_with_request(
SpawnAgentRequest {
prompt: Some("write the tests".to_string()),
mode: UserQueryMode::Normal,
config: None,
title: None,
team: None,
agent_identity_uid: None,
skill: None,
attachments: vec![],
interactive: None,
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
conversation_id: None,
initial_snapshot_token: None,
snapshot_disabled: None,
orchestration_handoff: None,
},
ctx,
);
});
let model = view.model.lock();
assert!(ambient_agent::is_cloud_agent_pre_first_exchange(
view.ambient_agent_view_model.as_ref(),
&view.agent_view_controller,
&model,
ctx,
));
assert!(view.is_input_box_visible(&model, ctx));
});
});
}
#[test]
fn shared_third_party_viewer_sync_enters_agent_view_and_retags_existing_block() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _agent_harness = FeatureFlag::AgentHarness.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.update(&mut app, |view, ctx| {
let harness_block_id = {
let mut model = view.model.lock();
model.set_shared_session_source(SharedSessionSource::ambient_agent(None));
model.set_shared_session_status(SharedSessionStatus::ActiveViewer {
role: Default::default(),
});
model.simulate_block("claude", "running");
model
.block_list()
.blocks()
.iter()
.find(|block| block.command_to_string() == "claude")
.expect("harness block should exist")
.id()
.clone()
};
view.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.update(ctx, |model, ctx| {
model.set_harness(Harness::Claude, ctx);
});
let conversation_id = view
.sync_agent_view_for_shared_third_party_viewer(ctx)
.expect("shared third-party viewer should sync");
let idempotent_conversation_id = view
.sync_agent_view_for_shared_third_party_viewer(ctx)
.expect("sync should be idempotent");
assert_eq!(conversation_id, idempotent_conversation_id);
let controller = view.agent_view_controller().as_ref(ctx);
match controller.agent_view_state() {
AgentViewState::Active { origin, .. } => {
assert_eq!(
controller.agent_view_state().active_conversation_id(),
Some(conversation_id)
);
assert_eq!(*origin, AgentViewEntryOrigin::ThirdPartyCloudAgent);
}
state => panic!("expected active agent view, got {state:?}"),
}
let model = view.model.lock();
let block = model
.block_list()
.block_with_id(&harness_block_id)
.expect("harness block should still exist");
assert!(!block.should_hide_block(model.block_list().agent_view_state()));
match block.agent_view_visibility() {
AgentViewVisibility::Terminal {
conversation_ids,
pending_conversation_ids,
} => {
assert!(pending_conversation_ids.is_empty());
assert!(conversation_ids.contains(&conversation_id));
}
visibility => panic!("expected terminal block visibility, got {visibility:?}"),
}
});
});
}
#[test]
fn shared_third_party_viewer_syncs_from_viewer_harness_updated_when_harness_unchanged() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _agent_harness = FeatureFlag::AgentHarness.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.update(&mut app, |view, ctx| {
let harness_block_id = {
let mut model = view.model.lock();
model.set_shared_session_source(SharedSessionSource::ambient_agent(None));
model.set_shared_session_status(SharedSessionStatus::ActiveViewer {
role: Default::default(),
});
model.simulate_block("claude", "running");
model
.block_list()
.blocks()
.iter()
.find(|block| block.command_to_string() == "claude")
.expect("harness block should exist")
.id()
.clone()
};
view.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.update(ctx, |model, ctx| {
model.set_harness(Harness::Claude, ctx);
model.set_harness(Harness::Claude, ctx);
});
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
view.handle_ambient_agent_event(
&AmbientAgentViewModelEvent::ViewerHarnessResolved,
ctx,
);
let controller = view.agent_view_controller().as_ref(ctx);
let AgentViewState::Active { origin, .. } = controller.agent_view_state() else {
panic!("expected active agent view");
};
let conversation_id = controller
.agent_view_state()
.active_conversation_id()
.expect("active agent view should select a conversation");
assert_eq!(*origin, AgentViewEntryOrigin::ThirdPartyCloudAgent);
let model = view.model.lock();
let block = model
.block_list()
.block_with_id(&harness_block_id)
.expect("harness block should still exist");
assert!(!block.should_hide_block(model.block_list().agent_view_state()));
match block.agent_view_visibility() {
AgentViewVisibility::Terminal {
conversation_ids,
pending_conversation_ids,
} => {
assert!(pending_conversation_ids.is_empty());
assert!(conversation_ids.contains(&conversation_id));
}
visibility => panic!("expected terminal block visibility, got {visibility:?}"),
}
});
});
}
#[test]
fn shared_third_party_viewer_syncs_from_cli_agent_state_without_ambient_model() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _agent_harness = FeatureFlag::AgentHarness.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let harness_block_id = terminal.update(&mut app, |view, _| {
assert!(view.ambient_agent_view_model().is_none());
let mut model = view.model.lock();
model.set_shared_session_source(SharedSessionSource::ambient_agent(None));
model.set_shared_session_status(SharedSessionStatus::ActiveViewer {
role: Default::default(),
});
model.simulate_block("claude", "running");
model
.block_list()
.blocks()
.iter()
.find(|block| block.command_to_string() == "claude")
.expect("harness block should exist")
.id()
.clone()
});
app.update(|ctx| {
let guard = RemoteUpdateGuard::new();
let active_update = guard.start_remote_update();
apply_cli_agent_state_update(
&terminal.downgrade(),
&CLIAgentSessionState::Active {
cli_agent: CLIAgent::Claude.to_serialized_name(),
is_rich_input_open: false,
},
&active_update,
ctx,
);
});
terminal.read(&app, |view, ctx| {
let controller = view.agent_view_controller().as_ref(ctx);
let AgentViewState::Active { origin, .. } = controller.agent_view_state() else {
panic!("expected active agent view");
};
let conversation_id = controller
.agent_view_state()
.active_conversation_id()
.expect("active agent view should select a conversation");
assert_eq!(*origin, AgentViewEntryOrigin::ThirdPartyCloudAgent);
let model = view.model.lock();
let block = model
.block_list()
.block_with_id(&harness_block_id)
.expect("harness block should still exist");
assert!(!block.should_hide_block(model.block_list().agent_view_state()));
match block.agent_view_visibility() {
AgentViewVisibility::Terminal {
conversation_ids,
pending_conversation_ids,
} => {
assert!(pending_conversation_ids.is_empty());
assert!(conversation_ids.contains(&conversation_id));
}
visibility => panic!("expected terminal block visibility, got {visibility:?}"),
}
});
});
}
#[test]
fn cloud_mode_followup_input_uses_explicit_submit_event_even_when_view_pending() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _agent_mode = FeatureFlag::AgentMode.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(true);
let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
let task_id = AmbientAgentTaskId::from_str("123e4567-e89b-12d3-a456-426614174000")
.expect("valid task id");
let ambient_agent_view_model = terminal.update(&mut app, |view, ctx| {
view.model
.lock()
.set_shared_session_status(SharedSessionStatus::ViewPending);
view.pending_cloud_followup_task_id = Some(task_id);
let ambient_agent_view_model = view
.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.clone();
ambient_agent_view_model.update(ctx, |model, ctx| {
model.enter_viewing_existing_session(task_id, ctx);
});
view.input().update(ctx, |input, ctx| {
input.set_input_mode_agent(true, ctx);
input.replace_buffer_content("follow up", ctx);
input.input_enter(ctx);
});
ambient_agent_view_model
});
terminal.read(&app, |_view, ctx| {
assert_eq!(
ambient_agent_view_model
.as_ref(ctx)
.pending_followup_prompt(),
Some("follow up")
);
});
});
}
#[test]
fn pending_cloud_followup_without_ambient_model_restores_prompt() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
app.add_singleton_model(|_| ToastStack);
let _flag = FeatureFlag::HandoffCloudCloud.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let task_id = AmbientAgentTaskId::from_str("123e4567-e89b-12d3-a456-426614174000")
.expect("valid task id");
terminal.update(&mut app, |view, ctx| {
view.pending_cloud_followup_task_id = Some(task_id);
assert!(view.try_submit_pending_cloud_followup("follow up".to_string(), ctx));
});
terminal.read(&app, |view, ctx| {
assert_eq!(view.pending_cloud_followup_task_id, None);
assert_eq!(view.input.as_ref(ctx).buffer_text(ctx), "follow up");
});
});
}
#[test]
fn cloud_mode_dispatched_agent_inserts_queued_user_query() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(true);
let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.update(&mut app, |view, ctx| {
view.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.update(ctx, |model, ctx| {
model.spawn_agent_with_request(
SpawnAgentRequest {
prompt: Some("write the tests".to_string()),
mode: UserQueryMode::Normal,
config: None,
title: None,
team: None,
agent_identity_uid: None,
skill: None,
attachments: vec![],
interactive: None,
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
conversation_id: None,
initial_snapshot_token: None,
snapshot_disabled: None,
orchestration_handoff: None,
},
ctx,
);
});
view.handle_ambient_agent_event(&AmbientAgentViewModelEvent::DispatchedAgent, ctx);
assert!(has_pending_user_query_block(view));
});
});
}
#[test]
fn cloud_mode_failed_keeps_queued_query_above_tombstone_and_hides_input() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(true);
let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
terminal.update(&mut app, |view, ctx| {
view.model
.lock()
.set_shared_session_status(SharedSessionStatus::ViewPending);
view.enter_ambient_agent_setup(None, ctx);
view.insert_cloud_mode_queued_user_query_block("queued prompt".to_string(), ctx);
assert!(has_pending_user_query_block(view));
let pending_query_view_id = view
.pending_user_query_view_id
.expect("queued query should have a view id");
view.handle_ambient_agent_event(
&AmbientAgentViewModelEvent::Failed {
error_message: "setup failed".to_string(),
},
ctx,
);
assert!(has_pending_user_query_block(view));
assert!(view.conversation_ended_tombstone_view_id.is_some());
assert_eq!(view.rich_content_views.len(), 2);
{
let model = view.model.lock();
assert!(!view.is_input_box_visible(&model, ctx));
let tombstone_view_id = view
.conversation_ended_tombstone_view_id
.expect("failed cloud mode should insert a tombstone");
let rich_content_view_ids = model
.block_list()
.block_heights()
.items()
.iter()
.filter_map(|item| {
match item {
crate::terminal::model::blocks::BlockHeightItem::RichContent(item) => {
Some(item.view_id)
}
crate::terminal::model::blocks::BlockHeightItem::Block(_)
| crate::terminal::model::blocks::BlockHeightItem::Gap(_)
| crate::terminal::model::blocks::BlockHeightItem::RestoredBlockSeparator {
..
}
| crate::terminal::model::blocks::BlockHeightItem::InlineBanner { .. }
| crate::terminal::model::blocks::BlockHeightItem::SubshellSeparator {
..
} => None,
}
})
.collect::<Vec<_>>();
let pending_query_position = rich_content_view_ids
.iter()
.position(|view_id| *view_id == pending_query_view_id)
.expect("queued query should be in the block list");
let tombstone_position = rich_content_view_ids
.iter()
.position(|view_id| *view_id == tombstone_view_id)
.expect("tombstone should be in the block list");
assert!(pending_query_position < tombstone_position);
}
view.handle_ambient_agent_event(
&AmbientAgentViewModelEvent::Failed {
error_message: "setup failed again".to_string(),
},
ctx,
);
assert_eq!(view.rich_content_views.len(), 2);
});
let window_id = app.read(|ctx| terminal.window_id(ctx));
let tombstones = app
.views_of_type::<ConversationEndedTombstoneView>(window_id)
.expect("window should have tombstone views");
let tombstone = tombstones
.last()
.expect("failed cloud mode should insert a tombstone");
tombstone.read(&app, |tombstone, _| {
assert_eq!(
tombstone.title_for_test(),
Some("Cloud agent failed to start")
);
assert_eq!(
tombstone.error_message_for_test(),
Some("setup failed again")
);
assert_eq!(tombstone.credits_for_test(), None);
assert!(!tombstone.has_continue_in_cloud_button_for_test());
assert!(!tombstone.has_continue_locally_button_for_test());
});
});
}
#[test]
fn cmd_enter_from_terminal_without_selected_block_enters_agent_view() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
app.add_singleton_model(ImportedConfigModel::new);
app.update(|ctx| {
crate::terminal::init(ctx);
crate::editor::init(ctx);
});
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let (window_id, terminal) = add_window_with_id_and_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
assert!(view
.ai_context_model
.as_ref(ctx)
.pending_context_block_ids()
.is_empty());
view.focus_terminal(ctx);
});
let keystroke = if cfg!(target_os = "macos") {
"cmd-enter"
} else {
"ctrl-shift-enter"
};
let handled = app
.dispatch_keystroke(
window_id,
&[terminal.id()],
&warpui::keymap::Keystroke::parse(keystroke).expect("valid keystroke"),
false,
)
.expect("dispatch should succeed");
assert!(
handled,
"{keystroke} should be handled from terminal context"
);
terminal.read(&app, |view, ctx| {
assert!(view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.is_fullscreen());
assert!(view
.ai_context_model
.as_ref(ctx)
.pending_context_block_ids()
.is_empty());
});
});
}
#[test]
fn cmd_enter_from_terminal_with_selected_block_enters_agent_view_with_context() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
app.add_singleton_model(ImportedConfigModel::new);
app.update(|ctx| {
crate::terminal::init(ctx);
crate::editor::init(ctx);
});
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let (window_id, terminal) = add_window_with_id_and_terminal(&mut app, None);
let selected_block_id = terminal.update(&mut app, |view, ctx| {
let (selected_block_index, selected_block_id) = {
let mut model = view.model.lock();
model.simulate_block("echo selected", "selected");
let block = model
.block_list()
.blocks()
.iter()
.find(|block| block.command_to_string() == "echo selected")
.expect("simulated block should exist");
(block.index(), block.id().clone())
};
view.integration_test_change_block_selection_to_single(selected_block_index, ctx);
assert!(view
.ai_context_model
.as_ref(ctx)
.pending_context_block_ids()
.contains(&selected_block_id));
view.focus_terminal(ctx);
selected_block_id
});
let keystroke = if cfg!(target_os = "macos") {
"cmd-enter"
} else {
"ctrl-shift-enter"
};
let handled = app
.dispatch_keystroke(
window_id,
&[terminal.id()],
&warpui::keymap::Keystroke::parse(keystroke).expect("valid keystroke"),
false,
)
.expect("dispatch should succeed");
assert!(
handled,
"{keystroke} should be handled from terminal context"
);
terminal.read(&app, |view, ctx| {
let conversation_id = view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state().active_conversation_id()
.expect("agent view should be active");
let model = view.model.lock();
let block = model
.block_list()
.block_with_id(&selected_block_id)
.expect("selected block should still exist");
assert!(
!block.should_hide_block(model.block_list().agent_view_state()),
"selected block should remain visible in the new agent conversation"
);
match block.agent_view_visibility() {
AgentViewVisibility::Terminal {
pending_conversation_ids,
..
} => {
assert!(
pending_conversation_ids.contains(&conversation_id),
"selected block should be attached as pending context for the new conversation"
);
}
visibility => panic!("expected terminal block visibility, got {visibility:?}"),
}
});
});
}
#[test]
fn cmd_enter_from_active_non_empty_agent_view_requires_confirmation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
app.add_singleton_model(ImportedConfigModel::new);
app.update(|ctx| {
crate::terminal::init(ctx);
crate::editor::init(ctx);
});
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let (window_id, terminal) = add_window_with_id_and_terminal(&mut app, None);
let original_conversation_id = terminal.update(&mut app, |view, ctx| {
let (conversation_id, _, _, _) =
append_exchange_and_handle_event(view, agent_jump_user_query("first"), ctx);
view.enter_agent_view_for_conversation(
None,
AgentViewEntryOrigin::ConversationSelector,
conversation_id,
ctx,
);
view.focus_terminal(ctx);
conversation_id
});
let keystroke = if cfg!(target_os = "macos") {
"cmd-enter"
} else {
"ctrl-shift-enter"
};
let keystroke = warpui::keymap::Keystroke::parse(keystroke).expect("valid keystroke");
let handled = app
.dispatch_keystroke(window_id, &[terminal.id()], &keystroke, false)
.expect("dispatch should succeed");
assert!(
handled,
"new conversation keybinding should be handled from terminal context"
);
terminal.read(&app, |view, ctx| {
assert_eq!(
view.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id(),
Some(original_conversation_id),
"first keybinding press should keep the current conversation active"
);
});
let handled = app
.dispatch_keystroke(window_id, &[terminal.id()], &keystroke, false)
.expect("dispatch should succeed");
assert!(
handled,
"new conversation keybinding should be handled from terminal context"
);
terminal.read(&app, |view, ctx| {
let new_conversation_id = view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
.expect("agent view should be active");
assert_ne!(
new_conversation_id, original_conversation_id,
"second keybinding press should start a new conversation"
);
});
});
}
#[test]
fn cloud_mode_followup_dispatched_inserts_queued_user_query() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(true);
let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true);
let terminal = add_window_with_cloud_mode_terminal(&mut app);
let task_id = AmbientAgentTaskId::from_str("123e4567-e89b-12d3-a456-426614174000")
.expect("valid task id");
terminal.update(&mut app, |view, ctx| {
view.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.update(ctx, |model, ctx| {
model.enter_viewing_existing_session(task_id, ctx);
model.submit_cloud_followup("follow up".to_string(), ctx);
});
view.handle_ambient_agent_event(&AmbientAgentViewModelEvent::FollowupDispatched, ctx);
assert!(has_pending_user_query_block(view));
});
});
}
#[test]
fn cloud_mode_setup_v2_suppresses_sharer_input_updates_while_followup_setup_commands_run() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true);
let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(true);
let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true);
let setup_command_ops = input_operations_for_buffer_content(&mut app, "setup command text");
let normal_input_ops = input_operations_for_buffer_content(&mut app, "normal sync text");
let terminal = add_window_with_cloud_mode_terminal(&mut app);
let task_id = AmbientAgentTaskId::from_str("123e4567-e89b-12d3-a456-426614174000")
.expect("valid task id");
terminal.update(&mut app, |view, ctx| {
let ambient_agent_view_model = view
.ambient_agent_view_model()
.expect("cloud mode terminal should have ambient model")
.clone();
ambient_agent_view_model.update(ctx, |model, ctx| {
model.enter_viewing_existing_session(task_id, ctx);
});
view.handle_ambient_agent_event(&AmbientAgentViewModelEvent::FollowupDispatched, ctx);
{
let model = view.model.lock();
assert!(view.is_input_box_visible(&model, ctx));
}
assert!(view.should_suppress_ambient_setup_input_sync(ctx));
let active_block_id = view.model.lock().block_list().active_block_id().clone();
view.input().update(ctx, |input, ctx| {
input.refresh_deferred_remote_operations(ctx);
});
view.apply_viewer_shared_session_input_update(&active_block_id, setup_command_ops, ctx);
assert_eq!(view.input().as_ref(ctx).buffer_text(ctx), "");
ambient_agent_view_model.update(ctx, |model, _| {
model
.setup_command_state_mut()
.set_did_execute_a_setup_command(true);
});
assert!(view.should_suppress_ambient_setup_input_sync(ctx));
ambient_agent_view_model.update(ctx, |model, _| {
let group_id = model.setup_command_state().current_group_id();
model.setup_command_state_mut().finish_group(group_id);
});
assert!(!view.should_suppress_ambient_setup_input_sync(ctx));
view.apply_viewer_shared_session_input_update(&active_block_id, normal_input_ops, ctx);
assert_eq!(
view.input().as_ref(ctx).buffer_text(ctx),
"normal sync text"
);
let model = view.model.lock();
assert!(view.is_input_box_visible(&model, ctx));
});
});
}
#[test]
fn pending_cloud_mode_query_waits_for_renderable_user_query_exchange() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.insert_cloud_mode_queued_user_query_block("queued prompt".to_string(), ctx);
assert!(has_pending_user_query_block(view));
append_exchange_and_handle_event(
view,
AIAgentInput::ResumeConversation {
context: Default::default(),
},
ctx,
);
assert!(has_pending_user_query_block(view));
append_exchange_and_handle_event(
view,
AIAgentInput::UserQuery {
query: "real prompt".to_string(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::default(),
running_command: None,
intended_agent: None,
},
ctx,
);
assert!(!has_pending_user_query_block(view));
});
});
}
#[test]
fn pending_cloud_mode_query_clears_when_streaming_exchange_becomes_renderable() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.insert_cloud_mode_queued_user_query_block(
"write a poem about rocks".to_string(),
ctx,
);
assert!(has_pending_user_query_block(view));
let (conversation_id, _, exchange_id, response_stream_id) =
append_exchange_with_inputs_and_handle_event(view, vec![], ctx);
assert!(has_pending_user_query_block(view));
update_exchange_input_and_handle_event(
view,
conversation_id,
exchange_id,
response_stream_id,
vec![AIAgentInput::UserQuery {
query: "write an ode about stones".to_string(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
}],
ctx,
);
assert!(!has_pending_user_query_block(view));
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.expect("conversation should exist");
let initial_user_query = conversation.initial_user_query();
let exchange = conversation
.exchange_with_id(exchange_id)
.expect("exchange should exist");
assert_eq!(
exchange.input[0]
.display_user_query(initial_user_query.as_ref())
.as_deref(),
Some("/agent write an ode about stones")
);
});
});
}
/// Test clearing of session flag state when terminal is cleared
#[test]
fn test_clear_session_flag_state() {
use galaxy_terminal::shell::ShellType;
use crate::ai::blocklist::SerializedBlockListItem;
use crate::terminal::model::block::SerializedBlock;
use crate::terminal::ShellHost;
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
// Create a remote restored block
let mut remote_block =
SerializedBlock::new_for_test("echo remote".into(), "remote output".into());
remote_block.is_local = Some(false); // Mark it as a remote block
remote_block.shell_host = Some(ShellHost {
shell_type: ShellType::Bash,
user: "user".to_string(),
hostname: "remote".to_string(), // Remote hostname indicates a remote session
});
// Convert to SerializedBlockListItem
let restored_blocks = [SerializedBlockListItem::Command {
block: Box::new(remote_block),
}];
// Create terminal with the restored remote block
let terminal = add_window_with_terminal(&mut app, Some(&restored_blocks));
terminal.update(&mut app, |view, ctx| {
// Verify initial state - block was created as remote and restored
assert!(
!view.any_session_contains_remote_blocks,
"Terminal should not have remote blocks"
);
assert!(
view.any_session_contains_restored_remote_blocks,
"Terminal should have restored remote blocks"
);
{
// Verify the block was properly created with correct properties
let model = view.model.lock();
let blocks = model.block_list().blocks();
// The first block should be our restored remote block
assert!(!blocks.is_empty(), "At least one block should exist");
if let Some(first_block) = blocks.first() {
assert_eq!(
first_block.restored_block_was_local(),
Some(false),
"First block should be marked as a remote restored block"
);
}
}
// Now clear the terminal
view.clear_buffer_for_testing(ctx);
// Flags should be reset
assert!(
!view.any_session_contains_remote_blocks,
"Terminal should not have remote blocks after clearing"
);
assert!(
!view.any_session_contains_restored_remote_blocks,
"Terminal should not have restored remote blocks after clearing"
);
});
})
}
fn assert_block_has_find_match(find_model: &TerminalFindModel, block_index: BlockIndex) {
assert!(find_model
.block_list_find_run()
.is_some_and(|run| run.matches_for_block(block_index).next().is_some()));
}
impl TerminalView {
fn is_top_of_active_block_in_viewport(
&self,
model: &TerminalModel,
input_mode: InputMode,
app: &AppContext,
) -> bool {
let active_block_index = model.block_list().active_block_index();
let viewport = self.viewport_state(model.block_list(), input_mode, app);
viewport.is_block_in_view(active_block_index, BlockVisibilityMode::TopOfBlockVisible)
}
fn scroll_top_in_lines(
&self,
model: &TerminalModel,
input_mode: InputMode,
app: &AppContext,
) -> Lines {
let viewport = self.viewport_state(model.block_list(), input_mode, app);
viewport.scroll_top_in_lines()
}
fn is_vertically_scrollable(&self, app: &AppContext) -> bool {
let total_block_heights = self
.model
.lock()
.block_list()
.block_heights()
.summary()
.height;
let visible_rows = self.content_element_height_lines(app);
heights_approx_gt(total_block_heights, visible_rows)
}
}
fn read_from_clipboard(ctx: &mut ViewContext<TerminalView>) -> String {
TerminalView::read_from_clipboard(Some(ShellFamily::Posix), ctx)
}
#[test]
fn test_insert() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let select_text = |view: &mut TerminalView, ctx: &mut ViewContext<TerminalView>| {
{
let mut model = view.model.lock();
model.start_command_execution();
let blocks = model.block_list_mut();
blocks.input('f');
blocks.linefeed();
blocks.preexec(PreexecValue::default());
blocks.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
}
view.begin_block_text_selection(
BlockListPoint::new(1.0, 1),
Side::Right,
SelectionType::Semantic,
Vector2F::zero(),
ctx,
);
view.end_text_selection(ctx);
};
let assert_input_text_eq = |app: &mut App, expected_text: &str| {
terminal.read(app, |view, _ctx| {
view.input.read(app, |view, ctx| {
assert_eq!(view.buffer_text(ctx), String::from(expected_text));
});
});
};
let assert_selected_blocks_cardinality_eq =
|app: &mut App, expected_cardinality: BlockSelectionCardinality| {
terminal.read(app, |view, _ctx| {
assert_eq!(
view.selected_blocks.cardinality().as_keymap_context_value(),
expected_cardinality.as_keymap_context_value()
);
});
};
let assert_selected_text_eq = |app: &mut App, expected_text: Option<String>| {
terminal.update(app, |view, ctx| {
let semantic_selection = SemanticSelection::as_ref(ctx);
let model = view.model.lock();
let context_selected_text =
model.selection_to_string(semantic_selection, false, ctx);
assert_eq!(context_selected_text, expected_text);
});
};
// Shell Mode: Nothing selected
terminal.update(&mut app, |view, ctx| {
view.focus_terminal(ctx);
view.typed_characters_on_terminal("hello", ctx);
});
assert_input_text_eq(&mut app, "hello");
assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None);
assert_selected_text_eq(&mut app, None);
// Shell Mode: Block selected
terminal.update(&mut app, |view, ctx| {
view.selected_blocks.reset_to_single(BlockIndex::zero());
view.focus_terminal(ctx);
view.typed_characters_on_terminal("_this", ctx);
});
assert_input_text_eq(&mut app, "hello_this");
assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None);
assert_selected_text_eq(&mut app, None);
// Shell Mode: Text selected
terminal.update(&mut app, |view, ctx| {
select_text(view, ctx);
view.focus_terminal(ctx);
view.typed_characters_on_terminal("_is", ctx);
});
assert_input_text_eq(&mut app, "hello_this_is");
assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None);
assert_selected_text_eq(&mut app, None);
// Activate Agent Mode, which should no longer allow text insertion to clear the selected block(s) or text
terminal.update(&mut app, |view, ctx| {
view.set_ai_input_mode_with_query(None, ctx);
});
// Agent Mode: Nothing selected
terminal.update(&mut app, |view, ctx| {
view.focus_terminal(ctx);
view.typed_characters_on_terminal("_your", ctx);
});
assert_input_text_eq(&mut app, "hello_this_is_your");
assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None);
assert_selected_text_eq(&mut app, None);
// Agent Mode: Block selected
terminal.update(&mut app, |view, ctx| {
view.selected_blocks.reset_to_single(BlockIndex::zero());
view.focus_terminal(ctx);
view.typed_characters_on_terminal("_captain", ctx);
});
assert_input_text_eq(&mut app, "hello_this_is_your_captain");
assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::One);
assert_selected_text_eq(&mut app, None);
// Agent Mode: Text selected
terminal.update(&mut app, |view, ctx| {
select_text(view, ctx);
view.focus_terminal(ctx);
view.typed_characters_on_terminal("_speaking", ctx);
});
assert_input_text_eq(&mut app, "hello_this_is_your_captain_speaking");
assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None);
assert_selected_text_eq(&mut app, Some("f".to_owned()));
})
}
const BODY_PREFIX: &str = "Latest output: ";
/// Regression test for CORE-1654. Tests the "Insert into Input" functionality from the context menu.
#[test]
fn test_insert_into_input() {
// Note that this is defined as a unit test rather than an integration test since it requires precise selections
// (where we don't want UI updates making the test brittle, due to hardcoded mouse positions).
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// TODO: Potentially explore if we can re-use helpers from `input_test.rs` (`select_first_command_line_of_block` and `insert_dummy_block`).
terminal.update(&mut app, |terminal_view, ctx| {
{
let mut terminal_model = terminal_view.model.lock();
let blocks = terminal_model.block_list_mut();
// Add two lines to the command grid and output grid in a new block.
let block_index = insert_block(blocks, "cmd_a\ncmd_b\n", "output_a\noutput_b\n");
let block = blocks.block_at(block_index).expect("block should exist");
// Selections are inclusive of endpoint, hence we need to identify the last column to select the first command.
let block_command_columns =
block.prompt_and_command_grid().grid_handler().columns();
let command_grid_offset = block.command_grid_offset();
// Create a selection that just spans the first line of the command grid in the block.
blocks.start_selection(
BlockListPoint::new(command_grid_offset, 0),
SelectionType::Simple,
Side::Left,
);
blocks.update_selection(
BlockListPoint::new(command_grid_offset, block_command_columns),
Side::Right,
);
let selection = blocks.selection();
assert!(selection.is_some());
}
terminal_view.context_menu_insert_selected_text(ctx);
});
// Confirm that the blocklist selection is cleared upon inserting into the input box.
terminal.read(&app, |terminal_view, _ctx| {
let terminal_model = terminal_view.model.lock();
let blocks = terminal_model.block_list();
let selection = blocks.selection();
assert!(
selection.is_none(),
"Expected no selections in the blocklist but got {selection:?}"
);
});
let input = terminal.read(&app, |terminal, _ctx| terminal.input().clone());
// Confirm that the input box has the correct text (the first line of the command grid was selected above).
input.read(&app, |input, ctx| {
assert_eq!(input.buffer_text(ctx), "cmd_a");
});
});
}
#[test]
fn test_copy_on_select() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// Add some text and make sure we update the selection
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
model.start_command_execution();
let blocks = model.block_list_mut();
blocks.input('f');
blocks.input('o');
blocks.input('o');
blocks.linefeed();
blocks.preexec(PreexecValue::default());
blocks.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
}
view.begin_block_text_selection(
BlockListPoint::new(1.0, 1),
Side::Right,
SelectionType::Semantic,
Vector2F::zero(),
ctx,
);
let selection_settings = SelectionSettings::as_ref(ctx);
assert!(selection_settings.copy_on_select_enabled());
assert_eq!("", &read_from_clipboard(ctx));
view.end_text_selection(ctx);
assert_eq!("foo", &read_from_clipboard(ctx));
});
})
}
#[test]
fn test_alt_screen_copy_on_select() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
// Enter alt screen and add text
let mut model = view.model.lock();
model.set_mode(ansi::Mode::SwapScreen {
save_cursor_and_clear_screen: true,
});
assert!(model.is_alt_screen_active());
model.alt_screen_mut().input('h');
}
// Ensure copy on select is enabled
let selection_settings = SelectionSettings::as_ref(ctx);
assert!(selection_settings.copy_on_select_enabled());
// Select input
view.begin_alt_selection(Point::new(0, 0), Side::Left, SelectionType::Simple, ctx);
assert_eq!("", &read_from_clipboard(ctx));
view.update_alt_selection(Point::new(0, 2), Side::Left, &Lines::zero(), ctx);
view.end_alt_selection(ctx);
// Ensure selection is copied
assert_eq!("h", &read_from_clipboard(ctx));
});
})
}
#[test]
fn test_alt_screen_select_with_sgr_mouse() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let (window_id, terminal) = add_window_with_id_and_terminal(&mut app, None);
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
let semantic_selection = SemanticSelection::mock(true, "");
let size_info = terminal.update(&mut app, |view, ctx| {
{
// Enter alt screen and enable SGR Mouse
let mut model = view.model.lock();
model.set_mode(ansi::Mode::SwapScreen {
save_cursor_and_clear_screen: true,
});
model.set_mode(ansi::Mode::SgrMouse);
assert!(model.is_alt_screen_active());
assert!(!should_intercept_mouse(&model, false, ctx));
assert!(should_intercept_mouse(&model, true, ctx));
// Write a bunch of characters into the alt screen.
// ABCDEFG
// HIJKLMN
// OPQRSTU
// VWXYZ[\
// ]^_`abc
// defghij
// klmnopq
// rstuvwx
// yz{|}~
// € ‚ƒ„…†
// ‡ˆ‰Š‹Œ
let mut ascii: u8 = 65;
for _ in 0..view.size_info.rows {
for _ in 0..view.size_info.columns {
model.alt_screen_mut().input(ascii as char);
ascii += 1;
}
}
*view.size_info
}
});
// We need to manually trigger re-renders to ensure the AltScreenElement is recreated, e.g.
// so its `is_terminal_selecting` property will be up-to-date.
macro_rules! rerender {
($app:ident, $presenter:expr, $invalidation:expr, $size_info:expr) => {
app.update(enclose!((presenter, invalidation) move |ctx| {
presenter
.borrow_mut()
.invalidate(invalidation, ctx);
presenter.borrow_mut().build_scene(
vec2f(size_info.pane_width_px, size_info.pane_height_px),
1.,
None,
ctx,
);
}));
}
}
// The start and end positions corresponds to 'J'
// and 'a' in the grid, respectively.
//
// We adjust the vertical coordinates to account for padding
// in the alt-screen.
let start_position = vec2f(
2. * size_info.cell_width_px.as_f32(),
2. * size_info.cell_height_px.as_f32() - 1.,
);
let end_position = vec2f(
5. * size_info.cell_width_px.as_f32(),
5. * size_info.cell_height_px.as_f32() - 1.,
);
// Simulate a mouse drag from the "J" to the "a" cell.
rerender!(app, presenter, invalidation, size_info);
app.update(enclose!((presenter) move |ctx| {
ctx.simulate_window_event(
galaxyui::Event::LeftMouseDown {
position: start_position,
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
}));
rerender!(app, presenter, invalidation, size_info);
app.update(enclose!((presenter) move |ctx| {
ctx.simulate_window_event(
galaxyui::Event::LeftMouseDragged {
position: end_position,
modifiers: Default::default(),
},
window_id,
presenter.clone(),
);
}));
rerender!(app, presenter, invalidation, size_info);
app.update(enclose!((presenter) move |ctx| {
ctx.simulate_window_event(
galaxyui::Event::LeftMouseUp {
position: end_position,
modifiers: Default::default(),
},
window_id,
presenter.clone(),
);
}));
// No selection should've occurred as we aren't intercepting mouse events.
terminal.read(&app, |view, ctx| {
let selected_text =
view.model
.lock()
.selection_to_string(&semantic_selection, false, ctx);
assert_eq!(selected_text, None);
});
// This time, hold Shift key for all mouse events.
rerender!(app, presenter, invalidation, size_info);
app.update(enclose!((presenter) move |ctx| {
ctx.simulate_window_event(
galaxyui::Event::LeftMouseDown {
position: start_position,
modifiers: ModifiersState {
shift: true,
..Default::default()
},
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
}));
rerender!(app, presenter, invalidation, size_info);
app.update(enclose!((presenter) move |ctx| {
ctx.simulate_window_event(
galaxyui::Event::LeftMouseDragged {
position: end_position,
modifiers: ModifiersState {
shift: true,
..Default::default()
},
},
window_id,
presenter.clone(),
);
}));
rerender!(app, presenter, invalidation, size_info);
app.update(enclose!((presenter) move |ctx| {
ctx.simulate_window_event(
galaxyui::Event::LeftMouseUp {
position: end_position,
modifiers: ModifiersState {
shift: true,
..Default::default()
},
},
window_id,
presenter.clone(),
);
}));
// This time we expect a selection since the Shift key had been held for this mouse drag.
terminal.read(&app, |view, ctx| {
let selected_text =
view.model
.lock()
.selection_to_string(&semantic_selection, false, ctx);
assert_eq!(selected_text.as_ref().unwrap(), "JKLMNOPQRSTUVWXYZ[\\]^_`a");
});
})
}
// Regression test for WAR-3433 on find bar selection crash.
#[test]
fn test_find_bar_select() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// Add some text and make sure we update the selection
terminal.update(&mut app, |view, ctx| {
// Mock a block with content 'foo g'.
{
let mut model = view.model.lock();
model.start_command_execution();
let blocks = model.block_list_mut();
blocks.input('f');
blocks.input('o');
blocks.input('o');
blocks.input(' ');
blocks.input('g');
blocks.linefeed();
blocks.preexec(PreexecValue::default());
blocks.on_finish_byte_processing(&ansi::ProcessorInput::new(&[]));
}
// Select 'foo'.
view.begin_block_text_selection(
BlockListPoint::new(1.0, 1),
Side::Right,
SelectionType::Semantic,
Vector2F::zero(),
ctx,
);
let selection_settings = SelectionSettings::as_ref(ctx);
assert!(selection_settings.copy_on_select_enabled());
assert_eq!("", &read_from_clipboard(ctx));
view.end_text_selection(ctx);
assert_eq!("foo", &read_from_clipboard(ctx));
// Show find bar. The find bar should have selected text 'foo' in its editor.
view.show_find_bar(ctx);
view.find_bar.read(ctx, |find, ctx| {
find.editor().read(ctx, |editor, ctx| {
assert_eq!("foo".to_string(), editor.selected_text(ctx));
})
});
// Now select 'foo g'.
view.begin_block_text_selection(
BlockListPoint::new(1.0, 1),
Side::Right,
SelectionType::Lines,
Vector2F::zero(),
ctx,
);
let selection_settings = SelectionSettings::as_ref(ctx);
assert!(selection_settings.copy_on_select_enabled());
assert_eq!("foo", &read_from_clipboard(ctx));
view.end_text_selection(ctx);
assert_eq!("foo g", &read_from_clipboard(ctx));
// Show find bar. The find bar should have selected text 'foo g' in its editor.
view.show_find_bar(ctx);
view.find_bar.read(ctx, |find, ctx| {
find.editor().read(ctx, |editor, ctx| {
assert_eq!("foo g".to_string(), editor.selected_text(ctx));
})
});
});
})
}
#[test]
fn test_viewport_iter_most_recent_at_bottom() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let mut model = view.model.lock();
model.simulate_block("ls", "foo");
model.simulate_block("echo multiline", "bar\nhey");
let viewport = view.viewport_state(model.block_list(), InputMode::PinnedToBottom, ctx);
let mut iter = viewport.iter();
let first_block = iter.next().expect("item 1");
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(1)),
first_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::into(1),
first_block.entry_index
);
assert!(first_block.block_height_item.height().into_lines() > Lines::zero());
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(1)),
viewport.topmost_visible_block()
);
let second_block = iter.next().expect("item 2");
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(2)),
second_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::into(2),
second_block.entry_index
);
assert!(
second_block.block_height_item.height() > first_block.block_height_item.height()
);
assert!(viewport.is_block_in_view(
std::convert::Into::<BlockIndex>::into(2),
BlockVisibilityMode::TopOfBlockVisible
));
let third_block = iter.next().expect("item 3");
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(3)),
third_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::into(3),
third_block.entry_index
);
assert_eq!(0., third_block.block_height_item.height().as_f64());
});
})
}
#[test]
fn test_viewport_iter_most_recent_at_top() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx: &mut ViewContext<'_, TerminalView>| {
let mut model = view.model.lock();
model.simulate_block("ls", "foo");
model.simulate_block("echo multiline", "bar\nhey");
let viewport = view.viewport_state(model.block_list(), InputMode::PinnedToTop, ctx);
let mut iter = viewport.iter();
let echo_block = iter.next().expect("item 2");
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(2)),
echo_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::into(2),
echo_block.entry_index
);
assert!(echo_block.block_height_item.height().into_lines() > Lines::zero());
assert_eq!(Pixels::zero(), viewport.offset_to_top_of_first_block(ctx));
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(2)),
viewport.topmost_visible_block()
);
assert!(viewport.is_block_in_view(
std::convert::Into::<BlockIndex>::into(2),
BlockVisibilityMode::TopOfBlockVisible
));
let ls_block = iter.next().expect("item 1");
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(1)),
ls_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::into(1),
ls_block.entry_index
);
assert!(
echo_block.block_height_item.height().as_f64()
> ls_block.block_height_item.height().as_f64()
);
});
})
}
#[test]
fn test_viewport_most_recent_at_top() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let mut model = view.model.lock();
model.simulate_block("ls", "foo");
model.simulate_block("echo multiline", "bar\nhey");
let viewport = view.viewport_state(model.block_list(), InputMode::PinnedToTop, ctx);
// Most recent block should be visible.
let topmost_visible_block = viewport.topmost_visible_block().unwrap();
assert!(viewport.is_block_in_view(
topmost_visible_block,
BlockVisibilityMode::TopOfBlockVisible
));
assert_eq!(Pixels::zero(), viewport.offset_to_top_of_first_block(ctx));
assert_eq!(0., viewport.scroll_top_in_lines().as_f64());
assert!(matches!(
viewport.next_scroll_position(
ScrollPositionUpdate::AfterScrollEvent {
scroll_delta: 1.0.into_lines()
},
ctx
),
ScrollPosition::FixedAtPosition { .. }
));
assert_eq!(
Lines::zero(),
viewport.top_of_block_in_lines(topmost_visible_block)
);
assert!(matches!(
viewport.scroll_position_at_bottom_of_block(topmost_visible_block),
ScrollPosition::FollowsBottomOfMostRecentBlock
));
let block_list_point = viewport
.screen_coord_to_blocklist_point(
vec2f(0., 0.),
SnackbarPoint {
coord: vec2f(0., 0.),
translation_mode: SnackbarTranslationMode::WithinSnackbar,
},
ClampingMode::ClampToGrid,
)
.unwrap();
assert_eq!(
Some(2.into()),
viewport.block_index_from_point(block_list_point)
);
});
})
}
#[test]
fn test_scroll_fixed_to_bottom() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.read(&app, |view, _| {
assert_eq!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
});
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
// Put in enough blocks so that the view should be scrollable
for _ in 0..100 {
model.simulate_block("ls", "foo");
}
}
assert!(view.is_vertically_scrollable(ctx));
assert_eq!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
view.scroll(1.0.into_lines(), ctx);
let expected_scroll_top = {
let model = view.model.lock();
model.block_list().block_heights().summary().height
- view.content_element_height_lines(ctx)
- 1.0.into_lines()
};
assert_eq!(
view.scroll_position(),
ScrollPosition::FixedAtPosition {
scroll_lines: ScrollLines::ScrollTop(expected_scroll_top)
},
);
// Now add to the active block and make sure we don't scroll
{
let mut model = view.model.lock();
model.simulate_cmd("test");
}
{
let mut model = view.model.lock();
for _ in 0..100 {
model.linefeed();
}
}
assert_eq!(
view.scroll_position(),
ScrollPosition::FixedAtPosition {
scroll_lines: ScrollLines::ScrollTop(expected_scroll_top)
},
);
});
})
}
#[test]
fn test_scroll_to_row() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
// Put in enough blocks so that the view should be scrollable
for _ in 0..50 {
model.simulate_block("ls", "foo\nfie\nfay\nfoe\nfum");
}
}
assert!(view.is_vertically_scrollable(ctx));
assert_eq!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
// Scroll upwards (no snackbar)
let a = BlockListPoint::new(30.0, 0);
view.scroll_to_row_if_not_visible(a.row.into_lines(), ctx);
assert_eq!(
view.scroll_position(),
ScrollPosition::FixedAtPosition {
scroll_lines: ScrollLines::ScrollTop(30.0.into_lines())
}
);
// Don't scroll at all
let b = BlockListPoint::new(38.0, 0);
view.scroll_to_row_if_not_visible(b.row.into_lines(), ctx);
assert_eq!(
view.scroll_position(),
ScrollPosition::FixedAtPosition {
scroll_lines: ScrollLines::ScrollTop(30.0.into_lines())
}
);
// Scroll downwards
let c = BlockListPoint::new(100.0, 0);
view.scroll_to_row_if_not_visible(c.row.into_lines(), ctx);
assert_eq!(
view.scroll_position(),
ScrollPosition::FixedAtPosition {
scroll_lines: ScrollLines::ScrollTop(90.5.into_lines())
}
);
});
})
}
#[test]
fn test_stable_scrolling_during_grid_truncation() {
App::test((), |mut app| async move {
const MAX_GRID_SIZE: usize = 50;
const INPUT_MODE: InputMode = InputMode::PinnedToBottom;
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// Note: this test is done in a single `update` to prevent
// any changes in the presenter's position cache throughout.
terminal.update(&mut app, |view, ctx| {
// Set up the block list by creating a long-running
// block that spans the entire viewport.
{
let mut model = view.model.lock();
model.update_max_grid_size(MAX_GRID_SIZE);
// Create a dummy, finished block and a long-running block.
model.simulate_block("ls", "foo");
model.simulate_long_running_block("cat", "");
assert!(model
.block_list()
.active_block()
.is_active_and_long_running());
// Add enough newlines so that the long-running block spans at
// least the viewport and surely exceeds the grid size.
let mut i = 0;
while view.is_top_of_active_block_in_viewport(&model, INPUT_MODE, ctx)
|| i < MAX_GRID_SIZE * 2
{
model.process_bytes("\n");
i += 1;
}
}
// Scroll up one line.
assert_eq!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
view.scroll(1.into_lines(), ctx);
assert!(matches!(
view.scroll_position(),
ScrollPosition::FixedWithinLongRunningBlock { .. }
));
// Introduce new lines and make sure the scroll-top is adjusted as expected.
{
let mut model = view.model.lock();
let active_block_index = model.block_list().active_block_index();
let scroll_top_before_scrolling = view.scroll_top_in_lines(&model, INPUT_MODE, ctx);
// To get to the top of the block, we need 50 lines for output grid and
// then one line for command grid.
for i in 1..=(MAX_GRID_SIZE + 1) {
model.process_bytes("\n");
let actual_scroll_top = view.scroll_top_in_lines(&model, INPUT_MODE, ctx);
let expected_scroll_top = scroll_top_before_scrolling - i.into_lines();
assert_eq!(actual_scroll_top, expected_scroll_top);
}
// Flush one full line in case the top of the block doesn't perfectly
// line up with full lines (e.g. due to padding).
model.process_bytes("\n");
// Any remaining newlines should not move the scroll-top;
// it should be "locked" at the top of the block.
for _ in 0..MAX_GRID_SIZE {
model.process_bytes("\n");
let viewport = view.viewport_state(model.block_list(), INPUT_MODE, ctx);
let actual_scroll_top = viewport.scroll_top_in_lines();
let expected_scroll_top = viewport.top_of_block_in_lines(active_block_index);
assert_eq!(actual_scroll_top, expected_scroll_top);
}
}
// Scroll up one line, bringing the previous block into the viewport.
view.scroll(1.into_lines(), ctx);
assert!(matches!(
view.scroll_position(),
ScrollPosition::FixedAtPosition { .. }
));
// Introduce newlines and make sure the scroll-top does _not_ change anymore.
{
let mut model = view.model.lock();
let scroll_top_before_newlines = view.scroll_top_in_lines(&model, INPUT_MODE, ctx);
for _ in 0..MAX_GRID_SIZE {
model.process_bytes("\n");
let new_scroll_top = view.scroll_top_in_lines(&model, INPUT_MODE, ctx);
assert_eq!(scroll_top_before_newlines, new_scroll_top);
}
}
});
})
}
#[test]
fn test_clear_buffer() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
for _ in 0..10 {
model.simulate_block("ls", "foo");
}
assert!(!model.block_list().blocks().is_empty());
}
view.bookmark_block(&BlockIndex::zero(), ctx);
view.clear_buffer(ctx);
{
let model = view.model.lock();
// There should be only one precmd block.
assert_eq!(model.block_list().blocks().len(), 1);
assert_eq!(view.bookmarked_blocks.len(), 0);
}
});
})
}
#[test]
fn test_context_menu_includes_clear_when_block_list_non_empty() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
model.simulate_block("ls", "foo");
assert!(!model.is_block_list_empty());
}
let menu_source = BlockListMenuSource::OutsideBlockRightClick {
position_in_terminal_view: Vector2F::zero(),
};
let items = view.context_menu_items(&menu_source, ctx);
let labels: Vec<&str> = items
.iter()
.filter_map(|item| item.fields().map(|fields| fields.label()))
.collect();
assert!(
labels.contains(&"Clear Blocks"),
"Expected `Clear Blocks` menu item, got {labels:?}"
);
});
})
}
#[test]
fn test_context_menu_omits_clear_when_block_list_empty() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let model = view.model.lock();
assert!(model.is_block_list_empty());
}
let menu_source = BlockListMenuSource::OutsideBlockRightClick {
position_in_terminal_view: Vector2F::zero(),
};
let items = view.context_menu_items(&menu_source, ctx);
let labels: Vec<&str> = items
.iter()
.filter_map(|item| item.fields().map(|fields| fields.label()))
.collect();
assert!(
!labels.contains(&"Clear Blocks"),
"Did not expect `Clear Blocks` menu item when block list is empty, got {labels:?}"
);
});
})
}
#[test]
fn test_context_menu_omits_clear_for_text_right_click() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
model.simulate_block("ls", "foo");
assert!(!model.is_block_list_empty());
}
let menu_source = BlockListMenuSource::RegularTextRightClick {
position_in_terminal_view: Vector2F::zero(),
};
let items = view.context_menu_items(&menu_source, ctx);
let labels: Vec<&str> = items
.iter()
.filter_map(|item| item.fields().map(|fields| fields.label()))
.collect();
assert!(
!labels.contains(&"Clear Blocks"),
"Did not expect `Clear Blocks` in text-selection right-click menu, got {labels:?}"
);
});
})
}
#[test]
fn test_clear_buffer_clears_autosuggestion() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
// Set a next command suggestion (empty input)
view.input.update(ctx, |input, ctx| {
input.editor().update(ctx, |editor, ctx| {
editor.set_autosuggestion(
"git status",
AutosuggestionLocation::EndOfBuffer,
AutosuggestionType::Command {
was_intelligent_autosuggestion: true,
},
ctx,
);
});
});
// Verify autosuggestion is present
view.input.read(ctx, |input, ctx| {
input.editor().read(ctx, |editor, _ctx| {
assert!(
editor.active_autosuggestion(),
"Autosuggestion should be active before clear_buffer"
);
});
});
// Clear the buffer
view.clear_buffer(ctx);
// Verify autosuggestion is cleared
view.input.read(ctx, |input, ctx| {
input.editor().read(ctx, |editor, _ctx| {
assert!(
!editor.active_autosuggestion(),
"Autosuggestion should be cleared after clear_buffer"
);
});
});
});
})
}
#[test]
fn test_bookmark_blocks_navigation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
for _ in 0..10 {
model.simulate_block("ls", "foo");
}
assert!(!model.block_list().blocks().is_empty());
}
view.bookmark_block(&BlockIndex::zero(), ctx);
view.bookmark_block(&BlockIndex::from(1), ctx);
view.bookmark_block(&BlockIndex::from(4), ctx);
view.bookmark_up(ctx);
assert_eq!(view.selected_blocks.tail(), Some(4.into()));
view.bookmark_down(ctx);
assert_eq!(view.selected_blocks.tail(), Some(0.into()));
view.bookmark_up(ctx);
assert_eq!(view.selected_blocks.tail(), Some(4.into()));
view.bookmark_up(ctx);
assert_eq!(view.selected_blocks.tail(), Some(1.into()));
view.bookmark_up(ctx);
assert_eq!(view.selected_blocks.tail(), Some(0.into()));
view.bookmark_down(ctx);
assert_eq!(view.selected_blocks.tail(), Some(1.into()));
view.bookmark_down(ctx);
assert_eq!(view.selected_blocks.tail(), Some(4.into()));
});
})
}
fn run_navigation_test(input_mode: InputMode) {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.read(&app, |view, _ctx| {
assert_eq!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
});
terminal.update(&mut app, |view, ctx| {
InputModeSettings::handle(ctx).update(ctx, |input_mode_settings, ctx| {
let _ = input_mode_settings.input_mode.set_value(input_mode, ctx);
});
{
let mut model = view.model.lock();
// Put in enough blocks so that the view should be scrollable
for _ in 0..100 {
model.simulate_block("ls", "foo");
}
// Put in one block that is larger than the viewport height.
model.simulate_block("ls", "foo\n".repeat(100).as_str())
}
assert!(view.is_vertically_scrollable(ctx));
assert_eq!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
view.select_most_recent_blocks(1, ctx);
assert_eq!(view.selected_blocks.tail(), Some(101.into()));
view.select_less_recent_block(false /* is_shift_down */, ctx);
assert_eq!(view.selected_blocks.tail(), Some(100.into()));
view.select_more_recent_block(
true, /* is_cmd_down */
false, /* is_shift_down */
ctx,
);
assert_ne!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
assert_eq!(view.selected_blocks.tail(), Some(101.into()));
view.select_more_recent_block(
true, /* is_cmd_down */
false, /* is_shift_down */
ctx,
);
if input_mode.is_inverted_blocklist() {
// In the inverted case, we intentionally align to the
// top of the most recent block here, not to its bottom
assert!(matches!(
view.scroll_position(),
ScrollPosition::FixedAtPosition { .. }
));
} else {
assert_eq!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
}
assert_eq!(view.selected_blocks.tail(), Some(101.into()));
view.select_more_recent_block(
true, /* is_cmd_down */
false, /* is_shift_down */
ctx,
);
assert_eq!(view.selected_blocks.tail(), None);
});
});
}
#[test]
fn test_navigate_blocks() {
run_navigation_test(InputMode::PinnedToBottom);
}
// #[test]
// fn test_navigate_blocks_inverted_blocklist() {
// run_navigation_test(InputMode::PinnedToTop);
// }
#[test]
fn test_alt_scroll_sequences() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// Test scrolling a distance of zero lines.
terminal.update(&mut app, |view, _| {
let content = view.alt_scroll_sequences(0);
assert!(content.is_empty());
});
// Scroll down 3 lines
terminal.update(&mut app, |view, _| {
let content = view.alt_scroll_sequences(-3);
assert_eq!(content.len(), 3 * 3);
assert_eq!(
content
.into_iter()
.filter(|b| *b == escape_sequences::EscCodes::ARROW_DOWN)
.count(),
3
);
});
// Scroll up 5 lines
terminal.update(&mut app, |view, _| {
let content = view.alt_scroll_sequences(5);
assert_eq!(content.len(), 5 * 3);
assert_eq!(
content
.into_iter()
.filter(|b| *b == escape_sequences::EscCodes::ARROW_UP)
.count(),
5
);
});
})
}
#[test]
fn test_not_bootstrapped() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let model = view.model.lock();
assert!(view.is_input_box_visible(&model, ctx));
drop(model);
assert_eq!(view.active_session_path_if_local(ctx), None);
});
})
}
#[test]
fn test_block_select() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.selected_blocks
.toggle(10.into(), Some(11.into()), Some(9.into()));
let single_mouse_down = BlockSelectAction::MouseDown(Some(1.into()));
// On Mac, we use cmd-click to toggle block selections, but
// we use ctrl-click on non-Mac platforms.
let single_mouse_up = if cfg!(target_os = "macos") {
BlockSelectAction::MouseUp {
block_index: 1.into(),
is_ctrl_down: false,
is_cmd_down: true,
is_shift_down: false,
}
} else {
BlockSelectAction::MouseUp {
block_index: 1.into(),
is_ctrl_down: true,
is_cmd_down: false,
is_shift_down: false,
}
};
view.block_select(&single_mouse_down, true, ctx);
view.block_select(&single_mouse_up, true, ctx);
assert!(view.selected_blocks.is_selected(1.into()));
assert!(view.selected_blocks.is_selected(10.into()));
let range_mouse_down = BlockSelectAction::MouseDown(Some(5.into()));
let range_mouse_up = BlockSelectAction::MouseUp {
block_index: 5.into(),
is_ctrl_down: false,
is_cmd_down: false,
is_shift_down: true,
};
view.block_select(&range_mouse_down, true, ctx);
view.block_select(&range_mouse_up, true, ctx);
assert!(!view.selected_blocks.is_selected(10.into()));
assert_eq!(view.selected_blocks_pivot_index(), Some(1.into()));
assert_eq!(view.selected_blocks_tail_index(), Some(5.into()));
});
})
}
#[test]
fn test_select_all_blocks() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
// Put in enough blocks so that the view should be scrollable
for _ in 0..100 {
model.simulate_block("ls", "foo");
}
}
assert!(view.is_vertically_scrollable(ctx));
view.select_all_blocks(ctx);
assert_eq!(view.selected_blocks_pivot_index().unwrap(), 1.into());
assert_eq!(view.selected_blocks_tail_index().unwrap(), 100.into());
for i in 1..100 {
assert!(view.selected_blocks.is_selected(i.into()));
}
});
})
}
#[test]
fn test_expand_selection_above_and_below() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
// Put in enough blocks so that the view should be scrollable
for _ in 0..100 {
model.simulate_block("ls", "foo");
}
}
assert!(view.is_vertically_scrollable(ctx));
// helper to ensure indices are all selected
fn assert_all_selected(selected_blocks: &SelectedBlocks, indices: Vec<BlockIndex>) {
for &idx in indices.iter() {
assert!(selected_blocks.is_selected(idx));
}
}
view.selected_blocks
.toggle(5.into(), Some(6.into()), Some(4.into()));
assert_all_selected(&view.selected_blocks, vec![5.into()]);
view.select_more_recent_block(
false, /* is_cmd_down */
true, /* is_shift_down */
ctx,
);
assert_all_selected(&view.selected_blocks, vec![5.into(), 6.into()]);
view.select_more_recent_block(
false, /* is_cmd_down */
true, /* is_shift_down */
ctx,
);
assert_all_selected(&view.selected_blocks, vec![5.into(), 6.into(), 7.into()]);
view.select_less_recent_block(true /* is_shift_down */, ctx);
assert_all_selected(&view.selected_blocks, vec![5.into(), 6.into()]);
view.select_less_recent_block(true /* is_shift_down */, ctx);
assert_all_selected(&view.selected_blocks, vec![5.into()]);
view.select_less_recent_block(true /* is_shift_down */, ctx);
assert_all_selected(&view.selected_blocks, vec![5.into(), 4.into()]);
view.select_more_recent_block(
false, /* is_cmd_down */
true, /* is_shift_down */
ctx,
);
assert_all_selected(&view.selected_blocks, vec![5.into()]);
});
})
}
#[test]
fn test_copy_blocks() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let (first_command, first_output) = ("ls", "foo");
let (second_command, second_output) = ("pwd", "bar");
{
let mut model = view.model.lock();
model.simulate_block(first_command, first_output);
model.simulate_block(second_command, second_output);
}
// select a single block
view.selected_blocks.toggle(2.into(), None, Some(1.into()));
// test copy for a single block
view.copy_blocks(BlockEntity::Command, ctx);
assert_eq!(read_from_clipboard(ctx), second_command.to_string());
view.copy_blocks(BlockEntity::Output, ctx);
assert_eq!(read_from_clipboard(ctx), second_output.to_string());
view.copy_blocks(BlockEntity::CommandAndOutput, ctx);
assert_eq!(
read_from_clipboard(ctx),
format!("{second_command}\n{second_output}")
);
// select another block (in reverse)
view.selected_blocks.toggle(1.into(), Some(2.into()), None);
// test copy semantics for multiple blocks
view.copy_blocks(BlockEntity::Command, ctx);
let expected_commands_str = format!("{first_command}\n{second_command}");
assert_eq!(read_from_clipboard(ctx), expected_commands_str);
view.copy_blocks(BlockEntity::Output, ctx);
let expected_outputs_str = format!("{first_output}\n{second_output}");
assert_eq!(read_from_clipboard(ctx), expected_outputs_str);
view.copy_blocks(BlockEntity::CommandAndOutput, ctx);
let expected_both_str =
format!("{first_command}\n{first_output}\n{second_command}\n{second_output}");
assert_eq!(read_from_clipboard(ctx), expected_both_str);
});
})
}
#[test]
fn test_reinput_blocks() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let (first_command, first_output) = ("ls", "foo");
let (second_command, second_output) = ("pwd", "bar");
{
let mut model = view.model.lock();
model.simulate_block(first_command, first_output);
model.simulate_block(second_command, second_output);
}
// test reinput command for single block
view.selected_blocks.toggle(2.into(), None, Some(1.into()));
view.reinput_commands(false /* as_root */, ctx);
assert_eq!(view.input().as_ref(ctx).buffer_text(ctx), second_command);
view.selected_blocks.toggle(2.into(), None, Some(1.into()));
view.reinput_commands(true /* as_root */, ctx);
assert_eq!(
view.input().as_ref(ctx).buffer_text(ctx),
format!("sudo {second_command}")
);
// test reinput commands for multiple blocks (selected in reverse)
view.selected_blocks.toggle(2.into(), None, Some(1.into()));
view.selected_blocks.toggle(1.into(), Some(2.into()), None);
view.reinput_commands(false /* as_root */, ctx);
assert_eq!(
view.input().as_ref(ctx).buffer_text(ctx),
format!("{first_command}\n{second_command}")
);
view.selected_blocks.toggle(2.into(), None, Some(1.into()));
view.selected_blocks.toggle(1.into(), Some(2.into()), None);
view.reinput_commands(true /* as_root */, ctx);
assert_eq!(
view.input().as_ref(ctx).buffer_text(ctx),
format!("sudo {first_command}\nsudo {second_command}")
);
});
})
}
fn run_find_test(input_mode: InputMode) {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
InputModeSettings::handle(ctx).update(ctx, |input_mode_settings, ctx| {
let _ = input_mode_settings.input_mode.set_value(input_mode, ctx);
});
let (first_command, first_output) = ("ls", "foo");
let (second_command, second_output) = ("pwd", "foobar foo beans");
let (third_command, third_output) = ("fools", "baz");
{
let mut model = view.model.lock();
model.simulate_block(first_command, first_output);
model.simulate_block(second_command, second_output);
model.simulate_block(third_command, third_output);
}
view.show_find_bar(ctx);
// Test without find_in_block enabled (results should be selection-agnostic)
view.find_bar.update(ctx, |view, _ctx| {
view.display_find_within_block = FindWithinBlockState::Disabled;
});
// find when no block is selected
view.handle_find_event(
&FindEvent::Update {
query: Some("foo".to_string()),
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
4
);
assert_eq!(
view.find_model
.as_ref(ctx)
.block_list_find_run()
.expect("BlockListFindRun exists.")
.focused_match_block_index()
.expect("Focused match exists."),
3.into()
);
view.handle_find_event(
&FindEvent::NextMatch {
direction: FindDirection::Down,
},
ctx,
);
if input_mode.is_inverted_blocklist() {
// should go "down" to middle block
assert_eq!(
view.find_model
.as_ref(ctx)
.block_list_find_run()
.expect("BlockListFindRun exists.")
.focused_match_block_index()
.expect("Focused match exists."),
2.into()
);
} else {
// should loop to earliest block
assert_eq!(
view.find_model
.as_ref(ctx)
.block_list_find_run()
.expect("BlockListFindRun exists.")
.focused_match_block_index()
.expect("Focused match exists."),
1.into()
);
}
// find when a single block is selected
view.selected_blocks.reset_to_single(2.into());
view.handle_find_event(
&FindEvent::Update {
query: Some("ls".to_string()),
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
2
);
assert_block_has_find_match(view.find_model.as_ref(ctx), 1.into());
assert_block_has_find_match(view.find_model.as_ref(ctx), 3.into());
// Test with find_in_block enabled
view.find_bar.update(ctx, |view, _ctx| {
view.display_find_within_block = FindWithinBlockState::Enabled;
});
// find when no block is selected
view.selected_blocks.reset();
view.handle_find_event(
&FindEvent::Update {
query: Some("foo".to_string()),
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
0
);
// find when a single block is selected
view.selected_blocks.reset_to_single(2.into());
view.handle_find_event(
&FindEvent::Update {
query: Some("pwd".to_string()),
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
1
);
assert_block_has_find_match(view.find_model.as_ref(ctx), 2.into());
// find when multiple blocks are selected, and find in block is enabled
view.selected_blocks.toggle(3.into(), Some(2.into()), None);
view.handle_find_event(
&FindEvent::Update {
query: Some("foo".to_string()),
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
3
);
assert_block_has_find_match(view.find_model.as_ref(ctx), 2.into());
assert_block_has_find_match(view.find_model.as_ref(ctx), 3.into());
});
})
}
#[test]
fn test_find_in_blocks() {
run_find_test(InputMode::PinnedToBottom);
}
#[test]
fn test_find_in_blocks_inverted_blocklist() {
run_find_test(InputMode::PinnedToTop);
}
#[test]
fn test_case_sensitive_find() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let (first_command, first_output) = ("ls", "foo");
let (second_command, second_output) = ("pwd", "fOObar");
let (third_command, third_output) = ("FoOls", "baz");
{
let mut model = view.model.lock();
model.simulate_block(first_command, first_output);
model.simulate_block(second_command, second_output);
model.simulate_block(third_command, third_output);
}
view.show_find_bar(ctx);
// Test without case sensitivity enabled (no blocks enabled)
view.handle_find_event(
&FindEvent::Update {
query: Some("fOO".to_string()),
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
3
);
// Test without case sensitivity enabled, but with find in block
view.find_bar.update(ctx, |view, _ctx| {
view.display_find_within_block = FindWithinBlockState::Enabled;
});
view.selected_blocks.reset_to_single(1.into());
view.update_find_selection(ctx);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
1
);
assert_block_has_find_match(view.find_model.as_ref(ctx), 1.into());
// Test with case sensitivity enabled (one block enabled)
view.handle_find_event(
&FindEvent::ToggleCaseSensitivity {
is_case_sensitive: true,
},
ctx,
);
view.selected_blocks.reset_to_single(1.into());
view.update_find_selection(ctx);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
0
);
view.selected_blocks.reset_to_single(2.into());
view.update_find_selection(ctx);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
1
);
assert_block_has_find_match(view.find_model.as_ref(ctx), 2.into());
// Test with case sensitivity enabled (no blocks enabled)
view.selected_blocks.reset();
view.find_bar.update(ctx, |view, _ctx| {
view.display_find_within_block = FindWithinBlockState::Disabled;
});
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
1
);
assert_block_has_find_match(view.find_model.as_ref(ctx), 2.into());
// Change regex to mismatch case sensitivity across all blocks
view.handle_find_event(
&FindEvent::Update {
query: Some("FOO".to_string()),
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
0
);
});
})
}
#[test]
fn test_find_bar_prefix_search() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let (command_1, output_1) = ("echo foo", "foo");
let (command_2, output_2) = ("echo bar foo", "bar foo");
{
let mut model = view.model.lock();
model.simulate_block(command_1, output_1);
model.simulate_block(command_2, output_2);
}
view.show_find_bar(ctx);
// Test without regex enabled
view.handle_find_event(
&FindEvent::Update {
query: Some("^foo".to_string()),
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
0
);
view.handle_find_event(
&FindEvent::ToggleRegexSearch {
is_regex_enabled: true,
},
ctx,
);
assert_eq!(
view.find_model.as_ref(ctx).visible_block_list_match_count(),
1
);
});
});
}
#[test]
fn test_create_notification_shorter_than_max() {
let command = "cargo run";
let output = "error: failed to find directory";
let command_succeeded = false;
let block_duration = Duration::new(4, 2);
let trigger = NotificationsTrigger::LongRunningCommand(command_succeeded, block_duration);
let actual_content =
trigger.create_notification_content(command.to_string(), output.to_string());
let expected_title = format!("'{command}' failed after 4s");
let expected_body = format!("{BODY_PREFIX}{output}");
assert_eq!(actual_content.title, expected_title);
assert_eq!(actual_content.body, expected_body);
}
#[test]
fn test_create_notification_as_long_as_max() {
let expected_title_suffix = " finished after 4s";
let max_command_len = UserNotification::MAX_TITLE_LENGTH - expected_title_suffix.len() - 2;
let command = "a".repeat(max_command_len);
let max_output_len = UserNotification::MAX_BODY_LENGTH - BODY_PREFIX.len();
let output = "a".repeat(max_output_len);
let command_succeeded = true;
let block_duration = Duration::new(4, 2);
let trigger = NotificationsTrigger::LongRunningCommand(command_succeeded, block_duration);
let actual_content =
trigger.create_notification_content(command.to_string(), output.to_string());
let expected_title = format!("'{command}'{expected_title_suffix}");
let expected_body = format!("{BODY_PREFIX}{output}");
assert_eq!(actual_content.title, expected_title);
assert_eq!(actual_content.body, expected_body);
}
#[test]
fn test_create_notification_longer_than_max() {
let expected_title_suffix = " finished after 4s";
let max_command_len = UserNotification::MAX_TITLE_LENGTH - expected_title_suffix.len() - 2;
let command = "a".repeat(max_command_len + 1);
let max_output_len = UserNotification::MAX_BODY_LENGTH - BODY_PREFIX.len();
let output = "a".repeat(max_output_len + 1);
let command_succeeded = true;
let block_duration = Duration::new(4, 2);
let trigger = NotificationsTrigger::LongRunningCommand(command_succeeded, block_duration);
let actual_content =
trigger.create_notification_content(command.to_string(), output.to_string());
let expected_title = format!(
"'{}...'{expected_title_suffix}",
&command[..max_command_len - 3]
);
let expected_body = format!("{BODY_PREFIX}...{}", &output[..max_output_len - 3]);
assert_eq!(actual_content.title, expected_title);
assert_eq!(actual_content.body, expected_body);
}
#[test]
fn test_create_notification_char_boundaries_respected() {
let expected_title_suffix = " finished after 4s";
let max_command_len = UserNotification::MAX_TITLE_LENGTH - expected_title_suffix.len() - 2;
let command = "😊".repeat(max_command_len + 1);
let output = "error: failed to find directory";
let command_succeeded = true;
let block_duration = Duration::new(4, 2);
let trigger = NotificationsTrigger::LongRunningCommand(command_succeeded, block_duration);
let actual_content = trigger.create_notification_content(command, output.to_string());
let expected_command_prefix = "😊".repeat(max_command_len - 3);
let expected_title = format!("'{expected_command_prefix}...'{expected_title_suffix}",);
assert_eq!(actual_content.title, expected_title);
}
#[test]
fn test_banner_for_incompatible_plugins() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
SessionSettings::handle(&app).update(&mut app, |session_settings, ctx| {
let _ = session_settings.honor_ps1.set_value(true, ctx);
});
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "zsh".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "zsh".to_owned(),
shell_plugins: Some(HashSet::from(["p10k_unsupported".to_string()])),
..Default::default()
});
});
// This is asynchronous because we're waiting for the bootstrap event
// to be sent from the terminal model to the terminal view.
assert_eventually!(
200 => terminal.read(&app, |view, _ctx| view
.is_incompatible_configuration_banner_open),
"Banner did not open in time"
);
})
}
// Regression test for GH#3548 / GH#6093: the "Seems like your completions are not
// working" banner must offer a permanent "Don't show me again" dismissal that is
// persisted, while the "x" close button keeps its existing per-session behavior.
#[test]
fn test_control_master_banner_permanent_dismissal_persists() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
terminal.update(&mut app, |view, ctx| {
// Temporary dismissal (the "x" button) clears the banner for this session
// but must not persist a "don't show again" preference.
view.control_master_error_banner_state.is_open = true;
view.handle_controlmaster_error_banner_event(
&BannerEvent::Dismiss(DismissalType::Temporary),
ctx,
);
assert!(!view.control_master_error_banner_state.is_open);
assert!(!view.control_master_error_banner_suppressed);
assert_eq!(
ctx.private_user_preferences()
.read_value(CONTROL_MASTER_BANNER_SUPPRESSED_KEY)
.unwrap(),
None,
"temporary dismissal should not persist a preference"
);
// Permanent dismissal ("Don't show me again") clears the banner and persists
// the choice so it never reopens.
view.control_master_error_banner_state.is_open = true;
view.handle_controlmaster_error_banner_event(
&BannerEvent::Dismiss(DismissalType::Permanent),
ctx,
);
assert!(!view.control_master_error_banner_state.is_open);
assert!(view.control_master_error_banner_suppressed);
assert_eq!(
ctx.private_user_preferences()
.read_value(CONTROL_MASTER_BANNER_SUPPRESSED_KEY)
.unwrap(),
Some("true".to_owned()),
"permanent dismissal should persist a preference"
);
});
})
}
// Regression test for GH#3548 / GH#6093: once the banner has been permanently
// dismissed it must not reopen on subsequent sessions.
#[test]
fn test_control_master_banner_suppressed_does_not_reopen() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
terminal.update(&mut app, |view, _ctx| {
// With no remote server and no prior dismissal, the banner may open.
view.control_master_error_banner_suppressed = false;
assert!(view.should_open_control_master_banner(/* has_remote_server */ false));
// A remote server makes the CTA irrelevant, so it stays closed.
assert!(!view.should_open_control_master_banner(/* has_remote_server */ true));
// Once permanently dismissed it must never reopen, even without a remote server.
view.control_master_error_banner_suppressed = true;
assert!(!view.should_open_control_master_banner(false));
});
})
}
#[test]
fn test_bash_vim_banner_already_shown() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
// Ensure the terminal is the active session.
terminal.update(&mut app, |view, ctx| {
let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id();
let focus_state = ctx.add_model(|_| {
PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false)
});
let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state);
view.set_focus_handle(focus_handle, ctx);
});
// The banner has already been shown and dismissed.
VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| {
let _ = banner_settings
.vim_keybindings_banner_state
.set_value(BannerState::Dismissed, ctx);
});
// Ensure Warp's vim keybindings are off.
AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| {
let _ = editor_settings.vim_mode.set_value(false, ctx);
});
// Bootstrap a bash session with vi mode enabled.
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "bash".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "bash".to_owned(),
shell_options: Some(HashSet::from(["vi_mode".to_string()])),
..Default::default()
});
});
// This is asynchronous because we're waiting for the bootstrap event
// to be sent from the terminal model to the terminal view.
assert_eventually!(
// Since the user already dismissed the banner, it should not
// be shown again.
terminal.read(&app, |terminal, _terminal_ctx| {
terminal.inline_banners_state.vim_banner_state.is_none()
}),
"Banner should not have opened"
);
})
}
#[test]
fn test_bash_vim_banner_on() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
// Ensure the terminal is the active session.
terminal.update(&mut app, |view, ctx| {
let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id();
let focus_state = ctx.add_model(|_| {
PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false)
});
let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state);
view.set_focus_handle(focus_handle, ctx);
});
// Ensure the banner has never been shown.
VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| {
let _ = banner_settings
.vim_keybindings_banner_state
.set_value(BannerState::NotDismissed, ctx);
});
// Ensure Warp's vim keybindings are off.
AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| {
let _ = editor_settings.vim_mode.set_value(false, ctx);
});
// Bootstrap a bash session with vi mode enabled.
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "bash".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "bash".to_owned(),
shell_options: Some(HashSet::from(["vi_mode".to_string()])),
..Default::default()
});
});
// This is asynchronous because we're waiting for the bootstrap event
// to be sent from the terminal model to the terminal view.
assert_eventually!(
// The vim keybinding banner should display.
200 => terminal.read(&app, |terminal, _terminal_ctx| {
terminal.inline_banners_state.vim_banner_state.is_some()
}),
"Banner did not open in time"
);
})
}
#[test]
fn test_bash_vim_banner_off() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
// Ensure the terminal is the active session.
terminal.update(&mut app, |view, ctx| {
let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id();
let focus_state = ctx.add_model(|_| {
PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false)
});
let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state);
view.set_focus_handle(focus_handle, ctx);
});
// Ensure the banner has never been shown.
VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| {
let _ = banner_settings
.vim_keybindings_banner_state
.set_value(BannerState::NotDismissed, ctx);
});
// Ensure Warp's vim keybindings are on.
AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| {
let _ = editor_settings.vim_mode.set_value(true, ctx);
});
// Bootstrap a bash session with vi mode enabled.
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "bash".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "bash".to_owned(),
shell_options: Some(HashSet::from(["vi_mode".to_string()])),
..Default::default()
});
});
// This is asynchronous because we're waiting for the bootstrap event
// to be sent from the terminal model to the terminal view.
assert_eventually!(
// The vim keybinding banner should NOT display
// because the user already has vim keybindings turned on.
terminal.read(&app, |terminal, _terminal_ctx| {
terminal.inline_banners_state.vim_banner_state.is_none()
}),
"Banner should not have opened"
);
})
}
#[test]
fn test_zsh_vim_banner_on() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
// Ensure the terminal is the active session.
terminal.update(&mut app, |view, ctx| {
let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id();
let focus_state = ctx.add_model(|_| {
PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false)
});
let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state);
view.set_focus_handle(focus_handle, ctx);
});
// Ensure the banner has never been shown.
VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| {
let _ = banner_settings
.vim_keybindings_banner_state
.set_value(BannerState::NotDismissed, ctx);
});
// Ensure Warp's vim keybindings are off.
AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| {
let _ = editor_settings.vim_mode.set_value(false, ctx);
});
// Bootstrap a zsh session with vi mode enabled.
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "zsh".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "zsh".to_owned(),
shell_plugins: Some(HashSet::from(["vi".to_string()])),
..Default::default()
});
});
// This is asynchronous because we're waiting for the bootstrap event
// to be sent from the terminal model to the terminal view.
assert_eventually!(
// The vim keybinding banner should display.
200 => terminal.read(&app, |terminal, _terminal_ctx| {
terminal.inline_banners_state.vim_banner_state.is_some()
}),
"Banner did not open in time"
);
})
}
#[test]
fn test_zsh_vim_banner_off() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
// Ensure the terminal is the active session.
terminal.update(&mut app, |view, ctx| {
let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id();
let focus_state = ctx.add_model(|_| {
PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false)
});
let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state);
view.set_focus_handle(focus_handle, ctx);
});
// Ensure the banner has never been shown.
VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| {
let _ = banner_settings
.vim_keybindings_banner_state
.set_value(BannerState::NotDismissed, ctx);
});
// Ensure Warp's vim keybindings are on.
AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| {
let _ = editor_settings.vim_mode.set_value(true, ctx);
});
// Bootstrap a zsh session with vi mode enabled.
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "zsh".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "zsh".to_owned(),
shell_plugins: Some(HashSet::from(["vi".to_string()])),
..Default::default()
});
});
// This is asynchronous because we're waiting for the bootstrap event
// to be sent from the terminal model to the terminal view.
assert_eventually!(
// The vim keybinding banner should NOT display
// because the user already has vim keybindings turned on.
terminal.read(&app, |terminal, _terminal_ctx| {
terminal.inline_banners_state.vim_banner_state.is_none()
}),
"Banner should not have opened"
);
})
}
#[test]
fn test_fish_vim_banner_on() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
// Ensure the terminal is the active session.
terminal.update(&mut app, |view, ctx| {
let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id();
let focus_state = ctx.add_model(|_| {
PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false)
});
let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state);
view.set_focus_handle(focus_handle, ctx);
});
// Ensure Warp's vim keybindings are off.
AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| {
let _ = editor_settings.vim_mode.set_value(false, ctx);
});
// Bootstrap a fish session with vi mode enabled.
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "fish".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "fish".to_owned(),
shell_options: Some(HashSet::from(["vi_mode".to_string()])),
..Default::default()
});
});
// This is asynchronous because we're waiting for the bootstrap event
// to be sent from the terminal model to the terminal view.
assert_eventually!(
// The vim keybinding banner should display.
200 => terminal.read(&app, |terminal, _terminal_ctx| {
terminal.inline_banners_state.vim_banner_state.is_some()
}),
"Banner did not open in time"
);
})
}
#[test]
fn test_fish_vim_banner_off() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal =
MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None);
// Ensure the terminal is the active session.
terminal.update(&mut app, |view, ctx| {
let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id();
let focus_state = ctx.add_model(|_| {
PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false)
});
let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state);
view.set_focus_handle(focus_handle, ctx);
});
// Ensure Warp's vim keybindings are on.
AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| {
let _ = editor_settings.vim_mode.set_value(true, ctx);
});
// Bootstrap a fish session with vi mode enabled.
terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "fish".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "fish".to_owned(),
shell_options: Some(HashSet::from(["vi_mode".to_string()])),
..Default::default()
});
});
// This is asynchronous because we're waiting for the bootstrap event
// to be sent from the terminal model to the terminal view.
assert_eventually!(
// The vim keybinding banner should NOT display
// because the user already has vim keybindings turned on.
terminal.read(&app, |terminal, _terminal_ctx| {
terminal.inline_banners_state.vim_banner_state.is_none()
}),
"Banner should not have opened"
);
})
}
#[test]
fn test_prompt_context_menu_items_for_ps1() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
SessionSettings::handle(&app).update(&mut app, |session_settings, ctx| {
let _ = session_settings.honor_ps1.set_value(true, ctx);
});
terminal.read(&app, |view, ctx| {
let items = view.prompt_context_menu_items(ctx);
let len = items.len();
assert_eq!(len, 3);
assert_eq!(items[0].fields().unwrap().label(), "Copy prompt");
assert!(items[1].is_separator());
assert_eq!(items[2].fields().unwrap().label(), "Edit prompt");
assert!(!items[2].fields().unwrap().is_disabled());
});
})
}
#[test]
fn test_prompt_context_menu_items_for_context_chips() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let model = view.model.lock();
view.current_prompt.update(ctx, |prompt, ctx| {
let PromptType::Dynamic { prompt } = prompt else {
return;
};
prompt.update(ctx, |prompt, ctx| {
prompt.update_context(model.block_list().active_block(), ctx)
});
})
});
// Set the prompt to something we can actually read for.
let prompt = Prompt::handle(&app);
prompt.update(&mut app, |prompt, ctx| {
prompt
.update(
[ContextChipKind::Time12],
false,
WarpPromptSeparator::None,
ctx,
)
.expect("updating prompt to time chip failed");
});
let session_settings = SessionSettings::handle(&app);
session_settings.update(&mut app, |settings, ctx| {
// Force a toggle so the change event fires.
let _ = settings.honor_ps1.set_value(true, ctx);
let _ = settings.honor_ps1.set_value(false, ctx);
});
terminal.read(&app, |view, ctx| {
let items: Vec<MenuItem<TerminalAction>> = view.prompt_context_menu_items(ctx);
assert_eq!(items.len(), 5);
// We expect the prompt menu items to be something like the following when context chips are used:
// Copy prompt
// ------------
// <context chip specific actions>
// ------------
// Edit prompt
assert_eq!(items[0].fields().unwrap().label(), "Copy prompt");
assert!(items[1].is_separator());
assert_eq!(
items[2].fields().unwrap().label(),
"Copy Time (12-hour format)"
);
assert!(items[3].is_separator());
assert_eq!(items[4].fields().unwrap().label(), "Edit prompt");
assert!(!items[4].fields().unwrap().is_disabled());
});
})
}
#[test]
fn test_prompt_context_menu_items_for_no_context_chips() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let model = view.model.lock();
view.current_prompt.update(ctx, |prompt, ctx| {
let PromptType::Dynamic { prompt } = prompt else {
return;
};
prompt.update(ctx, |prompt, ctx| {
prompt.update_context(model.block_list().active_block(), ctx)
});
})
});
let session_settings = SessionSettings::handle(&app);
session_settings.update(&mut app, |settings, ctx| {
let _ = settings.honor_ps1.set_value(false, ctx);
});
terminal.read(&app, |view, ctx| {
let items: Vec<MenuItem<TerminalAction>> = view.prompt_context_menu_items(ctx);
assert_eq!(items.len(), 3);
// We expect the prompt menu items to be something like the following when no context chips exist:
// Copy prompt
// ------------
// Edit prompt
assert_eq!(items[0].fields().unwrap().label(), "Copy prompt");
assert!(items[1].is_separator());
assert_eq!(items[2].fields().unwrap().label(), "Edit prompt");
assert!(!items[2].fields().unwrap().is_disabled());
});
})
}
#[test]
fn test_prompt_context_menu_items_for_agent_toolbelt_flag() {
let _agent_view_guard = FeatureFlag::AgentView.override_enabled(true);
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("Should be able to enter agent view");
});
});
{
let _agent_footer_guard = FeatureFlag::AgentToolbarEditor.override_enabled(false);
terminal.read(&app, |view, ctx| {
let items = view.prompt_context_menu_items(ctx);
let labels = items
.iter()
.filter_map(|item| item.fields().map(|fields| fields.label()))
.collect::<Vec<_>>();
assert!(!labels.contains(&"Edit prompt"));
assert!(!labels.contains(&"Edit agent toolbelt"));
});
}
{
let _agent_footer_guard = FeatureFlag::AgentToolbarEditor.override_enabled(true);
terminal.read(&app, |view, ctx| {
let items = view.prompt_context_menu_items(ctx);
let labels = items
.iter()
.filter_map(|item| item.fields().map(|fields| fields.label()))
.collect::<Vec<_>>();
assert!(!labels.contains(&"Edit prompt"));
assert!(labels.contains(&"Edit agent toolbelt"));
});
}
})
}
#[test]
fn agent_footer_updates_chip_groups_when_side_assignment_changes() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let model = view.model.lock();
view.current_prompt.update(ctx, |prompt, ctx| {
let PromptType::Dynamic { prompt } = prompt else {
return;
};
prompt.update(ctx, |prompt, ctx| {
prompt.update_context(model.block_list().active_block(), ctx);
});
});
});
SessionSettings::handle(&app).update(&mut app, |settings, ctx| {
let _ = settings.agent_footer_chip_selection.set_value(
AgentToolbarChipSelection::Custom {
left: vec![AgentToolbarItemKind::ContextChip(ContextChipKind::Time12)],
right: vec![AgentToolbarItemKind::ContextChip(ContextChipKind::Time24)],
},
ctx,
);
});
assert_eventually!(
terminal.read(&app, |view, ctx| {
view.input().as_ref(ctx).agent_footer_chip_kinds(ctx)
== (vec![ContextChipKind::Time12], vec![ContextChipKind::Time24])
}),
"Agent footer should render separate left and right chip groups"
);
SessionSettings::handle(&app).update(&mut app, |settings, ctx| {
let _ = settings.agent_footer_chip_selection.set_value(
AgentToolbarChipSelection::Custom {
left: vec![
AgentToolbarItemKind::ContextChip(ContextChipKind::Time12),
AgentToolbarItemKind::ContextChip(ContextChipKind::Time24),
],
right: vec![],
},
ctx,
);
});
assert_eventually!(
terminal.read(&app, |view, ctx| {
view.input().as_ref(ctx).agent_footer_chip_kinds(ctx)
== (
vec![ContextChipKind::Time12, ContextChipKind::Time24],
vec![],
)
}),
"Agent footer should update when a chip moves between sides without changing overall chip order"
);
})
}
#[test]
fn test_link_at_range_trims_zero_width_spaces() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// NOTE: this has two zero-width spaces, one after the '(', and one before the ')'
let input_url = "(\u{200b}https://warp.dev\u{200b})";
// NOTE: the final character in this string is a zero-width space
let non_escaped_url = "https://warp.dev\u{200b}";
let escaped_url = "https://warp.dev";
terminal.update(&mut app, |view, _ctx| {
view.model.lock().simulate_block(
r"printf '(%bhttps://warp.dev%b)\n' '\U200b' '\U200b'",
input_url,
);
});
terminal.read(&app, |view, ctx| {
let model = view.model.lock();
let block = view
.viewport_state(model.block_list(), InputMode::PinnedToBottom, ctx)
.iter()
.next()
.expect("blocklist should have at least one item");
let point = WithinModel::BlockList(WithinBlock::new(
// I picked the point 0, 4 b/c it seemed to work. It's not clear to me
// why 4 works when numbers like 9 do not. Either way, this is just to
// get the actual url out (passing 9 fails on url_at_point), and does
// not matter for testing link_at_range.
Point::new(0, 4),
block.block_index.expect("block index should exist"),
crate::terminal::GridType::Output,
));
let url = model
.url_at_point(&point)
.expect("url at the designated point should exist");
// Assert that string_at_range preserves the ZW Space
assert_eq!(
model.string_at_range(&url, RespectObfuscatedSecrets::No),
non_escaped_url
);
// Assert that link_at_range removes the ZW Space
assert_eq!(
model.link_at_range(&url, RespectObfuscatedSecrets::No),
escaped_url
);
});
})
}
#[test]
fn test_scroll_position_doesnt_change_when_block_finished() {
use futures_lite::StreamExt;
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let (tx, rx) = async_channel::bounded(1);
app.update(|ctx| {
ctx.subscribe_to_view(&terminal, move |_, event, _| {
if let Event::BlockCompleted { block, .. } = event {
let output = std::str::from_utf8(&block.stylized_output).unwrap();
if output.trim() == "lr" {
tx.try_send(()).expect("Can send over channel");
}
}
});
});
let scroll_position_before_finished = terminal.update(&mut app, |view, ctx| {
// Finish a lengthy block.
view.model.lock().simulate_block("ls", &"\n".repeat(1000));
assert!(view.is_vertically_scrollable(ctx));
assert_eq!(
view.scroll_position(),
ScrollPosition::FollowsBottomOfMostRecentBlock
);
// Start long-running block.
view.model.lock().simulate_long_running_block("", "lr");
// Before the block is finished, scroll up.
view.scroll(1.0.into_lines(), ctx);
let scroll_position_before_finished = view.scroll_position();
assert!(matches!(
scroll_position_before_finished,
ScrollPosition::FixedAtPosition { .. }
));
// Finish the block.
view.model.lock().finish_block();
scroll_position_before_finished
});
// Wait until the terminal view acknowledges the block as completed.
assert!(pin!(rx).next().await.is_some());
// Make sure the scroll position is unchanged when the block finishes.
terminal.read(&app, |view, _| {
let scroll_position_after_finished = view.scroll_position();
assert_eq!(
scroll_position_before_finished,
scroll_position_after_finished
);
});
})
}
#[test]
fn inline_agent_view_exits_when_tagged_in_long_running_command_is_tagged_out() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "zsh".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "zsh".to_owned(),
..Default::default()
});
model.simulate_long_running_block("sleep 10", "running");
}
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_inline_agent_view(
None,
AgentViewEntryOrigin::LongRunningCommand,
ctx,
)
.expect("should enter inline agent view for a tagged-in command");
});
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_is_agent_tagged_in(true);
assert!(view.agent_view_controller().as_ref(ctx).is_inline());
assert!(view
.model
.lock()
.block_list()
.active_block()
.is_agent_tagged_in());
let model = view.model.lock();
assert!(view.is_input_box_visible(&model, ctx));
drop(model);
view.handle_action(&TerminalAction::SetInputModeTerminal, ctx);
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
let model = view.model.lock();
let active_block = model.block_list().active_block();
assert!(!active_block.is_agent_tagged_in());
assert!(!view.is_input_box_visible(&model, ctx));
});
})
}
#[test]
fn agent_view_keeps_input_visible_for_agent_requested_and_blocked_commands() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let conversation_id = view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("should enter agent view")
});
bootstrap_with_long_running_block(view);
set_active_block_agent_driving(view, conversation_id);
{
let model = view.model.lock();
let active_block = model.block_list().active_block();
assert!(active_block.is_agent_driving_command());
assert!(view.is_input_box_visible(&model, ctx));
}
let task_id = TaskId::new("test-cli-subagent".to_owned());
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id)
.expect("agent-requested command should become monitored");
view.model
.lock()
.block_list_mut()
.active_block_mut()
.update_is_agent_blocked(true);
let model = view.model.lock();
let active_block = model.block_list().active_block();
assert!(active_block.is_agent_blocked());
assert!(view.is_input_box_visible(&model, ctx));
});
})
}
#[test]
fn ctrl_c_after_stop_takeover_cancels_conversation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let pty_writes: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
let writes = pty_writes.clone();
app.update(|ctx| {
ctx.subscribe_to_view(&terminal, move |_, event, _| {
if let Event::WriteBytesToPty { bytes } = event {
writes.borrow_mut().push(bytes.to_vec());
}
});
});
let conversation_id = terminal.update(&mut app, |view, ctx| {
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.start_new_conversation(view.view_id, false, false, false, ctx)
});
view.model
.lock()
.simulate_long_running_block("sleep 20", "running");
let task_id = TaskId::new("test-cli-subagent".to_owned());
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id)
.expect("command should become agent monitored");
view.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.switch_control_to_user(UserTakeOverReason::Stop, ctx);
});
conversation_id
});
terminal.update(&mut app, |view, ctx| {
view.handle_action(&TerminalAction::CtrlC, ctx);
});
assert_eq!(*pty_writes.borrow(), vec![vec![C0::ETX]]);
terminal.read(&app, |_, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.expect("conversation should exist");
assert_eq!(conversation.status(), &ConversationStatus::Cancelled);
});
})
}
#[test]
fn status_bar_stop_cancels_conversation_after_exchange_finishes() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let conversation_id = terminal.update(&mut app, |view, ctx| {
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.start_new_conversation(view.view_id, false, false, false, ctx)
});
let stream_id = ResponseStreamId::new_for_test();
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_reassigned_exchange(
&stream_id,
exchange_with_inputs(vec![]),
view.view_id,
ctx,
)
.expect("exchange should append");
});
let stream = ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
view.ai_controller.update(ctx, |controller, ctx| {
controller.register_mock_stream_for_test(stream_id, conversation_id, stream, ctx);
});
conversation_id
});
terminal.update(&mut app, |view, ctx| {
view.input.update(ctx, |input, ctx| {
input.agent_status_bar().update(ctx, |status_bar, ctx| {
status_bar.handle_action(&BlocklistAIStatusBarAction::Stop, ctx);
});
});
});
terminal.read(&app, |_, ctx| {
assert_eq!(
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.expect("conversation should exist")
.status(),
&ConversationStatus::Cancelled
);
});
})
}
#[test]
fn ctrl_c_after_transfer_takeover_does_not_cancel_conversation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let pty_writes: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
let writes = pty_writes.clone();
app.update(|ctx| {
ctx.subscribe_to_view(&terminal, move |_, event, _| {
if let Event::WriteBytesToPty { bytes } = event {
writes.borrow_mut().push(bytes.to_vec());
}
});
});
let conversation_id = terminal.update(&mut app, |view, ctx| {
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.start_new_conversation(view.view_id, false, false, false, ctx)
});
view.model
.lock()
.simulate_long_running_block("ssh localhost", "Password:");
let task_id = TaskId::new("test-cli-subagent".to_owned());
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id)
.expect("command should become agent monitored");
view.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.switch_control_to_user(
UserTakeOverReason::TransferFromAgent {
reason: "Enter your password".to_owned(),
},
ctx,
);
});
conversation_id
});
terminal.update(&mut app, |view, ctx| {
view.handle_action(&TerminalAction::CtrlC, ctx);
});
assert_eq!(*pty_writes.borrow(), vec![vec![C0::ETX]]);
terminal.read(&app, |_, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.expect("conversation should exist");
// Transfer takeover is still waiting to report control handback or command
// completion to the agent, so Ctrl-C should interrupt the command without
// cancelling the conversation.
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
});
})
}
#[test]
fn inline_agent_view_persists_across_transfer_takeover_for_monitored_long_running_command() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "zsh".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "zsh".to_owned(),
..Default::default()
});
model.simulate_long_running_block("ssh localhost", "Password:");
}
let conversation_id = view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_inline_agent_view(
None,
AgentViewEntryOrigin::LongRunningCommand,
ctx,
)
.expect("inline agent view should create a conversation")
});
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_is_agent_tagged_in(true);
let task_id = TaskId::new("test-task".to_owned());
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id)
.expect("tagged-in command should transition to agent-monitored");
assert!(view.agent_view_controller().as_ref(ctx).is_inline());
let model = view.model.lock();
assert!(model.block_list().active_block().is_agent_in_control());
assert!(view.is_input_box_visible(&model, ctx));
drop(model);
view.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.switch_control_to_user(
UserTakeOverReason::TransferFromAgent {
reason: "Enter your password".to_owned(),
},
ctx,
);
});
assert!(view.agent_view_controller().as_ref(ctx).is_inline());
let model = view.model.lock();
let active_block = model.block_list().active_block();
assert!(active_block.is_eligible_for_agent_handoff());
assert!(!view.is_input_box_visible(&model, ctx));
drop(model);
view.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.handoff_active_command_control_to_agent(ctx);
});
assert!(view.agent_view_controller().as_ref(ctx).is_inline());
let model = view.model.lock();
let active_block = model.block_list().active_block();
assert!(active_block.is_agent_in_control());
assert!(view.is_input_box_visible(&model, ctx));
});
})
}
#[test]
fn use_agent_footer_renders_for_transfer_handoff_even_when_user_command_footer_setting_disabled() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
let _ = settings
.should_render_use_agent_footer_for_user_commands
.set_value(false, ctx);
});
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
{
let mut model = view.model.lock();
model.init_shell(InitShellValue {
session_id: 0.into(),
shell: "zsh".to_owned(),
..Default::default()
});
model.bootstrapped(BootstrappedValue {
shell: "zsh".to_owned(),
..Default::default()
});
model.simulate_long_running_block("ssh localhost", "Password:");
}
view.maybe_show_use_agent_footer_in_blocklist(ctx);
{
let model = view.model.lock();
assert!(!view.should_render_use_agent_footer(&model, ctx));
let active_block_index = model.block_list().active_block_index();
assert!(model
.block_list()
.last_non_hidden_rich_content_block_after_block(Some(active_block_index))
.is_none());
}
let conversation_id = view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_inline_agent_view(
None,
AgentViewEntryOrigin::LongRunningCommand,
ctx,
)
.expect("inline agent view should create a conversation")
});
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_is_agent_tagged_in(true);
let task_id = TaskId::new("test-task".to_owned());
view.model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id)
.expect("tagged-in command should transition to agent-monitored");
view.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.switch_control_to_user(
UserTakeOverReason::TransferFromAgent {
reason: "Enter your password".to_owned(),
},
ctx,
);
});
view.maybe_show_use_agent_footer_in_blocklist(ctx);
let model = view.model.lock();
assert!(view.should_render_use_agent_footer(&model, ctx));
let active_block_index = model.block_list().active_block_index();
let rendered_footer_view_id = model
.block_list()
.last_non_hidden_rich_content_block_after_block(Some(active_block_index))
.map(|(_, item)| item.view_id);
assert_eq!(rendered_footer_view_id, Some(view.use_agent_footer.id()));
});
})
}
#[test]
fn exiting_agent_view_removes_empty_conversations() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// Enter agent view (creates new conversation)
let conversation_id = terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("Should be able to enter agent view")
})
});
// Entering agent view without specifying a conversation creates a new conversation.
let exists_before_exit = BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
history
.conversation(&conversation_id)
.is_some_and(|c| c.exchange_count() == 0)
});
assert!(exists_before_exit);
// Sanity: conversation exists but has no exchanges.
terminal.update(&mut app, |view, ctx| {
view.agent_view_controller()
.update(ctx, |controller, ctx| controller.exit_agent_view(ctx))
});
// Exiting agent view should remove the empty conversation.
let exists_after_exit = BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
history.conversation(&conversation_id).is_some()
});
assert!(!exists_after_exit);
})
}
#[test]
fn ctrl_c_exit_agent_view_requires_confirmation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
// Enter agent view (creates new conversation)
terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("Should be able to enter agent view")
})
});
// First ctrl-c should arm confirmation but not exit.
terminal.update(&mut app, |view, ctx| {
assert!(view.agent_view_controller().as_ref(ctx).is_active());
view.handle_input_event(
&InputEvent::CtrlC {
cleared_buffer_len: 0,
},
ctx,
);
assert!(view.agent_view_controller().as_ref(ctx).is_active());
});
// Second ctrl-c should confirm and exit.
terminal.update(&mut app, |view, ctx| {
view.handle_input_event(
&InputEvent::CtrlC {
cleared_buffer_len: 0,
},
ctx,
);
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
});
})
}
#[test]
fn ctrl_c_buffer_clear_then_exit_requires_three_presses_in_agent_view() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("Should be able to enter agent view")
})
});
// 1st ctrl-c clears input buffer (simulated) and should not trigger cancel/exit.
terminal.update(&mut app, |view, ctx| {
assert!(view.agent_view_controller().as_ref(ctx).is_active());
view.handle_input_event(
&InputEvent::CtrlC {
cleared_buffer_len: 5,
},
ctx,
);
assert!(view.agent_view_controller().as_ref(ctx).is_active());
});
// 2nd ctrl-c arms exit confirmation.
terminal.update(&mut app, |view, ctx| {
view.handle_input_event(
&InputEvent::CtrlC {
cleared_buffer_len: 0,
},
ctx,
);
assert!(view.agent_view_controller().as_ref(ctx).is_active());
});
// 3rd ctrl-c confirms and exits.
terminal.update(&mut app, |view, ctx| {
view.handle_input_event(
&InputEvent::CtrlC {
cleared_buffer_len: 0,
},
ctx,
);
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
});
})
}
#[test]
fn ctrl_c_with_nonempty_agent_input_cancels_active_conversation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let conversation_id = terminal.update(&mut app, |view, ctx| {
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.start_new_conversation(view.view_id, false, false, false, ctx)
});
let stream_id = ResponseStreamId::new_for_test();
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_reassigned_exchange(
&stream_id,
exchange_with_inputs(vec![]),
view.view_id,
ctx,
)
.expect("exchange should append");
});
conversation_id
});
terminal.update(&mut app, |view, ctx| {
view.handle_input_event(
&InputEvent::CtrlC {
cleared_buffer_len: 4,
},
ctx,
);
});
terminal.read(&app, |_, ctx| {
assert_eq!(
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.map(|conversation| conversation.status()),
Some(&ConversationStatus::Cancelled),
"Ctrl+C must cancel the agent instead of only clearing its draft"
);
});
})
}
#[test]
fn terminal_action_ctrl_c_exit_agent_view_requires_confirmation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("Should be able to enter agent view")
})
});
terminal.update(&mut app, |view, ctx| {
assert!(view.agent_view_controller().as_ref(ctx).is_active());
view.handle_action(&TerminalAction::CtrlC, ctx);
assert!(view.agent_view_controller().as_ref(ctx).is_active());
});
terminal.update(&mut app, |view, ctx| {
view.handle_action(&TerminalAction::CtrlC, ctx);
assert!(!view.agent_view_controller().as_ref(ctx).is_active());
});
})
}
/// Sets up a CLI agent session, opens rich input, submits `text`, and returns
/// the terminal handle and the collected PTY writes.
#[allow(clippy::type_complexity)]
fn submit_rich_input_and_collect_pty_writes(
app: &mut App,
agent: CLIAgent,
text: &str,
) -> (ViewHandle<TerminalView>, Rc<RefCell<Vec<Vec<u8>>>>) {
let terminal = add_window_with_terminal(app, None);
let pty_writes: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
let writes = pty_writes.clone();
app.update(|ctx| {
ctx.subscribe_to_view(&terminal, move |_, event, _| {
if let Event::WriteBytesToPty { bytes } = event {
writes.borrow_mut().push(bytes.to_vec());
}
});
});
terminal.update(app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
view.submit_cli_agent_rich_input(text.to_owned(), ctx);
});
(terminal, pty_writes)
}
fn open_cli_agent_rich_input_for_agent(app: &mut App, agent: CLIAgent) -> ViewHandle<TerminalView> {
open_cli_agent_rich_input_for_agent_with_window_id(app, agent).1
}
fn open_cli_agent_rich_input_for_agent_with_window_id(
app: &mut App,
agent: CLIAgent,
) -> (WindowId, ViewHandle<TerminalView>) {
let (window_id, terminal) = add_window_with_id_and_terminal(app, None);
terminal.update(app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
});
(window_id, terminal)
}
/// Verifies that Ctrl-G closes CLI agent rich input when dispatched from the
/// focused editor context. This is a regression test for #9286 where the
/// keybinding only matched the terminal context, not the embedded editor.
#[test]
fn ctrl_g_closes_cli_agent_rich_input_when_editor_is_focused() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
app.add_singleton_model(ImportedConfigModel::new);
// Register keybindings so keystroke dispatch can match the Ctrl-G binding.
app.update(|ctx| {
crate::terminal::init(ctx);
crate::editor::init(ctx);
});
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let (window_id, terminal) =
open_cli_agent_rich_input_for_agent_with_window_id(&mut app, CLIAgent::OpenCode);
// Dispatch Ctrl-G through the focused editor's responder chain.
let (input_id, editor_id) = terminal.read(&app, |view, ctx| {
let input = view.input.clone();
let editor = input.as_ref(ctx).editor().clone();
(input.id(), editor.id())
});
let handled = app
.dispatch_keystroke(
window_id,
&[terminal.id(), input_id, editor_id],
&warpui::keymap::Keystroke::parse("ctrl-g").expect("valid keystroke"),
false,
)
.expect("dispatch should succeed");
assert!(handled, "ctrl-g should be handled from the focused editor");
terminal.read(&app, |view, ctx| {
assert!(
!view.has_active_cli_agent_input_session(ctx),
"rich input should be closed after Ctrl-G"
);
});
})
}
/// Verifies that Ctrl-G closes CLI agent rich input when dispatched from the
/// terminal context alone (no editor in the responder chain). Regression test
/// for #9916 where the keybinding only opened rich input but did not close it
/// in scenarios where focus was outside the embedded editor and the active
/// block had transitioned out of `LongRunningCommand` — for example, when the
/// CLI agent has paused waiting for user input.
#[test]
fn ctrl_g_closes_cli_agent_rich_input_from_terminal_context() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
app.add_singleton_model(ImportedConfigModel::new);
// Register keybindings so keystroke dispatch can match the Ctrl-G binding.
app.update(|ctx| {
crate::terminal::init(ctx);
crate::editor::init(ctx);
});
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let (window_id, terminal) =
open_cli_agent_rich_input_for_agent_with_window_id(&mut app, CLIAgent::OpenCode);
// Dispatch Ctrl-G with only the terminal view in the responder chain.
// This simulates the case where focus is not on the embedded editor
// (e.g., on the block list) and the previous Case 1 / Case 2 predicates
// would both fail to match.
let handled = app
.dispatch_keystroke(
window_id,
&[terminal.id()],
&warpui::keymap::Keystroke::parse("ctrl-g").expect("valid keystroke"),
false,
)
.expect("dispatch should succeed");
assert!(
handled,
"ctrl-g should be handled from the terminal context when rich input is open"
);
terminal.read(&app, |view, ctx| {
assert!(
!view.has_active_cli_agent_input_session(ctx),
"rich input should be closed after Ctrl-G from terminal context"
);
});
})
}
/// Verifies that Ctrl-G is a true toggle: opens then closes rich input from
/// the terminal context. Regression test for #9916.
#[test]
fn ctrl_g_toggles_cli_agent_rich_input_from_terminal_context() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
app.add_singleton_model(ImportedConfigModel::new);
app.update(|ctx| {
crate::terminal::init(ctx);
crate::editor::init(ctx);
});
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
// Start with rich input open, then close via Ctrl-G, then re-open via
// direct call (Ctrl-G open path requires LongRunningCommand which is
// tricky to simulate in a unit test), then close via Ctrl-G again.
let (window_id, terminal) =
open_cli_agent_rich_input_for_agent_with_window_id(&mut app, CLIAgent::OpenCode);
let keystroke = warpui::keymap::Keystroke::parse("ctrl-g").expect("valid keystroke");
// First close: rich input is open → Ctrl-G should close.
let handled = app
.dispatch_keystroke(window_id, &[terminal.id()], &keystroke, false)
.expect("dispatch should succeed");
assert!(handled, "first ctrl-g should be handled (close)");
terminal.read(&app, |view, ctx| {
assert!(
!view.has_active_cli_agent_input_session(ctx),
"rich input should be closed after first Ctrl-G"
);
});
// Re-open programmatically (mirrors the user re-triggering open via
// Ctrl-G in a long-running context).
terminal.update(&mut app, |view, ctx| {
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::CtrlG, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
});
// Second close: rich input is open again → Ctrl-G should close again.
let handled = app
.dispatch_keystroke(window_id, &[terminal.id()], &keystroke, false)
.expect("dispatch should succeed");
assert!(handled, "second ctrl-g should be handled (close again)");
terminal.read(&app, |view, ctx| {
assert!(
!view.has_active_cli_agent_input_session(ctx),
"rich input should be closed after second Ctrl-G"
);
});
})
}
#[test]
fn cli_agent_rich_input_hint_text_mentions_active_cli_agent() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
for (agent, expected_hint_text) in [
(CLIAgent::Claude, "Enter prompt for Claude Code..."),
(CLIAgent::Gemini, "Enter prompt for Gemini..."),
(CLIAgent::Codex, "Enter prompt for Codex..."),
(CLIAgent::Unknown, "Tell the agent what to build..."),
] {
let terminal = open_cli_agent_rich_input_for_agent(&mut app, agent);
terminal.read(&app, |view, ctx| {
let placeholder_text = view
.input
.as_ref(ctx)
.editor()
.as_ref(ctx)
.placeholder_text("");
assert_eq!(placeholder_text, Some(expected_hint_text));
});
}
})
}
#[test]
fn cli_agent_rich_input_shell_mode_uses_run_commands_hint_text() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude);
terminal.update(&mut app, |view, ctx| {
view.input.update(ctx, |input, ctx| {
input.ai_input_model().update(ctx, |ai_input, ctx| {
ai_input.set_input_config(
InputConfig {
input_type: InputType::Shell,
is_locked: true,
},
true,
None,
ctx,
);
});
input.set_zero_state_hint_text(ctx);
});
});
terminal.read(&app, |view, ctx| {
let placeholder_text = view
.input
.as_ref(ctx)
.editor()
.as_ref(ctx)
.placeholder_text("");
assert_eq!(placeholder_text, Some("Run commands"));
});
})
}
#[test]
fn submit_cli_agent_rich_input_codex_uses_bracketed_paste() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let (_terminal, pty_writes) =
submit_rich_input_and_collect_pty_writes(&mut app, CLIAgent::Codex, "hello");
let writes = pty_writes.borrow();
// BracketedPaste: first write is ESC[200~ + text + ESC[201~, second is \r.
assert_eq!(
writes.len(),
2,
"expected 2 PTY writes, got {}",
writes.len()
);
let mut expected_paste =
Vec::with_capacity(BRACKETED_PASTE_START.len() + 5 + BRACKETED_PASTE_END.len());
expected_paste.extend_from_slice(BRACKETED_PASTE_START);
expected_paste.extend_from_slice(b"hello");
expected_paste.extend_from_slice(BRACKETED_PASTE_END);
assert_eq!(writes[0], expected_paste);
assert_eq!(writes[1], b"\r");
})
}
#[test]
fn submit_cli_agent_rich_input_opencode_defers_enter_and_close() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let (_terminal, pty_writes) =
submit_rich_input_and_collect_pty_writes(&mut app, CLIAgent::OpenCode, "hello");
// Immediately after submit, only the text should have been written;
// the \r is sent after a short delay.
assert_eq!(pty_writes.borrow().len(), 1);
assert_eq!(pty_writes.borrow()[0], b"hello");
// Wait for the delayed \r to arrive.
assert_eventually!(
pty_writes.borrow().len() == 2,
"carriage return should be written after delay"
);
assert_eq!(pty_writes.borrow()[1], b"\r");
})
}
#[test]
fn attach_path_as_context_routes_to_open_cli_agent_rich_input() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let _hoa_code_review = FeatureFlag::HoaCodeReview.override_enabled(true);
let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude);
let pty_writes: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
let writes = pty_writes.clone();
app.update(|ctx| {
ctx.subscribe_to_view(&terminal, move |_, event, _| {
if let Event::WriteBytesToPty { bytes } = event {
writes.borrow_mut().push(bytes.to_vec());
}
});
});
terminal.update(&mut app, |view, ctx| {
view.attach_path_as_context(std::path::Path::new("src/main.rs"), ctx);
});
terminal.read(&app, |view, ctx| {
assert_eq!(view.input.as_ref(ctx).buffer_text(ctx), "src/main.rs");
});
assert!(
pty_writes.borrow().is_empty(),
"context should be inserted into rich input instead of written to PTY"
);
})
}
#[test]
fn drag_drop_image_in_cli_agent_long_running_command_pastes_via_clipboard() {
// Regression test: dropping an image file into a tab where a CLI agent
// (e.g. Claude Code) is the foreground long-running process should
// mirror the Cmd+V image-paste path — write the image to the system
// clipboard and send the agent's paste keystroke to the PTY — instead
// of shell-escaping the path and typing it into the agent's prompt.
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
// The new path actually reads the file off disk, so we need a real
// file. Bytes don't have to be a valid PNG.
let mut image_path = std::env::temp_dir();
image_path.push(format!(
"warp-test-cli-agent-drop-{}.png",
std::process::id()
));
std::fs::write(&image_path, b"fake-png-bytes").expect("write tmp image");
let image_path_str = image_path.to_string_lossy().into_owned();
let terminal = add_window_with_terminal(&mut app, None);
let pty_writes: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
let writes = pty_writes.clone();
app.update(|ctx| {
ctx.subscribe_to_view(&terminal, move |_, event, _| {
if let Event::WriteBytesToPty { bytes } = event {
writes.borrow_mut().push(bytes.to_vec());
}
});
});
terminal.update(&mut app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
// The CLI-agent paste branch is gated on the active block being
// long-running (the agent's TUI). Without a long-running block
// we'd fall through to the regular image-attach flow.
{
let mut model = view.model.lock();
model.simulate_long_running_block("claude", "");
assert!(model
.block_list()
.active_block()
.is_active_and_long_running());
}
view.drag_and_drop_files(&[image_path_str], ctx);
});
// The paste flow is async (off-thread file read, then hop back to
// the view to write the clipboard + paste keystroke). Wait for the
// single PTY write of the platform-appropriate paste byte: 0x16
// (Ctrl+V) on macOS/Linux, or `ESC v` on Windows. Without the fix
// a shell-escaped path string is written here instead.
let expected_paste_bytes: Vec<u8> = if cfg!(windows) {
vec![0x1b, b'v']
} else {
vec![0x16]
};
assert_eventually!(
pty_writes.borrow().len() == 1 && pty_writes.borrow()[0] == expected_paste_bytes,
"expected single paste-keystroke PTY write {:?}; got {:?}",
expected_paste_bytes,
pty_writes.borrow()
);
std::fs::remove_file(&image_path).ok();
})
}
#[test]
fn paste_raw_image_clipboard_in_cli_agent_sends_correct_bytes() {
fn run_for_agent(agent: CLIAgent) {
App::test((), move |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let pty_writes: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
let writes = pty_writes.clone();
app.update(|ctx| {
ctx.subscribe_to_view(&terminal, move |_, event, _| {
if let Event::WriteBytesToPty { bytes } = event {
writes.borrow_mut().push(bytes.to_vec());
}
});
});
terminal.update(&mut app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
{
let mut model = view.model.lock();
model.simulate_long_running_block(agent.command_prefix(), "");
model.set_mode(ansi::Mode::BracketedPaste);
}
// Write image-only data to the clipboard (no text, no paths).
ctx.clipboard().write(ClipboardContent {
images: Some(vec![warpui::clipboard::ImageData {
data: vec![0x89, 0x50, 0x4E, 0x47], // PNG magic bytes
mime_type: "image/png".to_string(),
filename: None,
}]),
..Default::default()
});
view.handle_action(&TerminalAction::Paste, ctx);
});
let writes = pty_writes.borrow();
assert_eq!(
writes.len(),
1,
"expected 1 PTY write, got {}",
writes.len()
);
if cfg!(windows) {
if agent == CLIAgent::Claude {
assert_eq!(writes[0], vec![C0::ESC, b'v']);
} else {
let mut expected = Vec::new();
expected.extend_from_slice(BRACKETED_PASTE_START);
expected.extend_from_slice(BRACKETED_PASTE_END);
assert_eq!(writes[0], expected);
}
} else {
assert_eq!(writes[0], vec![C0::SYN]);
}
})
}
run_for_agent(CLIAgent::Claude);
run_for_agent(CLIAgent::OpenCode);
run_for_agent(CLIAgent::Codex);
}
#[test]
fn submit_without_auto_dismiss_keeps_rich_input_open() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
// auto_dismiss defaults to false — leave it off.
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
view.submit_cli_agent_rich_input("hello".to_owned(), ctx);
// Rich input stays open because auto_dismiss is off.
assert!(view.has_active_cli_agent_input_session(ctx));
});
// Buffer should still be cleared even though rich input is open.
terminal.read(&app, |view, ctx| {
let input = view.input.as_ref(ctx);
assert!(input.editor().as_ref(ctx).buffer_text(ctx).is_empty());
});
})
}
#[test]
fn submit_with_plugin_and_auto_toggle_keeps_rich_input_open() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
// auto_toggle_rich_input defaults to true.
// Turn on auto_dismiss too — it should be overridden by auto_toggle.
AISettings::handle(&app).update(&mut app, |settings, ctx| {
let _ = settings
.auto_dismiss_rich_input_after_submit
.set_value(true, ctx);
});
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
// Create a session with a plugin listener and should_auto_toggle_input.
let listener = ctx.add_model(|ctx| {
CLIAgentSessionListener::new(
view.view_id,
CLIAgent::Claude,
&view.model_events_handle,
ctx,
)
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: true,
listener: Some(listener),
remote_host: None,
plugin_version: Some("1.0.0".to_owned()),
draft_text: None,
custom_command_prefix: None,
received_rich_notification: true,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
view.submit_cli_agent_rich_input("hello".to_owned(), ctx);
// Rich input stays open because auto_toggle + plugin takes precedence.
assert!(view.has_active_cli_agent_input_session(ctx));
});
})
}
#[test]
fn submit_with_plugin_but_auto_toggle_off_respects_auto_dismiss() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
let _ = settings.auto_toggle_rich_input.set_value(false, ctx);
let _ = settings
.auto_dismiss_rich_input_after_submit
.set_value(true, ctx);
});
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let listener = ctx.add_model(|ctx| {
CLIAgentSessionListener::new(
view.view_id,
CLIAgent::Claude,
&view.model_events_handle,
ctx,
)
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: true,
listener: Some(listener),
remote_host: None,
plugin_version: Some("1.0.0".to_owned()),
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
view.submit_cli_agent_rich_input("hello".to_owned(), ctx);
});
// auto_toggle is off, so auto_dismiss closes rich input.
// Claude uses DelayedEnter, so the close happens after a timer.
assert_eventually!(
terminal.read(&app, |view, ctx| !view
.has_active_cli_agent_input_session(ctx)),
"Rich input should be closed after submit with auto_dismiss"
);
})
}
#[test]
fn status_blocked_auto_closes_rich_input() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
// auto_toggle_rich_input defaults to true.
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let listener = ctx.add_model(|ctx| {
CLIAgentSessionListener::new(
view.view_id,
CLIAgent::Claude,
&view.model_events_handle,
ctx,
)
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: true,
listener: Some(listener),
remote_host: None,
plugin_version: Some("1.0.0".to_owned()),
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
// Simulate a PermissionRequest event → status transitions to Blocked.
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionRequest,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload {
summary: Some("Approve?".to_owned()),
..Default::default()
},
},
ctx,
);
});
});
// The StatusChanged event is delivered to the terminal view, which
// auto-closes rich input because the agent is blocked.
terminal.read(&app, |view, ctx| {
assert!(!view.has_active_cli_agent_input_session(ctx));
});
// should_auto_toggle_input is preserved so auto-open can fire later.
terminal.read(&app, |_view, ctx| {
let session = CLIAgentSessionsModel::as_ref(ctx).session(_view.view_id);
assert!(session.unwrap().should_auto_toggle_input);
});
})
}
#[test]
fn status_in_progress_auto_opens_rich_input_after_blocked() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let listener = ctx.add_model(|ctx| {
CLIAgentSessionListener::new(
view.view_id,
CLIAgent::Claude,
&view.model_events_handle,
ctx,
)
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: true,
listener: Some(listener),
remote_host: None,
plugin_version: Some("1.0.0".to_owned()),
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
// Open rich input, then simulate blocked → closed automatically.
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionRequest,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload {
summary: Some("Approve?".to_owned()),
..Default::default()
},
},
ctx,
);
});
});
// Rich input should be auto-closed from the blocked status.
terminal.read(&app, |view, ctx| {
assert!(!view.has_active_cli_agent_input_session(ctx));
});
// Simulate permission replied → status transitions back to InProgress.
terminal.update(&mut app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionReplied,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
},
ctx,
);
});
});
// Rich input should auto-open because should_auto_toggle_input was preserved.
terminal.read(&app, |view, ctx| {
assert!(view.has_active_cli_agent_input_session(ctx));
});
})
}
// Regression test for https://github.com/warpdotdev/warp/issues/9059.
// Codex's listener doesn't emit Blocked-state events (it only forwards opaque
// OSC 9 notifications as Stop), so auto-toggling rich input would trap arrow
// keys when Codex shows interactive option menus. Auto-toggle must not fire
// for agents whose handlers report `supports_rich_status() == false`.
#[test]
fn codex_status_change_does_not_auto_open_rich_input() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
// auto_toggle_rich_input defaults to true.
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let listener = ctx.add_model(|ctx| {
CLIAgentSessionListener::new(
view.view_id,
CLIAgent::Codex,
&view.model_events_handle,
ctx,
)
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Codex,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: true,
listener: Some(listener),
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
// Rich input starts closed. Simulating a Stop event (the only
// status Codex's handler ever emits) must not re-open it,
// because the user may be navigating Codex's option menus.
assert!(!view.has_active_cli_agent_input_session(ctx));
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::CodexOsc9Fallback,
v: 1,
agent: CLIAgent::Codex,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload {
query: Some("Agent turn complete".to_owned()),
..Default::default()
},
},
ctx,
);
});
});
terminal.read(&app, |view, ctx| {
assert!(!view.has_active_cli_agent_input_session(ctx));
});
})
}
#[test]
fn cli_session_status_updates_active_child_conversation() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let child_conversation_id = terminal.update(&mut app, |view, ctx| {
let parent_conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_conversation(view.view_id, false, false, false, ctx)
});
let child_conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_child_conversation(
view.view_id,
"Agent 2".to_string(),
parent_conversation_id,
None,
ctx,
)
});
view.enter_agent_view(
None,
Some(child_conversation_id),
AgentViewEntryOrigin::ChildAgent,
ctx,
);
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
child_conversation_id
});
terminal.read(&app, |_view, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.expect("child conversation should exist");
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
});
terminal.update(&mut app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionRequest,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload {
summary: Some("Approve?".to_owned()),
..Default::default()
},
},
ctx,
);
});
});
terminal.read(&app, |_view, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.expect("child conversation should exist");
assert_eq!(
conversation.status(),
&ConversationStatus::Blocked {
blocked_action: "Approve?".to_string(),
}
);
});
terminal.update(&mut app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionReplied,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
},
ctx,
);
});
});
terminal.read(&app, |_view, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.expect("child conversation should exist");
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
});
terminal.update(&mut app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload {
response: Some("Done".to_owned()),
..Default::default()
},
},
ctx,
);
});
});
terminal.read(&app, |_view, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.expect("child conversation should exist");
assert_eq!(conversation.status(), &ConversationStatus::Success);
});
})
}
#[test]
fn cli_session_status_updates_single_child_conversation_without_agent_view() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let child_conversation_id = terminal.update(&mut app, |view, ctx| {
let parent_conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_conversation(view.view_id, false, false, false, ctx)
});
let child_conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_child_conversation(
view.view_id,
"Agent 2".to_string(),
parent_conversation_id,
None,
ctx,
)
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
remote_host: None,
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
child_conversation_id
});
terminal.read(&app, |_view, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.expect("child conversation should exist");
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
});
terminal.update(&mut app, |view, ctx| {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload {
response: Some("Done".to_owned()),
..Default::default()
},
},
ctx,
);
});
});
terminal.read(&app, |_view, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&child_conversation_id)
.expect("child conversation should exist");
assert_eq!(conversation.status(), &ConversationStatus::Success);
});
})
}
#[test]
fn manual_dismiss_disables_auto_toggle_for_session() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
let listener = ctx.add_model(|ctx| {
CLIAgentSessionListener::new(
view.view_id,
CLIAgent::Claude,
&view.model_events_handle,
ctx,
)
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.set_session(
view.view_id,
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: true,
listener: Some(listener),
remote_host: None,
plugin_version: Some("1.0.0".to_owned()),
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
});
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
// Manual dismiss via the "disable auto-toggle" path (Escape / Ctrl-G / footer).
view.close_cli_agent_rich_input_and_disable_auto_toggle(ctx);
assert!(!view.has_active_cli_agent_input_session(ctx));
});
// should_auto_toggle_input should now be false.
terminal.read(&app, |view, ctx| {
let session = CLIAgentSessionsModel::as_ref(ctx).session(view.view_id);
assert!(!session.unwrap().should_auto_toggle_input);
});
// A status change to InProgress should NOT auto-open rich input.
terminal.update(&mut app, |view, ctx| {
// First move to Blocked so we can transition back.
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionRequest,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload {
summary: Some("Approve?".to_owned()),
..Default::default()
},
},
ctx,
);
});
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| {
sessions.update_from_event(
view.view_id,
&CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionReplied,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
},
ctx,
);
});
});
// Rich input should remain closed.
terminal.read(&app, |view, ctx| {
assert!(!view.has_active_cli_agent_input_session(ctx));
});
})
}
#[test]
fn close_cli_agent_rich_input_saves_draft_and_reopen_restores_it() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude);
// Type some text into the composer.
terminal.update(&mut app, |view, ctx| {
view.input.update(ctx, |input, ctx| {
input.replace_buffer_content("work in progress", ctx);
});
});
// Close the composer — the buffer text should be saved as a draft.
terminal.update(&mut app, |view, ctx| {
view.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Manual, ctx);
assert!(!view.has_active_cli_agent_input_session(ctx));
});
terminal.read(&app, |view, ctx| {
let session = CLIAgentSessionsModel::as_ref(ctx)
.session(view.view_id)
.expect("session should exist");
assert_eq!(
session.draft_text.as_deref(),
Some("work in progress"),
"draft should be saved on close"
);
});
// Reopen — draft should be restored into the buffer and consumed.
terminal.update(&mut app, |view, ctx| {
view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx);
assert!(view.has_active_cli_agent_input_session(ctx));
});
terminal.read(&app, |view, ctx| {
assert_eq!(
view.input.as_ref(ctx).buffer_text(ctx),
"work in progress",
"draft should be restored on reopen"
);
let session = CLIAgentSessionsModel::as_ref(ctx)
.session(view.view_id)
.expect("session should exist");
assert_eq!(
session.draft_text, None,
"draft should be consumed after restore"
);
});
})
}
#[test]
fn submit_cli_agent_rich_input_clears_draft() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
// Keep the input open after submit so we can inspect the buffer.
let _ = settings
.auto_dismiss_rich_input_after_submit
.set_value(false, ctx);
});
let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude);
terminal.update(&mut app, |view, ctx| {
view.submit_cli_agent_rich_input("hello agent".to_owned(), ctx);
// Input stays open because auto-dismiss is off.
assert!(view.has_active_cli_agent_input_session(ctx));
});
terminal.read(&app, |view, ctx| {
let session = CLIAgentSessionsModel::as_ref(ctx)
.session(view.view_id)
.expect("session should exist");
assert_eq!(
session.draft_text, None,
"draft should be cleared after submit"
);
assert!(
view.input.as_ref(ctx).buffer_text(ctx).is_empty(),
"buffer should be empty after submit"
);
});
})
}
#[test]
fn close_cli_agent_rich_input_with_empty_buffer_stores_no_draft() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true);
let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude);
// Close immediately without typing anything.
terminal.update(&mut app, |view, ctx| {
view.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Manual, ctx);
assert!(!view.has_active_cli_agent_input_session(ctx));
});
terminal.read(&app, |view, ctx| {
let session = CLIAgentSessionsModel::as_ref(ctx)
.session(view.view_id)
.expect("session should exist");
assert_eq!(
session.draft_text, None,
"no draft should be stored for empty buffer"
);
});
})
}
#[test]
fn ctrl_c_does_not_accept_prompt_suggestion_banner() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let block_id = terminal.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.simulate_block("ls", "output");
let last_completed_block_index = BlockIndex(model.block_list().blocks().len() - 2);
model
.block_list()
.block_at(last_completed_block_index)
.unwrap()
.id()
.clone()
});
terminal.update(&mut app, |view, ctx| {
view.on_legacy_prompt_suggestion_generated(
AgentModePromptSuggestion::Success(PromptSuggestion {
id: "suggestion".to_owned(),
label: Some("Do something".to_owned()),
prompt: "Do something".to_owned(),
coding_query_context: None,
static_prompt_suggestion_name: None,
should_start_new_conversation: false,
}),
block_id.clone(),
"ls".to_owned(),
0,
ctx,
);
assert!(view
.inline_banners_state
.prompt_suggestions_banner
.is_some());
// Ctrl-C should not accept the prompt suggestion.
view.handle_action(&TerminalAction::CtrlC, ctx);
assert!(view
.inline_banners_state
.prompt_suggestions_banner
.is_some());
});
})
}
/// Regression test for GH703: a Linear deeplink prompt must never be auto-submitted
/// to the LLM. Because `LinearDeepLink` returns `AutoTriggerBehavior::Never`, the
/// prompt must land in the input buffer as a draft and the "press enter again to
/// send" ephemeral message must be shown so the user can inspect and explicitly
/// send it.
#[test]
fn linear_deeplink_populates_input_as_draft_when_not_in_agent_view() {
use super::agent_view::ENTER_AGAIN_TO_SEND_MESSAGE_ID;
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.enter_agent_view_for_new_conversation(
Some("attacker prompt".to_owned()),
AgentViewEntryOrigin::LinearDeepLink,
ctx,
);
});
terminal.read(&app, |view, ctx| {
assert!(
view.agent_view_controller().as_ref(ctx).is_active(),
"Linear deeplink should enter agent view"
);
assert_eq!(
view.input.as_ref(ctx).buffer_text(ctx),
"attacker prompt",
"Linear deeplink prompt should be placed in the input buffer as a draft"
);
let ephemeral_message_id = view
.ephemeral_message_model
.as_ref(ctx)
.current_message()
.and_then(|msg| msg.id().map(|id| id.to_owned()));
assert_eq!(
ephemeral_message_id.as_deref(),
Some(ENTER_AGAIN_TO_SEND_MESSAGE_ID),
"the 'enter again to send' affordance should be shown"
);
});
})
}
/// The critical regression guard for GH703: even when the user is already in
/// fullscreen agent view, a Linear deeplink prompt must not be auto-submitted to
/// the LLM. `LinearDeepLink` returns `AutoTriggerBehavior::Never`, so even the
/// `was_in_agent_view_already` shortcut cannot promote it to auto-submit.
#[test]
fn linear_deeplink_does_not_auto_submit_when_already_in_agent_view() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
// First enter fullscreen agent view with no initial prompt. This matches the
// pre-condition in the issue: the focused terminal is already in fullscreen
// agent view when the `warp://linear/work?prompt=...` URI is dispatched.
let original_conversation_id = terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("Should be able to enter agent view")
})
});
terminal.read(&app, |view, ctx| {
assert!(view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.is_fullscreen());
});
// Now dispatch the Linear deeplink while already in fullscreen agent view.
terminal.update(&mut app, |view, ctx| {
view.enter_agent_view_for_new_conversation(
Some("attacker prompt".to_owned()),
AgentViewEntryOrigin::LinearDeepLink,
ctx,
);
});
terminal.read(&app, |view, ctx| {
// A new conversation should have been created for the Linear deeplink.
let new_conversation_id = view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
.expect("agent view should still be active after Linear deeplink entry");
assert_ne!(
new_conversation_id, original_conversation_id,
"Linear deeplink should open a new conversation"
);
// The prompt must be a draft, not auto-submitted.
assert_eq!(
view.input.as_ref(ctx).buffer_text(ctx),
"attacker prompt",
"Linear deeplink prompt must stay as a draft in the input buffer"
);
let ephemeral_message_id = view
.ephemeral_message_model
.as_ref(ctx)
.current_message()
.and_then(|msg| msg.id().map(|id| id.to_owned()));
assert_eq!(
ephemeral_message_id.as_deref(),
Some(ENTER_AGAIN_TO_SEND_MESSAGE_ID),
"the 'enter again to send' affordance must be shown so the user can \
consciously send the Linear-originated prompt"
);
});
})
}
/// `LinearDeepLink` returns `AutoTriggerBehavior::Never`, so it must not
/// auto-submit regardless of prior agent-view state.
#[test]
fn linear_deeplink_via_default_entrypoint_does_not_auto_submit_in_fullscreen() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("Should be able to enter agent view")
});
});
terminal.update(&mut app, |view, ctx| {
view.enter_agent_view_for_new_conversation(
Some("attacker prompt".to_owned()),
AgentViewEntryOrigin::LinearDeepLink,
ctx,
);
});
terminal.read(&app, |view, ctx| {
assert_eq!(
view.input.as_ref(ctx).buffer_text(ctx),
"attacker prompt",
"Linear deeplink prompt must not be auto-submitted"
);
let ephemeral_message_id = view
.ephemeral_message_model
.as_ref(ctx)
.current_message()
.and_then(|msg| msg.id().map(|id| id.to_owned()));
assert_eq!(
ephemeral_message_id.as_deref(),
Some(ENTER_AGAIN_TO_SEND_MESSAGE_ID),
);
});
})
}
/// Regression test for https://github.com/warpdotdev/warp/issues/11212.
///
/// Closing the find bar must immediately clear find highlights on AI blocks.
/// AI blocks are separate child views, so unless `close_find_bar` clears find
/// state they keep stale highlights until the pane is refocused.
#[test]
fn close_find_bar_clears_ai_block_find_highlights() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
// Create an AI block whose user query contains a searchable term.
terminal.update(&mut app, |view, ctx| {
append_exchange_and_handle_event(
view,
AIAgentInput::UserQuery {
query: "highlight the needle here".to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
},
ctx,
);
});
let ai_block = terminal.read(&app, |view, _| {
view.rich_content_views
.iter()
.find_map(|rich_content| {
rich_content
.ai_block_metadata()
.map(|metadata| metadata.ai_block_handle.clone())
})
.expect("an AI block should have been inserted")
});
// Open the find bar and run find for a term that matches the AI block.
// The blocklist find pipeline only visits rich-content views that have
// been laid out, which does not happen in this headless test, so also
// drive the AI block's `run_find` directly to put it in the
// highlighted state.
let find_options = || FindOptions {
query: Some("needle".to_owned().into()),
..Default::default()
};
terminal.update(&mut app, |view, ctx| {
view.show_find_bar(ctx);
view.run_find(find_options(), ctx);
});
ai_block.update(&mut app, |block, ctx| {
crate::terminal::find::FindableRichContentView::run_find(block, &find_options(), ctx);
});
assert_eq!(
ai_block.read(&app, |block, _| block.find_match_count()),
1,
"running find should highlight the match inside the AI block"
);
// Dismissing the find bar must clear the AI block's find highlights.
terminal.update(&mut app, |view, ctx| {
view.close_find_bar(ctx);
});
assert_eq!(
ai_block.read(&app, |block, _| block.find_match_count()),
0,
"closing the find bar should immediately clear AI block find highlights"
);
})
}
/// Regression test for the async-find branch of #11212.
///
/// Closing the find bar must clear stale AI block highlights without dropping
/// the saved query options on the async-find path. `open_find_bar` reads
/// `active_find_options` to restore the previous query; if `close_find_bar`
/// routes through `clear_matches → AsyncFindController::clear_results`, that
/// helper resets `current_find_options` and reopening the find bar starts
/// from a blank query instead of the previous one.
#[test]
fn close_find_bar_preserves_options_on_async_find_path() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _async_find = FeatureFlag::AsyncFind.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let needle_options = || FindOptions {
query: Some("needle".to_owned().into()),
..Default::default()
};
terminal.update(&mut app, |view, ctx| {
view.show_find_bar(ctx);
view.run_find(needle_options(), ctx);
});
// The async controller should have saved the active query.
assert_eq!(
terminal.read(&app, |view, ctx| view
.find_model
.as_ref(ctx)
.active_find_options()
.map(|o| o.query.clone())),
Some(needle_options().query),
"running find on the async path must save the active query"
);
// Closing the find bar must NOT drop the saved query — otherwise
// the next `open_find_bar` would start blank instead of restoring
// the previous search.
terminal.update(&mut app, |view, ctx| {
view.close_find_bar(ctx);
});
assert_eq!(
terminal.read(&app, |view, ctx| view
.find_model
.as_ref(ctx)
.active_find_options()
.map(|o| o.query.clone())),
Some(needle_options().query),
"closing the find bar must preserve the saved query on the async path"
);
})
}
/// Regression test: selecting text inside an AI (rich content) block and copying
/// must place the selected text on the clipboard.
///
/// AI blocks own their text selection independently of the point-based model
/// selection, so the model must be told (via `AIBlockEvent::SelectionChanged`)
/// which rich content block has an active selection. Otherwise
/// `selection_to_string` returns nothing and the copy paths produce an empty
/// clipboard (the regression introduced by the `mouse_down` `if !handled` guard
/// in #12079).
#[test]
fn copy_selected_text_from_ai_block() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
// Insert an AI block with a user query.
terminal.update(&mut app, |view, ctx| {
append_exchange_and_handle_event(
view,
AIAgentInput::UserQuery {
query: "the quick brown fox".to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
},
ctx,
);
});
let ai_block = terminal.read(&app, |view, _| {
view.rich_content_views
.iter()
.find_map(|rich_content| {
rich_content
.ai_block_metadata()
.map(|metadata| metadata.ai_block_handle.clone())
})
.expect("an AI block should have been inserted")
});
// Simulate a block-level text selection within the AI block and notify the
// terminal view (mirrors the `SelectableArea` selection callback plus the
// `AIBlockAction::SelectText` dispatch that happens on a real drag).
ai_block.update(&mut app, |block, ctx| {
block.set_block_level_selected_text_for_test(Some("quick brown".to_owned()));
block.handle_action(&AIBlockAction::SelectText, ctx);
});
// The model must now record that the AI block has an active text
// selection, which is what lets the copy/insert paths (via
// `selection_to_string`) find the selected text. This is the part the
// #12079 regression broke. We assert on the model record rather than the
// clipboard string because reading the selected text cross-view requires
// an active window, which the headless test harness does not provide (the
// end-to-end clipboard behavior is covered by manual/computer-use
// verification).
terminal.read(&app, |view, ctx| {
let semantic_selection = SemanticSelection::as_ref(ctx);
let model = view.model.lock();
assert!(
model
.block_list()
.has_renderable_selection(semantic_selection, false),
"the model must record the AI block's text selection so copy/insert can find it"
);
});
// Clearing the AI block's selection must clear the tracked model selection
// so stale text isn't returned by later copy/insert operations.
ai_block.update(&mut app, |block, ctx| {
block.set_block_level_selected_text_for_test(None);
block.handle_action(&AIBlockAction::SelectText, ctx);
});
terminal.read(&app, |view, ctx| {
let semantic_selection = SemanticSelection::as_ref(ctx);
let model = view.model.lock();
assert!(
!model
.block_list()
.has_renderable_selection(semantic_selection, false),
"clearing the AI block selection should clear the model's recorded selection"
);
});
})
}
#[test]
fn cmd_k_does_not_clear_buffer_when_agent_is_driving_command() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
bootstrap_with_long_running_block(view);
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.start_new_conversation(view.view_id, false, false, false, ctx)
});
set_active_block_agent_driving(view, conversation_id);
assert!(view
.model
.lock()
.block_list()
.active_block()
.is_agent_driving_command());
assert!(!view
.model
.lock()
.block_list()
.active_block()
.is_agent_monitoring());
let block_count_before = view.model.lock().block_list().blocks().len();
view.clear_buffer_for_testing(ctx);
assert_eq!(
view.model.lock().block_list().blocks().len(),
block_count_before,
"cmd-k must not wipe blocks while the agent is driving a command"
);
});
})
}
#[test]
fn cmd_k_in_agent_view_clears_active_block_not_full_buffer_when_agent_driving_command() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let conversation_id = terminal.update(&mut app, |view, ctx| {
let conversation_id = view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("should enter agent view")
});
bootstrap_with_long_running_block(view);
set_active_block_agent_driving(view, conversation_id);
assert!(view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.is_fullscreen());
assert!(view
.model
.lock()
.block_list()
.active_block()
.is_agent_driving_command());
conversation_id
});
let block_count_before = terminal.read(&app, |view, _| {
view.model.lock().block_list().blocks().len()
});
terminal.update(&mut app, |view, ctx| {
view.clear_buffer_for_testing(ctx);
});
terminal.read(&app, |view, ctx| {
// Same conversation still active: no new conversation was started.
assert_eq!(
view.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id(),
Some(conversation_id),
"cmd-k must not start a new conversation while agent is driving a command"
);
// Block count unchanged: only the active block output was cleared.
assert_eq!(
view.model.lock().block_list().blocks().len(),
block_count_before,
"cmd-k must not remove blocks while agent is driving a command"
);
});
})
}
#[test]
fn cmd_k_in_agent_view_cancels_in_progress_conversation_and_starts_new_one() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
FeatureFlag::AgentView.set_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);
let old_conversation_id = terminal.update(&mut app, |view, ctx| {
view.agent_view_controller().update(ctx, |controller, ctx| {
controller
.try_enter_agent_view(
None,
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
)
.expect("should enter agent view")
})
});
// New conversations always start InProgress.
terminal.read(&app, |_, ctx| {
assert_eq!(
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&old_conversation_id)
.map(|c| c.status().clone()),
Some(ConversationStatus::InProgress)
);
});
// Attach a mock in-flight response stream so cancel_conversation_progress
// can actually cancel it and flip the status to Cancelled.
let stream_id = ResponseStreamId::new_for_test();
terminal.update(&mut app, |view, ctx| {
// Associate the stream_id with the conversation in the history model so
// is_processing_response_stream returns true when the controller looks it up.
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let exchange = exchange_with_inputs(vec![]);
history
.conversation_mut(&old_conversation_id)
.expect("conversation should exist")
.append_reassigned_exchange(&stream_id, exchange, view.view_id, ctx)
.expect("exchange should append");
});
let stream = ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
view.ai_controller.update(ctx, |controller, ctx| {
controller.register_mock_stream_for_test(
stream_id.clone(),
old_conversation_id,
stream,
ctx,
);
});
});
// Cmd+K with no long-running command: cancels the old in-progress conversation
// (stream is cancelled → AfterStreamFinished → Cancelled status) and starts a new one.
terminal.update(&mut app, |view, ctx| {
view.clear_buffer_for_testing(ctx);
});
terminal.read(&app, |view, ctx| {
// A new conversation must now be active.
let new_conversation_id = view
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
.expect("agent view should still be active after cmd-k");
assert_ne!(
new_conversation_id, old_conversation_id,
"cmd-k must start a new conversation when an in-progress one is active"
);
// The old conversation must be Cancelled — the stream was actually cancelled.
assert_eq!(
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&old_conversation_id)
.map(|c| c.status().clone()),
Some(ConversationStatus::Cancelled),
"the old in-progress conversation must be Cancelled after cmd-k"
);
});
})
}