use crate::tui_internal::key_hint;
use crate::tui_internal::key_hint::KeyBinding;
use crate::tui_internal::key_hint::has_ctrl_or_alt;
use crate::tui_internal::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::tui_internal::ui_consts::FOOTER_INDENT_COLS;
use crate::message_history::HistoryBatchCursor;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef;
use super::chat_composer_history::ChatComposerHistory;
use super::chat_composer_history::HistoryEntry;
use super::chat_composer_history::HistoryEntryResponse;
use super::chat_composer_history::HistorySearchResult;
use super::command_popup::CommandItem;
use super::file_search_popup::FileSearchPopup;
use super::footer::CollaborationModeIndicator;
use super::footer::FooterKeyHints;
use super::footer::FooterMode;
use super::footer::FooterProps;
use super::footer::GoalStatusIndicator;
use super::footer::SummaryLeft;
use super::footer::can_show_left_with_context;
use super::footer::context_window_line;
use super::footer::esc_hint_mode;
use super::footer::footer_height;
use super::footer::footer_hint_items_width;
use super::footer::footer_line_width;
use super::footer::inset_footer_hint_area;
use super::footer::max_left_width_for_right;
use super::footer::passive_footer_status_line;
use super::footer::render_context_right;
use super::footer::render_footer_from_props;
use super::footer::render_footer_hint_items;
use super::footer::render_footer_line;
use super::footer::reset_mode_after_activity;
use super::footer::side_conversation_context_line;
use super::footer::single_line_footer_layout;
use super::footer::status_line_right_indicator_line;
use super::footer::toggle_shortcut_mode;
use super::footer::uses_passive_footer_status_layout;
use super::mentions_v2::MentionV2Popup;
use super::mentions_v2::MentionV2Selection;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use super::prompt_args::parse_slash_name;
use super::skill_popup::MentionItem;
use super::skill_popup::SkillPopup;
use super::slash_commands::BuiltinCommandFlags;
use super::slash_commands::ServiceTierCommand;
use super::slash_commands::SlashCommandItem;
use crate::tui_internal::bottom_pane::paste_burst::FlushResult;
use crate::tui_internal::history_cell::sanitize_user_text;
use crate::tui_internal::key_hint::KeyBindingListExt;
use crate::tui_internal::keymap::EditorKeymap;
use crate::tui_internal::keymap::RuntimeKeymap;
use crate::tui_internal::keymap::VimNormalKeymap;
use crate::tui_internal::keymap::primary_binding;
use crate::tui_internal::onboarding::mark_underlined_hyperlink;
use crate::tui_internal::render::Insets;
use crate::tui_internal::render::RectExt;
use crate::tui_internal::render::renderable::Renderable;
use crate::tui_internal::slash_command::SlashCommand;
use crate::tui_internal::style::user_message_style;
use lemurclaw_core::protocol::ThreadId;
use lemurclaw_core::protocol::user_input::ByteRange;
use lemurclaw_core::protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use lemurclaw_core::protocol::user_input::TextElement;
mod attachment_state;
mod completion_target;
mod draft_state;
mod footer_state;
mod history_search;
mod popup_state;
mod slash_input;
use self::attachment_state::AttachmentState;
use self::draft_state::ComposerMentionBinding;
use self::draft_state::DraftState;
use self::footer_state::FooterState;
use self::history_search::HistorySearchSession;
use self::popup_state::ActivePopup;
use self::popup_state::DismissedToken;
use self::popup_state::PopupState;
use self::slash_input::SlashInput;
use self::slash_input::SlashValidation;
use self::slash_input::SubmissionValidation;
use crate::tui_internal::app_event::AppEvent;
use crate::tui_internal::app_event::ConnectorsSnapshot;
use crate::tui_internal::app_event_sender::AppEventSender;
use crate::tui_internal::bottom_pane::LocalImageAttachment;
use crate::tui_internal::bottom_pane::MentionBinding;
use crate::tui_internal::bottom_pane::textarea::TextArea;
use crate::tui_internal::clipboard_paste::normalize_pasted_path;
use crate::tui_internal::clipboard_paste::pasted_image_format;
use crate::tui_internal::history_cell;
use crate::tui_internal::skills_helpers::skill_display_name;
use crate::tui_internal::tui::FrameRequester;
use crate::tui_internal::ui_consts::LIVE_PREFIX_COLS;
use lemurclaw_core::connectors::AppInfo;
#[cfg(test)]
use lemurclaw_core::core_skills::model::SkillInterface;
use lemurclaw_core::core_skills::model::SkillMetadata;
use lemurclaw_core::file_search::FileMatch;
#[cfg(test)]
use lemurclaw_core::plugin::AppConnectorId;
use lemurclaw_core::plugin::PluginCapabilitySummary;
use std::cell::OnceCell;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::ops::Range;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use ratatui::style::Color;
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
fn user_input_too_large_message(actual_chars: usize) -> String {
format!(
"Message exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters ({actual_chars} provided)."
)
}
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted {
text: String,
text_elements: Vec<TextElement>,
},
Queued {
text: String,
text_elements: Vec<TextElement>,
action: QueuedInputAction,
pending_pastes: Vec<(String, String)>,
},
Command(SlashCommand),
ServiceTierCommand(ServiceTierCommand),
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
ParentOwnedInputBlocked,
None,
}
fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool {
args.is_empty()
&& matches!(
command,
SlashCommand::Feedback
| SlashCommand::New
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::App
| SlashCommand::Side
| SlashCommand::Btw
| SlashCommand::Agent
| SlashCommand::MultiAgents
| SlashCommand::Vim
| SlashCommand::Keymap
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Logout
| SlashCommand::Copy
| SlashCommand::Raw
| SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Import
| SlashCommand::Hooks
| SlashCommand::Status
| SlashCommand::Usage
| SlashCommand::Ide
| SlashCommand::DebugConfig
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::Theme
| SlashCommand::Pets
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Rollout
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueuedInputAction {
Plain,
ParseSlash,
RunShell,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PendingPasteHandling {
Expand,
Preserve,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct ChatComposerConfig {
pub(crate) popups_enabled: bool,
pub(crate) slash_commands_enabled: bool,
pub(crate) image_paste_enabled: bool,
}
impl Default for ChatComposerConfig {
fn default() -> Self {
Self {
popups_enabled: true,
slash_commands_enabled: true,
image_paste_enabled: true,
}
}
}
impl ChatComposerConfig {
pub(crate) const fn plain_text() -> Self {
Self {
popups_enabled: false,
slash_commands_enabled: false,
image_paste_enabled: false,
}
}
}
pub(crate) struct ChatComposer {
draft: DraftState,
popups: PopupState,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
footer: FooterState,
has_focus: bool,
frame_requester: Option<FrameRequester>,
attachments: AttachmentState,
placeholder_text: String,
blocks_direct_input: bool,
is_task_running: bool,
queue_submissions: bool,
pending_slash_command_history: Option<HistoryEntry>,
skills: Option<Vec<SkillMetadata>>,
plugins: Option<Vec<PluginCapabilitySummary>>,
connectors_snapshot: Option<ConnectorsSnapshot>,
collaboration_modes_enabled: bool,
config: ChatComposerConfig,
connectors_enabled: bool,
plugins_command_enabled: bool,
token_activity_command_enabled: bool,
service_tier_commands_enabled: bool,
service_tier_commands: Vec<ServiceTierCommand>,
mentions_v2_enabled: bool,
goal_command_enabled: bool,
personality_command_enabled: bool,
windows_degraded_sandbox_active: bool,
side_conversation_active: bool,
history_search: Option<HistorySearchSession>,
submit_keys: Vec<KeyBinding>,
queue_keys: Vec<KeyBinding>,
toggle_shortcuts_keys: Vec<KeyBinding>,
history_search_previous_keys: Vec<KeyBinding>,
history_search_next_keys: Vec<KeyBinding>,
editor_keymap: EditorKeymap,
vim_normal_keymap: VimNormalKeymap,
}
struct MentionCompletionTarget {
range: Range<usize>,
query: String,
prebuilt_mentions: Option<Vec<MentionItem>>,
}
#[derive(Clone, Debug)]
struct ComposerDraft {
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>,
mention_bindings: Vec<MentionBinding>,
pending_pastes: Vec<(String, String)>,
cursor: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ComposerDraftSnapshot {
pub(crate) text: String,
pub(crate) text_elements: Vec<TextElement>,
pub(crate) local_images: Vec<LocalImageAttachment>,
pub(crate) remote_image_urls: Vec<String>,
pub(crate) mention_bindings: Vec<MentionBinding>,
pub(crate) pending_pastes: Vec<(String, String)>,
}
const FOOTER_SPACING_HEIGHT: u16 = 0;
fn plan_mode_nudge_line() -> Line<'static> {
Line::from(vec![
"Create a plan?".magenta(),
" ".into(),
key_hint::shift(KeyCode::Tab).into(),
" use Plan mode".into(),
" ".into(),
key_hint::plain(KeyCode::Esc).into(),
" dismiss".into(),
])
}
impl ChatComposer {
fn slash_input(&self) -> SlashInput<'_> {
SlashInput::new(
self.slash_commands_enabled(),
self.draft.is_bash_mode,
self.builtin_command_flags(),
&self.service_tier_commands,
)
}
fn builtin_command_flags(&self) -> BuiltinCommandFlags {
BuiltinCommandFlags {
collaboration_modes_enabled: self.collaboration_modes_enabled,
connectors_enabled: self.connectors_enabled,
plugins_command_enabled: self.plugins_command_enabled,
token_activity_command_enabled: self.token_activity_command_enabled,
service_tier_commands_enabled: self.service_tier_commands_enabled,
goal_command_enabled: self.goal_command_enabled,
personality_command_enabled: self.personality_command_enabled,
allow_elevate_sandbox: self.windows_degraded_sandbox_active,
side_conversation_active: self.side_conversation_active,
}
}
pub fn new(
has_input_focus: bool,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
placeholder_text: String,
disable_paste_burst: bool,
) -> Self {
Self::new_with_config(
has_input_focus,
app_event_tx,
enhanced_keys_supported,
placeholder_text,
disable_paste_burst,
ChatComposerConfig::default(),
)
}
pub(crate) fn new_with_config(
has_input_focus: bool,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
placeholder_text: String,
disable_paste_burst: bool,
config: ChatComposerConfig,
) -> Self {
let use_shift_enter_hint = enhanced_keys_supported;
let default_keymap = RuntimeKeymap::defaults();
let default_editor_keymap = default_keymap.editor.clone();
let default_vim_normal_keymap = default_keymap.vim_normal.clone();
let mut this = Self {
draft: DraftState::new(),
popups: PopupState::default(),
app_event_tx,
history: ChatComposerHistory::new(),
footer: FooterState {
quit_shortcut_expires_at: None,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
esc_backtrack_hint: false,
use_shift_enter_hint,
mode: FooterMode::ComposerEmpty,
hint_override: None,
plan_mode_nudge_visible: false,
flash: None,
context_window_percent: None,
context_window_used_tokens: None,
collaboration_mode_indicator: None,
goal_status_indicator: None,
ide_context_active: false,
status_line_value: None,
status_line_hyperlink_url: None,
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
insert_newline_key: footer_insert_newline_key(
&default_keymap.editor.insert_newline,
use_shift_enter_hint,
),
queue_key: Some(key_hint::plain(KeyCode::Tab)),
toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))),
history_search_key: primary_binding(
&default_keymap.composer.history_search_previous,
),
reasoning_down_key: primary_binding(&default_keymap.chat.decrease_reasoning_effort),
reasoning_up_key: primary_binding(&default_keymap.chat.increase_reasoning_effort),
},
has_focus: has_input_focus,
frame_requester: None,
attachments: AttachmentState::default(),
placeholder_text,
blocks_direct_input: false,
is_task_running: false,
queue_submissions: false,
pending_slash_command_history: None,
skills: None,
plugins: None,
connectors_snapshot: None,
collaboration_modes_enabled: false,
config,
connectors_enabled: false,
plugins_command_enabled: false,
token_activity_command_enabled: false,
service_tier_commands_enabled: false,
service_tier_commands: Vec::new(),
mentions_v2_enabled: false,
goal_command_enabled: false,
personality_command_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
history_search: None,
submit_keys: vec![key_hint::plain(KeyCode::Enter)],
queue_keys: vec![key_hint::plain(KeyCode::Tab)],
toggle_shortcuts_keys: vec![
key_hint::plain(KeyCode::Char('?')),
key_hint::shift(KeyCode::Char('?')),
],
history_search_previous_keys: default_keymap.composer.history_search_previous.clone(),
history_search_next_keys: default_keymap.composer.history_search_next.clone(),
editor_keymap: default_editor_keymap,
vim_normal_keymap: default_vim_normal_keymap,
};
this.set_disable_paste_burst(disable_paste_burst);
this
}
pub(crate) fn set_frame_requester(&mut self, frame_requester: FrameRequester) {
self.frame_requester = Some(frame_requester);
}
pub fn set_skill_mentions(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.skills = skills;
self.sync_popups();
}
pub fn set_plugin_mentions(&mut self, plugins: Option<Vec<PluginCapabilitySummary>>) {
self.plugins = plugins;
self.sync_popups();
}
pub fn set_plugins_command_enabled(&mut self, enabled: bool) {
self.plugins_command_enabled = enabled;
}
pub fn set_token_activity_command_enabled(&mut self, enabled: bool) {
self.token_activity_command_enabled = enabled;
}
pub fn set_mentions_v2_enabled(&mut self, enabled: bool) {
self.mentions_v2_enabled = enabled;
self.history.set_at_mention_restore_enabled(enabled);
self.sync_popups();
}
pub fn set_image_paste_enabled(&mut self, enabled: bool) {
self.config.image_paste_enabled = enabled;
}
pub fn set_connector_mentions(&mut self, connectors_snapshot: Option<ConnectorsSnapshot>) {
self.connectors_snapshot = connectors_snapshot;
self.sync_popups();
}
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
let elements = self.current_mention_elements();
let mut ordered = Vec::new();
for (id, sigil, mention) in elements {
if let Some(binding) = self.draft.mention_bindings.remove(&id)
&& binding.sigil == sigil
&& binding.mention == mention
{
ordered.push(MentionBinding {
sigil: binding.sigil,
mention: binding.mention,
path: binding.path,
});
}
}
self.draft.mention_bindings.clear();
ordered
}
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
self.collaboration_modes_enabled = enabled;
}
pub fn set_connectors_enabled(&mut self, enabled: bool) {
self.connectors_enabled = enabled;
}
pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) {
self.service_tier_commands_enabled = enabled;
}
pub fn set_service_tier_commands(&mut self, commands: Vec<ServiceTierCommand>) {
self.service_tier_commands = commands;
self.sync_popups();
}
pub fn set_goal_command_enabled(&mut self, enabled: bool) {
self.goal_command_enabled = enabled;
}
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.submit_keys = keymap.composer.submit.clone();
self.queue_keys = keymap.composer.queue.clone();
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
self.history_search_previous_keys = keymap.composer.history_search_previous.clone();
self.history_search_next_keys = keymap.composer.history_search_next.clone();
self.editor_keymap = keymap.editor.clone();
self.vim_normal_keymap = keymap.vim_normal.clone();
self.draft.textarea.set_keymap_bindings(keymap);
self.footer.external_editor_key = primary_binding(&keymap.app.open_external_editor);
self.footer.show_transcript_key = primary_binding(&keymap.app.open_transcript);
self.footer.insert_newline_key = footer_insert_newline_key(
&keymap.editor.insert_newline,
self.footer.use_shift_enter_hint,
);
self.footer.queue_key = primary_binding(&keymap.composer.queue);
self.footer.toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts);
self.footer.history_search_key = primary_binding(&keymap.composer.history_search_previous);
self.footer.reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort);
self.footer.reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort);
}
pub fn set_collaboration_mode_indicator(
&mut self,
indicator: Option<CollaborationModeIndicator>,
) {
self.footer.collaboration_mode_indicator = indicator;
}
pub fn set_goal_status_indicator(&mut self, indicator: Option<GoalStatusIndicator>) {
self.footer.goal_status_indicator = indicator;
}
pub fn set_ide_context_active(&mut self, active: bool) {
self.footer.ide_context_active = active;
}
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
self.personality_command_enabled = enabled;
}
pub fn set_side_conversation_active(&mut self, active: bool) {
self.side_conversation_active = active;
}
#[cfg(test)]
pub fn set_steer_enabled(&mut self, _enabled: bool) {}
fn popups_enabled(&self) -> bool {
self.config.popups_enabled
}
fn slash_commands_enabled(&self) -> bool {
self.config.slash_commands_enabled
}
fn image_paste_enabled(&self) -> bool {
self.config.image_paste_enabled
}
#[cfg(target_os = "windows")]
pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) {
self.windows_degraded_sandbox_active = enabled;
}
fn layout_areas(&self, area: Rect) -> [Rect; 4] {
self.layout_areas_with_textarea_right_reserve(area, 0)
}
fn layout_areas_with_textarea_right_reserve(
&self,
area: Rect,
textarea_right_reserve: u16,
) -> [Rect; 4] {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(&footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
let popup_constraint = match &self.popups.active {
ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::Skill(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::MentionV2(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::None => Constraint::Max(footer_total_height),
};
let [composer_rect, popup_rect] =
Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area);
let mut textarea_rect = composer_rect.inset(Insets::tlbr(
1,
LIVE_PREFIX_COLS,
1,
1u16.saturating_add(textarea_right_reserve),
));
let remote_images_height = self
.attachments
.remote_image_lines()
.len()
.try_into()
.unwrap_or(u16::MAX)
.min(textarea_rect.height.saturating_sub(1));
let remote_images_separator = u16::from(remote_images_height > 0);
let consumed = remote_images_height.saturating_add(remote_images_separator);
let remote_images_rect = Rect {
x: textarea_rect.x,
y: textarea_rect.y,
width: textarea_rect.width,
height: remote_images_height,
};
textarea_rect.y = textarea_rect.y.saturating_add(consumed);
textarea_rect.height = textarea_rect.height.saturating_sub(consumed);
[composer_rect, remote_images_rect, textarea_rect, popup_rect]
}
fn footer_spacing(footer_hint_height: u16) -> u16 {
if footer_hint_height == 0 {
0
} else {
FOOTER_SPACING_HEIGHT
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_textarea_right_reserve(area, 0)
}
pub(crate) fn cursor_pos_with_textarea_right_reserve(
&self,
area: Rect,
textarea_right_reserve: u16,
) -> Option<(u16, u16)> {
if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() {
return None;
}
if let Some(pos) = self.history_search_cursor_pos(area) {
return Some(pos);
}
let [_, _, textarea_rect, _] =
self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve);
let state = *self.draft.textarea_state.borrow();
self.draft
.textarea
.cursor_pos_with_state(textarea_rect, state)
}
pub(crate) fn is_empty(&self) -> bool {
self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty()
}
pub(crate) fn set_history_metadata(
&mut self,
thread_id: ThreadId,
log_id: u64,
entry_count: usize,
) {
self.history.set_metadata(thread_id, log_id, entry_count);
}
pub(crate) fn on_history_entry_response(
&mut self,
log_id: u64,
offset: usize,
entry: Option<String>,
) -> bool {
match self
.history
.on_entry_response(log_id, offset, entry, &self.app_event_tx)
{
HistoryEntryResponse::Found(entry) => {
self.apply_history_entry(entry);
true
}
HistoryEntryResponse::Search(result) => {
self.apply_history_search_result(result);
true
}
HistoryEntryResponse::Ignored => false,
}
}
pub(crate) fn on_history_batch_response(
&mut self,
log_id: u64,
cursor: HistoryBatchCursor,
entries: Vec<crate::tui_internal::app_event::HistoryBatchEntryResponse>,
next_older_cursor: Option<HistoryBatchCursor>,
) -> bool {
let result = self.history.on_batch_response(
log_id,
cursor,
entries,
next_older_cursor,
&self.app_event_tx,
);
self.apply_history_batch_result(result)
}
pub(crate) fn on_history_batch_error(
&mut self,
log_id: u64,
cursor: HistoryBatchCursor,
) -> bool {
let result = self
.history
.on_batch_error(log_id, cursor, &self.app_event_tx);
self.apply_history_batch_result(result)
}
fn apply_history_batch_result(&mut self, result: Option<HistorySearchResult>) -> bool {
let Some(result) = result else {
return false;
};
self.apply_history_search_result(result);
true
}
pub(crate) fn record_replayed_user_message_history(&mut self, entry: HistoryEntry) {
self.history.record_replayed_submission(entry);
}
pub fn handle_paste(&mut self, pasted: String) -> bool {
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
let pasted = sanitize_user_text(&pasted);
let char_count = pasted.chars().count();
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
let placeholder = self.next_large_paste_placeholder(char_count);
self.draft.textarea.insert_element(&placeholder);
self.draft.pending_pastes.push((placeholder, pasted));
} else if char_count > 1
&& self.image_paste_enabled()
&& self.handle_paste_image_path(pasted.clone())
{
self.draft.textarea.insert_str(" ");
} else {
self.insert_str(&pasted);
}
self.draft.paste_burst.clear_after_explicit_paste();
self.sync_popups();
true
}
pub fn handle_paste_image_path(&mut self, pasted: String) -> bool {
let Some(path_buf) = normalize_pasted_path(&pasted) else {
return false;
};
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::info!("OK: {pasted}");
tracing::debug!("image dimensions={}x{}", width, height);
let format = pasted_image_format(&path_buf);
tracing::debug!("attached image format={}", format.label());
self.attach_image(path_buf);
true
}
Err(err) => {
tracing::trace!("ERR: {err}");
false
}
}
}
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
let was_disabled = self.draft.disable_paste_burst;
self.draft.disable_paste_burst = disabled;
if disabled && !was_disabled {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.draft.paste_burst.clear_after_explicit_paste();
}
}
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.draft.pending_pastes.clear();
let (text, _) = self.imported_text_for_textarea(text, Vec::new());
let mut placeholder_counts: HashMap<String, usize> = HashMap::new();
for placeholder in self
.attachments
.local_images
.iter()
.map(|image| &image.placeholder)
{
if placeholder_counts.contains_key(placeholder) {
continue;
}
let count = text.match_indices(placeholder).count();
if count > 0 {
placeholder_counts.insert(placeholder.clone(), count);
}
}
let mut kept_images = Vec::new();
for img in self.attachments.local_images.drain(..) {
if let Some(count) = placeholder_counts.get_mut(&img.placeholder)
&& *count > 0
{
*count -= 1;
kept_images.push(img);
}
}
self.attachments.local_images = kept_images;
self.draft.textarea.set_text_clearing_elements("");
let mut remaining: HashMap<&str, usize> = HashMap::new();
for img in &self.attachments.local_images {
*remaining.entry(img.placeholder.as_str()).or_insert(0) += 1;
}
let mut occurrences: Vec<(usize, &str)> = Vec::new();
for placeholder in remaining.keys() {
for (pos, _) in text.match_indices(placeholder) {
occurrences.push((pos, *placeholder));
}
}
occurrences.sort_unstable_by_key(|(pos, _)| *pos);
let mut idx = 0usize;
for (pos, ph) in occurrences {
let Some(count) = remaining.get_mut(ph) else {
continue;
};
if *count == 0 {
continue;
}
if pos > idx {
self.draft.textarea.insert_str(&text[idx..pos]);
}
self.draft.textarea.insert_element(ph);
*count -= 1;
idx = pos + ph.len();
}
if idx < text.len() {
self.draft.textarea.insert_str(&text[idx..]);
}
self.attachments
.relabel_local_images(&mut self.draft.textarea);
self.draft
.textarea
.set_cursor(self.draft.textarea.text().len());
self.sync_popups();
}
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.draft.textarea.set_vim_enabled(enabled);
self.draft.paste_burst.clear_after_explicit_paste();
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
pub(crate) fn toggle_vim_enabled(&mut self) -> bool {
let enabled = !self.draft.textarea.is_vim_enabled();
self.set_vim_enabled(enabled);
enabled
}
#[cfg(test)]
pub(crate) fn is_vim_enabled(&self) -> bool {
self.draft.textarea.is_vim_enabled()
}
pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool {
self.draft
.textarea
.should_handle_vim_insert_escape(key_event)
}
fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
self.draft
.textarea
.vim_mode_label()
.map(|label| match label {
"Normal" => "Vim: Normal".magenta(),
"Insert" => "Vim: Insert".green(),
_ => unreachable!(),
})
}
fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option<Line<'static>> {
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(vim_mode) = self.vim_mode_indicator_span() {
spans.push(vim_mode);
}
if let Some(indicators) = status_line_right_indicator_line(
self.footer.collaboration_mode_indicator,
self.footer.goal_status_indicator.as_ref(),
self.footer.ide_context_active,
show_cycle_hint,
) {
if !spans.is_empty() {
spans.push(" | ".dim());
}
spans.extend(indicators.spans);
}
if spans.is_empty() {
None
} else {
Some(Line::from(spans))
}
}
fn right_footer_line_with_context(&self) -> Line<'static> {
let mut line = context_window_line(
self.footer.context_window_percent,
self.footer.context_window_used_tokens,
);
if let Some(vim_mode) = self.vim_mode_indicator_span() {
line.spans.push(" | ".dim());
line.spans.push(vim_mode);
}
line
}
pub(crate) fn current_text_with_pending(&self) -> String {
let text = self.current_text();
if self.draft.pending_pastes.is_empty() {
return text;
}
let (text, _) = Self::expand_pending_pastes(
&text,
self.current_text_elements(),
&self.draft.pending_pastes,
);
text
}
pub(crate) fn input_enabled(&self) -> bool {
self.draft.input_enabled
}
pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> {
self.draft.pending_pastes.clone()
}
pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) {
let text = self.current_text();
self.draft.pending_pastes = pending_pastes
.into_iter()
.filter(|(placeholder, _)| text.contains(placeholder))
.collect();
}
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
self.footer.hint_override = items;
}
pub(crate) fn set_plan_mode_nudge_visible(&mut self, visible: bool) -> bool {
if self.footer.plan_mode_nudge_visible == visible {
return false;
}
self.footer.plan_mode_nudge_visible = visible;
true
}
#[cfg(test)]
pub(crate) fn plan_mode_nudge_visible(&self) -> bool {
self.footer.plan_mode_nudge_visible
}
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
self.attachments
.set_remote_image_urls(urls, &mut self.draft.textarea);
self.sync_popups();
}
pub(crate) fn remote_image_urls(&self) -> Vec<String> {
self.attachments.remote_image_urls()
}
pub(crate) fn take_remote_image_urls(&mut self) -> Vec<String> {
let urls = self
.attachments
.take_remote_image_urls(&mut self.draft.textarea);
self.sync_popups();
urls
}
#[cfg(test)]
pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) {
self.footer.show_flash(line, duration);
}
pub(crate) fn set_text_content(
&mut self,
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
) {
self.set_text_content_with_mention_bindings(
text,
text_elements,
local_image_paths,
Vec::new(),
);
}
pub(crate) fn set_text_content_with_mention_bindings(
&mut self,
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
mention_bindings: Vec<MentionBinding>,
) {
self.draft.textarea.set_text_clearing_elements("");
self.draft.is_bash_mode = false;
self.draft.pending_pastes.clear();
self.draft.mention_bindings.clear();
let (text, text_elements) = self.imported_text_for_textarea(text, text_elements);
self.draft
.textarea
.set_text_with_elements(&text, &text_elements);
self.attachments
.reset_local_images(local_image_paths, &mut self.draft.textarea);
self.bind_mentions_from_snapshot(mention_bindings);
self.draft.textarea.set_cursor( 0);
self.sync_popups();
}
fn current_cursor(&self) -> usize {
self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 }
}
#[cfg(test)]
pub(crate) fn cursor(&self) -> usize {
self.current_cursor()
}
fn history_navigation_cursor(&self) -> usize {
if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 {
0
} else if self.draft.textarea.is_vim_normal_mode()
&& !self.draft.textarea.text().is_empty()
&& self.draft.textarea.cursor() == self.draft.textarea.vim_normal_end_cursor()
{
self.current_text().len()
} else {
self.current_cursor()
}
}
fn set_current_cursor(&mut self, cursor: usize) {
let visible_cursor = if self.draft.is_bash_mode {
cursor.saturating_sub(1)
} else {
cursor
};
self.draft
.textarea
.set_cursor(visible_cursor.min(self.draft.textarea.text().len()));
}
fn current_text_elements(&self) -> Vec<TextElement> {
let shift = if self.draft.is_bash_mode { 1 } else { 0 };
self.draft
.textarea
.text_elements()
.into_iter()
.filter_map(|element| Self::shift_text_element(element, shift))
.collect()
}
fn shift_text_element(element: TextElement, shift: isize) -> Option<TextElement> {
let start = element.byte_range.start.checked_add_signed(shift)?;
let end = element.byte_range.end.checked_add_signed(shift)?;
if start >= end {
return None;
}
Some(element.map_range(|_| (start..end).into()))
}
fn snapshot_draft(&self) -> ComposerDraft {
ComposerDraft {
text: self.current_text(),
text_elements: self.current_text_elements(),
local_image_paths: self.attachments.local_image_paths(),
remote_image_urls: self.attachments.remote_image_urls(),
mention_bindings: self.snapshot_mention_bindings(),
pending_pastes: self.draft.pending_pastes.clone(),
cursor: self.current_cursor(),
}
}
fn restore_draft(&mut self, draft: ComposerDraft) {
let ComposerDraft {
text,
text_elements,
local_image_paths,
remote_image_urls,
mention_bindings,
pending_pastes,
cursor,
} = draft;
self.set_remote_image_urls(remote_image_urls);
self.set_text_content_with_mention_bindings(
text,
text_elements,
local_image_paths,
mention_bindings,
);
self.set_pending_pastes(pending_pastes);
self.set_current_cursor(cursor);
self.sync_popups();
}
pub(crate) fn set_placeholder_text(&mut self, placeholder: String) {
self.placeholder_text = placeholder;
}
pub(crate) fn set_parent_owned_thread(&mut self) {
self.blocks_direct_input = true;
self.placeholder_text = "Viewing sub-agent — direct input is disabled".to_string();
}
pub(crate) fn move_cursor_to_end(&mut self) {
self.draft
.textarea
.set_cursor(self.draft.textarea.text().len());
self.sync_popups();
}
fn move_cursor_to_history_entry_end(&mut self) {
let cursor = if self.draft.textarea.is_vim_normal_mode() {
self.draft.textarea.vim_normal_end_cursor()
} else {
self.draft.textarea.text().len()
};
self.draft.textarea.set_cursor(cursor);
self.sync_popups();
}
fn imported_text_for_textarea(
&mut self,
text: String,
text_elements: Vec<TextElement>,
) -> (String, Vec<TextElement>) {
if let Some(stripped) = text.strip_prefix('!') {
self.draft.is_bash_mode = true;
(
stripped.to_string(),
text_elements
.into_iter()
.filter_map(|element| Self::shift_text_element(element, -1))
.collect(),
)
} else {
self.draft.is_bash_mode = false;
(text, text_elements)
}
}
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
if self.is_empty() {
return None;
}
let previous = self.current_text();
let text_elements = self.current_text_elements();
let local_image_paths = self.attachments.local_image_paths();
let pending_pastes = std::mem::take(&mut self.draft.pending_pastes);
let remote_image_urls = self.attachments.remote_image_urls();
let mention_bindings = self.snapshot_mention_bindings();
self.set_text_content(String::new(), Vec::new(), Vec::new());
self.attachments.clear_remote_image_urls();
self.history.reset_navigation();
self.history.record_local_submission(HistoryEntry {
text: previous.clone(),
text_elements,
local_image_paths,
remote_image_urls,
mention_bindings,
pending_pastes,
});
Some(previous)
}
pub(crate) fn current_text(&self) -> String {
if self.draft.is_bash_mode {
format!("!{}", self.draft.textarea.text())
} else {
self.draft.textarea.text().to_string()
}
}
fn apply_history_entry(&mut self, entry: HistoryEntry) {
let HistoryEntry {
text,
text_elements,
local_image_paths,
remote_image_urls,
mention_bindings,
pending_pastes,
} = entry;
self.set_remote_image_urls(remote_image_urls);
self.set_text_content_with_mention_bindings(
text,
text_elements,
local_image_paths,
mention_bindings,
);
self.set_pending_pastes(pending_pastes);
self.move_cursor_to_history_entry_end();
}
pub(crate) fn text_elements(&self) -> Vec<TextElement> {
self.current_text_elements()
}
pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot {
ComposerDraftSnapshot {
text: self.current_text(),
text_elements: self.text_elements(),
local_images: self.local_images(),
remote_image_urls: self.remote_image_urls(),
mention_bindings: self.mention_bindings(),
pending_pastes: self.pending_pastes(),
}
}
#[cfg(test)]
pub(crate) fn local_image_paths(&self) -> Vec<PathBuf> {
self.attachments.local_image_paths()
}
#[cfg(test)]
pub(crate) fn status_line_text(&self) -> Option<String> {
self.footer.status_line_text()
}
pub(crate) fn local_images(&self) -> Vec<LocalImageAttachment> {
self.attachments.local_images()
}
pub(crate) fn mention_bindings(&self) -> Vec<MentionBinding> {
self.snapshot_mention_bindings()
}
pub(crate) fn take_recent_submission_mention_bindings(&mut self) -> Vec<MentionBinding> {
std::mem::take(&mut self.draft.recent_submission_mention_bindings)
}
pub(crate) fn record_pending_slash_command_history(&mut self) {
if let Some(entry) = self.pending_slash_command_history.take() {
self.history.record_local_submission(entry);
}
}
pub fn attach_image(&mut self, path: PathBuf) {
self.attachments
.attach_image(&mut self.draft.textarea, path);
}
#[cfg(test)]
pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
self.attachments.take_recent_submission_images()
}
pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec<LocalImageAttachment> {
self.attachments
.take_recent_submission_images_with_placeholders()
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
self.handle_paste_burst_flush(Instant::now())
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.draft.paste_burst.is_active()
}
pub(crate) fn recommended_paste_flush_delay() -> Duration {
PasteBurst::recommended_flush_delay()
}
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
let current_opt = if self.mentions_v2_enabled {
self.current_mentions_v2_token()
} else {
Self::current_at_token(&self.draft.textarea)
};
let Some(current_token) = current_opt else {
return;
};
if !current_token.starts_with(&query) {
return;
}
match &mut self.popups.active {
ActivePopup::File(popup) => {
popup.set_matches(&query, matches);
}
ActivePopup::MentionV2(popup) => {
popup.set_file_matches(&query, matches);
}
_ => {}
}
}
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
self.footer.quit_shortcut_expires_at = Instant::now()
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
.or_else(|| Some(Instant::now()));
self.footer.quit_shortcut_key = key;
self.footer.mode = FooterMode::QuitShortcutReminder;
self.set_has_focus(has_focus);
}
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
self.footer.quit_shortcut_expires_at = None;
self.footer.mode = reset_mode_after_activity(self.footer.mode);
self.set_has_focus(has_focus);
}
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
self.footer
.quit_shortcut_expires_at
.is_some_and(|expires_at| Instant::now() < expires_at)
}
fn next_large_paste_placeholder(&self, char_count: usize) -> String {
let base = format!("[Pasted Content {char_count} chars]");
let prefix = format!("{base} #");
let mut max_suffix = 0usize;
for (placeholder, _) in &self.draft.pending_pastes {
if placeholder == &base {
max_suffix = max_suffix.max(1);
continue;
}
if let Some(suffix) = placeholder.strip_prefix(&prefix)
&& let Ok(value) = suffix.parse::<usize>()
{
max_suffix = max_suffix.max(value);
}
}
if max_suffix == 0 {
base
} else {
format!("{base} #{}", max_suffix + 1)
}
}
pub(crate) fn insert_str(&mut self, text: &str) {
self.draft.textarea.insert_str(text);
self.sync_bash_mode_from_text();
self.sync_popups();
}
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if !self.draft.input_enabled {
return (InputResult::None, false);
}
if matches!(key_event.kind, KeyEventKind::Release) {
return (InputResult::None, false);
}
if self.history_search.is_some() {
return self.handle_history_search_key(key_event);
}
if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) {
return self.begin_history_search();
}
let result = match &mut self.popups.active {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event),
ActivePopup::MentionV2(_) => self.handle_key_event_with_mentions_v2_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
self.reset_vim_mode_after_successful_dispatch(&result.0);
self.sync_popups();
result
}
pub(crate) fn popup_active(&self) -> bool {
self.history_search.is_some() || self.popups.active()
}
#[inline]
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
let mut p = pos.min(text.len());
if p < text.len() && !text.is_char_boundary(p) {
p = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= p)
.last()
.unwrap_or(0);
}
p
}
#[inline]
fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) {
if self.draft.disable_paste_burst {
self.draft.textarea.input(input);
let text_after = self.draft.textarea.text();
self.draft
.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
return (InputResult::None, true);
}
if let KeyEvent {
code: KeyCode::Char(ch),
..
} = input
{
if self.draft.paste_burst.try_append_char_if_active(ch, now) {
return (InputResult::None, true);
}
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
if let Some(decision) = self.draft.paste_burst.on_plain_char_no_hold(now) {
match decision {
CharDecision::BufferAppend => {
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::BeginBuffer { retro_chars } => {
let cur = self.draft.textarea.cursor();
let txt = self.draft.textarea.text();
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
let before = &txt[..safe_cur];
if let Some(grab) = self.draft.paste_burst.decide_begin_buffer(
now,
before,
retro_chars as usize,
) {
if !grab.grabbed.is_empty() {
self.draft
.textarea
.replace_range(grab.start_byte..safe_cur, "");
}
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
}
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
}
}
}
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.draft.textarea.input(input);
let text_after = self.draft.textarea.text();
self.draft
.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
(InputResult::None, true)
}
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running);
if next_mode != self.footer.mode {
self.footer.mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
let ActivePopup::File(popup) = &mut self.popups.active else {
unreachable!();
};
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some((range, query)) = completion_target::current_prefixed_token_range(
&self.draft.textarea,
'@',
false,
) {
self.popups.dismissed_file_token =
Some(DismissedToken::new(&self.draft.textarea, range, query));
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let Some(sel) = popup.selected_match() else {
self.popups.active = ActivePopup::None;
return if key_event.code == KeyCode::Enter {
self.handle_key_event_without_popup(key_event)
} else {
(InputResult::None, true)
};
};
let sel_path = sel.to_string_lossy().to_string();
if let Some((token_range, _)) =
self.current_editable_at_token_range_with_options( false)
{
self.insert_selected_file_path(token_range, &sel_path);
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
}
fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
self.footer.mode = reset_mode_after_activity(self.footer.mode);
let ActivePopup::Skill(popup) = &mut self.popups.active else {
unreachable!();
};
let mut selected_mention: Option<(String, Option<String>)> = None;
let mut close_popup = false;
let result = match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some(target) = self.current_mention_target() {
self.popups.dismissed_mention_token = Some(DismissedToken::new(
&self.draft.textarea,
target.range,
target.query,
));
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
if let Some(mention) = popup.selected_mention() {
selected_mention = Some((mention.insert_text.clone(), mention.path.clone()));
}
close_popup = true;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
};
if close_popup {
if let Some((insert_text, path)) = selected_mention
&& let Some(target) = self.current_mention_target()
{
self.insert_selected_mention(target.range, &insert_text, path.as_deref());
}
self.popups.active = ActivePopup::None;
}
result
}
fn handle_key_event_with_mentions_v2_popup(
&mut self,
key_event: KeyEvent,
) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
self.footer.mode = reset_mode_after_activity(self.footer.mode);
let can_switch_search_mode = self.current_editable_at_token().is_some();
let ActivePopup::MentionV2(popup) = &mut self.popups.active else {
unreachable!();
};
let mut selected: Option<MentionV2Selection> = None;
let mut close_popup = false;
let mut submit_without_popup = false;
let result = match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
} => {
if can_switch_search_mode {
popup.previous_search_mode();
(InputResult::None, true)
} else {
self.handle_input_basic(key_event)
}
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} => {
if can_switch_search_mode {
popup.next_search_mode();
(InputResult::None, true)
} else {
self.handle_input_basic(key_event)
}
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some((range, query)) = self.current_mentions_v2_token_range() {
self.popups.dismissed_mention_token =
Some(DismissedToken::new(&self.draft.textarea, range, query));
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
selected = popup.selected();
close_popup = true;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
selected = popup.selected();
close_popup = true;
submit_without_popup = selected.is_none();
(InputResult::None, true)
}
input => self.handle_input_basic(input),
};
if close_popup {
let token_range = self
.current_editable_at_token_range_with_options( true)
.map(|(range, _)| range);
if let (Some(selected), Some(token_range)) = (selected, token_range) {
match selected {
MentionV2Selection::File(path) => {
self.insert_selected_file_path(
token_range,
path.to_string_lossy().as_ref(),
);
}
MentionV2Selection::Tool { insert_text, path } => {
self.insert_selected_mention(token_range, &insert_text, path.as_deref());
}
}
}
self.popups.active = ActivePopup::None;
if submit_without_popup {
return self.handle_key_event_without_popup(key_event);
}
}
result
}
fn is_image_path(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".png")
|| lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".gif")
|| lower.ends_with(".webp")
}
fn advance_past_completion_separator(&mut self) {
let cursor = self.draft.textarea.cursor();
let existing_separator_len = self.draft.textarea.text()[cursor..]
.chars()
.next()
.filter(|c| {
c.is_whitespace()
&& !matches!(
*c,
'\n' | '\r'
| '\u{000B}'
| '\u{000C}'
| '\u{0085}'
| '\u{2028}'
| '\u{2029}'
)
})
.map(char::len_utf8);
if let Some(separator_len) = existing_separator_len {
let after_separator = cursor + separator_len;
let separator_precedes_suffix = self.draft.textarea.text()[after_separator..]
.chars()
.next()
.is_some_and(|c| !c.is_whitespace());
if separator_precedes_suffix {
self.draft.textarea.insert_str(" ");
} else {
self.draft.textarea.set_cursor(after_separator);
}
} else {
self.draft.textarea.insert_str(" ");
}
}
fn dismiss_completed_prefixed_token(
&mut self,
prefix: char,
inserted_range: Range<usize>,
inserted_text: &str,
) {
let Some(completed_token) = inserted_text.strip_prefix(prefix) else {
return;
};
let Some((current_range, current_token)) = completion_target::current_prefixed_token_range(
&self.draft.textarea,
prefix,
true,
) else {
return;
};
if current_range != inserted_range || current_token != completed_token {
return;
}
if prefix == '@' && !self.mentions_v2_enabled {
self.popups.dismissed_file_token = Some(DismissedToken::new(
&self.draft.textarea,
current_range,
current_token,
));
} else {
self.popups.dismissed_mention_token = Some(DismissedToken::new(
&self.draft.textarea,
current_range,
current_token,
));
}
}
fn insert_selected_file_path(&mut self, token_range: Range<usize>, selected_path: &str) {
let token_range = self.separate_completion_from_adjacent_element(token_range);
if Self::is_image_path(selected_path) {
let path_buf = PathBuf::from(selected_path);
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::debug!("selected image dimensions={}x{}", width, height);
let start_idx = token_range.start;
self.draft.textarea.replace_range(token_range, "");
self.draft.textarea.set_cursor(start_idx);
self.attach_image(path_buf);
self.advance_past_completion_separator();
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
self.insert_selected_path(token_range, selected_path);
}
}
} else {
self.insert_selected_path(token_range, selected_path);
}
}
fn trim_text_elements(
original: &str,
trimmed: &str,
elements: Vec<TextElement>,
) -> Vec<TextElement> {
if trimmed.is_empty() || elements.is_empty() {
return Vec::new();
}
let trimmed_start = original.len().saturating_sub(original.trim_start().len());
let trimmed_end = trimmed_start.saturating_add(trimmed.len());
elements
.into_iter()
.filter_map(|elem| {
let start = elem.byte_range.start;
let end = elem.byte_range.end;
if end <= trimmed_start || start >= trimmed_end {
return None;
}
let new_start = start.saturating_sub(trimmed_start);
let new_end = end.saturating_sub(trimmed_start).min(trimmed.len());
if new_start >= new_end {
return None;
}
let placeholder = trimmed.get(new_start..new_end).map(str::to_string);
Some(TextElement::new(
ByteRange {
start: new_start,
end: new_end,
},
placeholder,
))
})
.collect()
}
pub(crate) fn expand_pending_pastes(
text: &str,
mut elements: Vec<TextElement>,
pending_pastes: &[(String, String)],
) -> (String, Vec<TextElement>) {
if pending_pastes.is_empty() || elements.is_empty() {
return (text.to_string(), elements);
}
let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new();
for (placeholder, actual) in pending_pastes {
pending_by_placeholder
.entry(placeholder.as_str())
.or_default()
.push_back(actual.as_str());
}
elements.sort_by_key(|elem| elem.byte_range.start);
let mut rebuilt = String::with_capacity(text.len());
let mut rebuilt_elements = Vec::with_capacity(elements.len());
let mut cursor = 0usize;
for elem in elements {
let start = elem.byte_range.start.min(text.len());
let end = elem.byte_range.end.min(text.len());
if start > end {
continue;
}
if start > cursor {
rebuilt.push_str(&text[cursor..start]);
}
let elem_text = &text[start..end];
let placeholder = elem.placeholder(text).map(str::to_string);
let replacement = placeholder
.as_deref()
.and_then(|ph| pending_by_placeholder.get_mut(ph))
.and_then(VecDeque::pop_front);
if let Some(actual) = replacement {
rebuilt.push_str(actual);
} else {
let new_start = rebuilt.len();
rebuilt.push_str(elem_text);
let new_end = rebuilt.len();
let placeholder = placeholder.or_else(|| Some(elem_text.to_string()));
rebuilt_elements.push(TextElement::new(
ByteRange {
start: new_start,
end: new_end,
},
placeholder,
));
}
cursor = end;
}
if cursor < text.len() {
rebuilt.push_str(&text[cursor..]);
}
(rebuilt, rebuilt_elements)
}
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
self.skills.as_ref()
}
pub fn plugins(&self) -> Option<&Vec<PluginCapabilitySummary>> {
self.plugins.as_ref()
}
fn mentions_enabled(&self) -> bool {
let skills_ready = self
.skills
.as_ref()
.is_some_and(|skills| !skills.is_empty());
let plugins_ready = self
.plugins
.as_ref()
.is_some_and(|plugins| !plugins.is_empty());
let connectors_ready = self.connectors_enabled
&& self
.connectors_snapshot
.as_ref()
.is_some_and(|snapshot| !snapshot.connectors.is_empty());
skills_ready || plugins_ready || connectors_ready
}
fn current_prefixed_token(
textarea: &TextArea,
prefix: char,
allow_empty: bool,
) -> Option<String> {
completion_target::current_prefixed_token_range(textarea, prefix, allow_empty)
.map(|(_, token)| token)
}
fn current_at_token(textarea: &TextArea) -> Option<String> {
Self::current_prefixed_token(textarea, '@', false)
}
fn current_editable_prefixed_token_range(
&self,
prefix: char,
allow_empty: bool,
) -> Option<(Range<usize>, String)> {
let (range, token) = completion_target::current_prefixed_token_range(
&self.draft.textarea,
prefix,
allow_empty,
)?;
completion_target::prefixed_token_range_is_editable(
&self.draft.textarea,
prefix,
&range,
&token,
)
.then_some((range, token))
}
fn current_editable_at_token_range_with_options(
&self,
allow_empty: bool,
) -> Option<(Range<usize>, String)> {
self.current_editable_prefixed_token_range('@', allow_empty)
}
fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option<String> {
self.current_editable_at_token_range_with_options(allow_empty)
.map(|(_, token)| token)
}
fn current_editable_at_token(&self) -> Option<String> {
self.current_editable_at_token_with_options( false)
}
fn current_mentions_v2_token_range(&self) -> Option<(Range<usize>, String)> {
if !self.mentions_v2_enabled {
return None;
}
self.current_editable_at_token_range_with_options( true)
}
fn current_mentions_v2_token(&self) -> Option<String> {
self.current_mentions_v2_token_range()
.map(|(_, token)| token)
}
fn current_mention_target(&self) -> Option<MentionCompletionTarget> {
if !self.mentions_enabled() {
return None;
}
let mentions: OnceCell<Vec<MentionItem>> = OnceCell::new();
let (range, query) = {
let dollar_query_is_completable =
|query: &str| match completion_target::dollar_query_kind(query) {
completion_target::DollarQueryKind::Completable => true,
completion_target::DollarQueryKind::AmbiguousShellParameter => mentions
.get_or_init(|| {
self.mention_items()
.into_iter()
.filter(|mention| {
mention.path.is_some()
&& Self::mention_token_from_insert_text(
&mention.insert_text,
)
.is_some()
})
.collect()
})
.iter()
.any(|mention| mention.fuzzy_match_query(query).is_some()),
completion_target::DollarQueryKind::ShellVariable
| completion_target::DollarQueryKind::DefiniteShellParameter
| completion_target::DollarQueryKind::Invalid => false,
};
let (range, query) =
completion_target::current_prefixed_token_range_with_dollar_predicate(
&self.draft.textarea,
'$',
true,
dollar_query_is_completable,
)?;
if !completion_target::prefixed_token_range_is_editable(
&self.draft.textarea,
'$',
&range,
&query,
) || !dollar_query_is_completable(&query)
{
return None;
}
(range, query)
};
let prebuilt_mentions = if matches!(
completion_target::dollar_query_kind(&query),
completion_target::DollarQueryKind::AmbiguousShellParameter
) {
mentions.into_inner()
} else {
None
};
Some(MentionCompletionTarget {
range,
query,
prebuilt_mentions,
})
}
fn insert_selected_path(&mut self, token_range: Range<usize>, path: &str) {
let needs_quotes = path.chars().any(char::is_whitespace);
let inserted = if needs_quotes && !path.contains('"') {
format!("\"{path}\"")
} else {
path.to_string()
};
let start_idx = token_range.start;
self.draft.textarea.replace_range(token_range, &inserted);
let inserted_range = start_idx..start_idx.saturating_add(inserted.len());
self.draft.textarea.set_cursor(inserted_range.end);
self.advance_past_completion_separator();
self.dismiss_completed_prefixed_token('@', inserted_range, &inserted);
}
fn insert_selected_mention(
&mut self,
token_range: Range<usize>,
insert_text: &str,
path: Option<&str>,
) {
let token_range = self.separate_completion_from_adjacent_element(token_range);
let start_idx = token_range.start;
self.draft.textarea.replace_range(token_range, "");
self.draft.textarea.set_cursor(start_idx);
let id = self.draft.textarea.insert_element(insert_text);
let inserted_range = start_idx..start_idx.saturating_add(insert_text.len());
if let (Some(path), Some((sigil, mention))) =
(path, Self::mention_token_from_insert_text(insert_text))
{
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
sigil,
mention,
path: path.to_string(),
},
);
}
self.advance_past_completion_separator();
if let Some(sigil) = insert_text.chars().next()
&& matches!(sigil, '$' | '@')
{
self.dismiss_completed_prefixed_token(sigil, inserted_range, insert_text);
}
}
fn separate_completion_from_adjacent_element(
&mut self,
mut token_range: Range<usize>,
) -> Range<usize> {
let starts_after_element = self
.draft
.textarea
.text_element_ranges()
.any(|range| range.end == token_range.start);
if starts_after_element {
self.draft
.textarea
.replace_range(token_range.start..token_range.start, " ");
token_range.start += 1;
token_range.end += 1;
}
token_range
}
fn mention_token_from_insert_text(insert_text: &str) -> Option<(char, String)> {
let sigil = insert_text.chars().next()?;
if !matches!(sigil, '$' | '@') {
return None;
}
let name = &insert_text[sigil.len_utf8()..];
if name.is_empty() {
return None;
}
if name
.as_bytes()
.iter()
.all(|byte| is_mention_name_char(*byte))
{
Some((sigil, name.to_string()))
} else {
None
}
}
fn current_mention_elements(&self) -> Vec<(u64, char, String)> {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
Self::mention_token_from_insert_text(snapshot.text.as_str())
.map(|(sigil, mention)| (snapshot.id, sigil, mention))
})
.collect()
}
fn snapshot_mention_bindings(&self) -> Vec<MentionBinding> {
let mut ordered = Vec::new();
for (id, sigil, mention) in self.current_mention_elements() {
if let Some(binding) = self.draft.mention_bindings.get(&id)
&& binding.sigil == sigil
&& binding.mention == mention
{
ordered.push(MentionBinding {
sigil: binding.sigil,
mention: binding.mention.clone(),
path: binding.path.clone(),
});
}
}
ordered
}
fn bind_mentions_from_snapshot(&mut self, mention_bindings: Vec<MentionBinding>) {
self.draft.mention_bindings.clear();
if mention_bindings.is_empty() {
return;
}
let text = self.draft.textarea.text().to_string();
let mut scan_from = 0usize;
for binding in mention_bindings {
let token = format!("{}{}", binding.sigil, binding.mention);
let Some(range) =
find_next_mention_token_range(text.as_str(), token.as_str(), scan_from)
else {
continue;
};
let id = if let Some(id) = self.draft.textarea.add_element_range(range.clone()) {
Some(id)
} else {
self.draft
.textarea
.element_id_for_exact_range(range.clone())
};
if let Some(id) = id {
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
sigil: binding.sigil,
mention: binding.mention,
path: binding.path,
},
);
scan_from = range.end;
}
}
}
fn plugin_at_mention_highlights(&self) -> Vec<(Range<usize>, Style)> {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
let binding = self.draft.mention_bindings.get(&snapshot.id)?;
if !binding.path.starts_with("plugin://") || !snapshot.text.starts_with('@') {
return None;
}
Some((snapshot.range, Style::default().fg(Color::Magenta)))
})
.collect()
}
fn prepare_submission_text(
&mut self,
record_history: bool,
) -> Option<(String, Vec<TextElement>)> {
self.prepare_submission_text_with_options(
record_history,
SlashValidation::Immediate,
PendingPasteHandling::Expand,
)
}
fn prepare_submission_text_with_options(
&mut self,
record_history: bool,
slash_validation: SlashValidation,
pending_paste_handling: PendingPasteHandling,
) -> Option<(String, Vec<TextElement>)> {
let mut text = self.current_text();
let original_input = text.clone();
let original_text_elements = self.current_text_elements();
let original_mention_bindings = self.snapshot_mention_bindings();
let original_local_image_paths = self.attachments.local_image_paths();
let original_pending_pastes = self.draft.pending_pastes.clone();
let mut text_elements = original_text_elements.clone();
let input_starts_with_space = original_input.starts_with(' ');
self.draft.recent_submission_mention_bindings.clear();
self.draft.textarea.set_text_clearing_elements("");
self.draft.is_bash_mode = false;
if pending_paste_handling == PendingPasteHandling::Expand
&& !self.draft.pending_pastes.is_empty()
{
let (expanded, expanded_elements) =
Self::expand_pending_pastes(&text, text_elements, &self.draft.pending_pastes);
text = expanded;
text_elements = expanded_elements;
}
let expanded_input = text.clone();
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
if slash_validation == SlashValidation::Immediate
&& let SubmissionValidation::UnknownCommand(name) = self
.slash_input()
.validate_submission(&text, input_starts_with_space)
{
let message = format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(message, None),
)));
self.set_text_content_with_mention_bindings(
original_input.clone(),
original_text_elements,
original_local_image_paths,
original_mention_bindings,
);
self.draft
.pending_pastes
.clone_from(&original_pending_pastes);
self.draft.textarea.set_cursor(original_input.len());
return None;
}
let actual_chars = text.chars().count();
if actual_chars > MAX_USER_INPUT_TEXT_CHARS {
let message = user_input_too_large_message(actual_chars);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(message),
)));
self.set_text_content_with_mention_bindings(
original_input.clone(),
original_text_elements,
original_local_image_paths,
original_mention_bindings,
);
self.draft
.pending_pastes
.clone_from(&original_pending_pastes);
self.draft.textarea.set_cursor(original_input.len());
return None;
}
self.attachments
.prune_local_images_for_submission(&text, &text_elements);
if text.is_empty() && self.attachments.is_empty() {
return None;
}
self.draft.recent_submission_mention_bindings = original_mention_bindings.clone();
if record_history && (!text.is_empty() || !self.attachments.is_empty()) {
self.history.record_local_submission(HistoryEntry {
text: text.clone(),
text_elements: text_elements.clone(),
local_image_paths: self.attachments.local_image_paths(),
remote_image_urls: self.attachments.remote_image_urls(),
mention_bindings: original_mention_bindings,
pending_pastes: if pending_paste_handling == PendingPasteHandling::Preserve {
original_pending_pastes.clone()
} else {
Vec::new()
},
});
}
self.draft.pending_pastes.clear();
Some((text, text_elements))
}
fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) {
let result = self.handle_submission_with_time(should_queue, Instant::now());
self.reset_vim_mode_after_successful_dispatch(&result.0);
result
}
fn reset_vim_mode_after_successful_dispatch(&mut self, result: &InputResult) {
if matches!(
result,
InputResult::Submitted { .. }
| InputResult::Queued { .. }
| InputResult::Command(_)
| InputResult::ServiceTierCommand(_)
| InputResult::CommandWithArgs(_, _, _)
) {
self.draft.textarea.enter_vim_normal_mode();
}
}
fn handle_submission_with_time(
&mut self,
should_queue: bool,
now: Instant,
) -> (InputResult, bool) {
let in_slash_context = self.slash_commands_enabled()
&& !self.draft.is_bash_mode
&& (matches!(self.popups.active, ActivePopup::Command(_))
|| self
.draft
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/'));
if !should_queue
&& !self.draft.disable_paste_burst
&& self.draft.paste_burst.is_active()
&& !in_slash_context
&& self.draft.paste_burst.append_newline_if_active(now)
{
return (InputResult::None, true);
}
if !should_queue
&& !in_slash_context
&& !self.draft.disable_paste_burst
&& self
.draft
.paste_burst
.newline_should_insert_instead_of_submit(now)
{
self.draft.textarea.insert_str("\n");
self.draft.paste_burst.extend_window(now);
return (InputResult::None, true);
}
if let Some(result) = self.handle_parent_owned_submission() {
return result;
}
if should_queue {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
let raw_text = self.draft.textarea.text();
let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text);
let preserve_pending_pastes = defer_slash_validation
&& !self.draft.pending_pastes.is_empty()
&& parse_slash_name(raw_text)
.is_some_and(|(name, _, _)| name == SlashCommand::Goal.command());
let pending_pastes = if preserve_pending_pastes {
self.draft.pending_pastes.clone()
} else {
Vec::new()
};
if let Some((text, text_elements)) = self.prepare_submission_text_with_options(
true,
if defer_slash_validation {
SlashValidation::Deferred
} else {
SlashValidation::Immediate
},
if preserve_pending_pastes {
PendingPasteHandling::Preserve
} else {
PendingPasteHandling::Expand
},
) {
let action = slash_input::queued_input_action(&text, defer_slash_validation);
return (
InputResult::Queued {
text,
text_elements,
action,
pending_pastes,
},
true,
);
}
return (InputResult::None, true);
}
if let Some(result) = self.try_dispatch_bare_slash_command() {
return (result, true);
}
let original_input = self.current_text();
let original_text_elements = self.current_text_elements();
let original_mention_bindings = self.snapshot_mention_bindings();
let original_local_image_paths = self.attachments.local_image_paths();
let original_pending_pastes = self.draft.pending_pastes.clone();
if let Some(result) = self.try_dispatch_slash_command_with_args() {
return (result, true);
}
if let Some((text, text_elements)) =
self.prepare_submission_text( true)
{
if should_queue {
(
InputResult::Queued {
text,
text_elements,
action: QueuedInputAction::Plain,
pending_pastes: Vec::new(),
},
true,
)
} else {
(
InputResult::Submitted {
text,
text_elements,
},
true,
)
}
} else {
self.set_text_content_with_mention_bindings(
original_input,
original_text_elements,
original_local_image_paths,
original_mention_bindings,
);
self.draft.pending_pastes = original_pending_pastes;
(InputResult::None, true)
}
}
fn handle_parent_owned_submission(&mut self) -> Option<(InputResult, bool)> {
if !self.blocks_direct_input {
return None;
}
let text = self.current_text();
let allowed_slash_command = parse_slash_name(&text).is_some_and(|(name, args, _)| {
matches!(
self.slash_input().command(name),
Some(SlashCommandItem::Builtin(command))
if name == command.command()
&& parent_owned_command_is_allowed(command, args)
)
});
if text.starts_with('/') && allowed_slash_command {
return None;
}
Some((InputResult::ParentOwnedInputBlocked, true))
}
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
let command = self
.slash_input()
.bare_command(self.draft.textarea.text())?;
if self.reject_slash_command_if_unavailable(&command) {
self.stage_slash_command_history(&command);
self.record_pending_slash_command_history();
return Some(InputResult::None);
}
self.stage_slash_command_history(&command);
self.draft.textarea.set_text_clearing_elements("");
self.draft.is_bash_mode = false;
Some(match command {
SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd),
SlashCommandItem::ServiceTier(command) => InputResult::ServiceTierCommand(command),
})
}
fn try_dispatch_slash_command_with_args(&mut self) -> Option<InputResult> {
let text = self.draft.textarea.text().to_string();
let inline_command = self.slash_input().inline_command(&text)?;
let command = inline_command.command;
if self.reject_slash_command_if_unavailable(&command) {
self.stage_slash_command_history(&command);
self.record_pending_slash_command_history();
return Some(InputResult::None);
}
self.stage_slash_command_history(&command);
let mut args_elements = slash_input::args_elements(
inline_command.rest,
inline_command.rest_offset,
&self.draft.textarea.text_elements(),
);
let trimmed_rest = inline_command.rest.trim();
args_elements = Self::trim_text_elements(inline_command.rest, trimmed_rest, args_elements);
let SlashCommandItem::Builtin(cmd) = command else {
return None;
};
Some(InputResult::CommandWithArgs(
cmd,
trimmed_rest.to_string(),
args_elements,
))
}
pub(crate) fn prepare_inline_args_submission(
&mut self,
record_history: bool,
) -> Option<(String, Vec<TextElement>)> {
let (prepared_text, prepared_elements) = self.prepare_submission_text(record_history)?;
let (prepared_rest, prepared_rest_offset) = slash_input::prepared_args(&prepared_text)?;
let mut args_elements =
slash_input::args_elements(prepared_rest, prepared_rest_offset, &prepared_elements);
let trimmed_rest = prepared_rest.trim();
args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements);
Some((trimmed_rest.to_string(), args_elements))
}
fn reject_slash_command_if_unavailable(&self, command: &SlashCommandItem) -> bool {
if !self.is_task_running || command.available_during_task() {
return false;
}
let message = format!(
"'/{}' is disabled while a task is in progress.",
command.command()
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(message),
)));
true
}
fn stage_slash_command_history(&mut self, command: &SlashCommandItem) {
if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) {
return;
}
self.stage_slash_command_history_text(self.draft.textarea.text().trim().to_string());
}
fn stage_selected_slash_command_history(&mut self, command: &CommandItem) {
if matches!(command, CommandItem::Builtin(SlashCommand::Clear)) {
return;
}
self.stage_slash_command_history_text(format!("/{}", command.command()));
}
fn stage_slash_command_history_text(&mut self, text: String) {
self.pending_slash_command_history = Some(HistoryEntry {
text,
text_elements: self.draft.textarea.text_elements(),
local_image_paths: self.attachments.local_image_paths(),
remote_image_urls: self.attachments.remote_image_urls(),
mention_bindings: self.snapshot_mention_bindings(),
pending_pastes: self.draft.pending_pastes.clone(),
});
}
fn handle_remote_image_selection_key(
&mut self,
key_event: &KeyEvent,
) -> Option<(InputResult, bool)> {
self.attachments
.handle_remote_image_selection_key(key_event, &mut self.draft.textarea)
}
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if let Some((result, redraw)) = self.handle_remote_image_selection_key(&key_event) {
return (result, redraw);
}
if self.attachments.selected_remote_image_index.is_some() {
self.attachments.clear_remote_image_selection();
}
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if self.draft.is_bash_mode && key_event.code == KeyCode::Esc {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
if self.draft.textarea.is_empty() {
self.draft.is_bash_mode = false;
return (InputResult::None, true);
}
}
if self.should_handle_vim_insert_escape(key_event) {
return self.handle_input_basic(key_event);
}
if self.draft.textarea.is_vim_normal_mode() && self.draft.textarea.is_vim_operator_pending()
{
return self.handle_input_basic(key_event);
}
if self.draft.textarea.is_vim_normal_mode()
&& self.is_empty()
&& matches!(
key_event,
KeyEvent {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
)
{
self.footer.mode = reset_mode_after_activity(self.footer.mode);
self.draft.textarea.set_text_clearing_elements("/");
self.draft
.textarea
.set_cursor(self.draft.textarea.text().len());
self.draft.textarea.enter_vim_insert_mode();
return (InputResult::None, true);
}
if self.draft.textarea.is_vim_normal_mode()
&& self.is_empty()
&& matches!(
key_event,
KeyEvent {
code: KeyCode::Char('!'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
)
{
self.footer.mode = reset_mode_after_activity(self.footer.mode);
self.draft.is_bash_mode = true;
self.draft.textarea.enter_vim_insert_mode();
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
if self.is_empty() {
let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running);
if next_mode != self.footer.mode {
self.footer.mode = next_mode;
return (InputResult::None, true);
}
}
} else {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
if self.queue_keys.is_pressed(key_event)
&& (self.is_task_running || self.queue_submissions || !self.is_bang_shell_command())
{
return self.handle_submission(self.is_task_running || self.queue_submissions);
}
if self.submit_keys.is_pressed(key_event) {
return self.handle_submission(self.queue_submissions);
}
if let KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} = key_event
&& self.is_empty()
{
return (InputResult::None, false);
}
let (history_up_pressed, history_down_pressed) = if self.draft.textarea.is_vim_normal_mode()
{
if self.draft.textarea.is_vim_operator_pending() {
(false, false)
} else {
(
self.vim_normal_keymap.move_up.is_pressed(key_event),
self.vim_normal_keymap.move_down.is_pressed(key_event),
)
}
} else {
(
self.editor_keymap.move_up.is_pressed(key_event),
self.editor_keymap.move_down.is_pressed(key_event),
)
};
if history_up_pressed || history_down_pressed {
if self
.history
.should_handle_navigation(&self.current_text(), self.history_navigation_cursor())
{
let replace_entry = if history_up_pressed {
self.history.navigate_up(&self.app_event_tx)
} else {
self.history.navigate_down(&self.app_event_tx)
};
if let Some(entry) = replace_entry {
self.apply_history_entry(entry);
return (InputResult::None, true);
}
}
return self.handle_input_basic(key_event);
}
self.handle_input_basic(key_event)
}
fn is_bang_shell_command(&self) -> bool {
self.current_text().trim_start().starts_with('!')
}
fn shell_mode_footer_line(&self) -> Option<Line<'static>> {
self.is_bang_shell_command()
.then_some(())
.map(|_| Line::from(vec![Span::from("Shell mode").light_red()]))
}
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
match self.draft.paste_burst.flush_if_due(now) {
FlushResult::Paste(pasted) => {
self.handle_paste(pasted);
true
}
FlushResult::Typed(ch) => {
self.insert_str(ch.to_string().as_str());
true
}
FlushResult::None => false,
}
}
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
if !matches!(input.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return (InputResult::None, false);
}
self.handle_input_basic_with_time(input, Instant::now())
}
fn handle_input_basic_with_time(
&mut self,
input: KeyEvent,
now: Instant,
) -> (InputResult, bool) {
self.handle_paste_burst_flush(now);
if !matches!(input.code, KeyCode::Esc) {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
if matches!(input.code, KeyCode::Enter)
&& !self.draft.disable_paste_burst
&& self.draft.paste_burst.is_active()
&& self.draft.paste_burst.append_newline_if_active(now)
{
return (InputResult::None, true);
}
if let KeyEvent {
code: KeyCode::Char(ch),
modifiers,
..
} = input
{
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if !has_ctrl_or_alt
&& !self.draft.disable_paste_burst
&& self.draft.textarea.allows_paste_burst()
{
if !ch.is_ascii() {
return self.handle_non_ascii_char(input, now);
}
match self.draft.paste_burst.on_plain_char(ch, now) {
CharDecision::BufferAppend => {
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::BeginBuffer { retro_chars } => {
let cur = self.draft.textarea.cursor();
let txt = self.draft.textarea.text();
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
let before = &txt[..safe_cur];
if let Some(grab) = self.draft.paste_burst.decide_begin_buffer(
now,
before,
retro_chars as usize,
) {
if !grab.grabbed.is_empty() {
self.draft
.textarea
.replace_range(grab.start_byte..safe_cur, "");
}
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
}
CharDecision::BeginBufferFromPending => {
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::RetainFirstChar => {
return (InputResult::None, true);
}
}
}
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
}
if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter)
&& let Some(pasted) = self.draft.paste_burst.flush_before_modified_input()
{
self.handle_paste(pasted);
}
let elements_before = if self.draft.pending_pastes.is_empty() && self.attachments.is_empty()
{
None
} else {
Some(self.draft.textarea.element_payloads())
};
if self.draft.is_bash_mode
&& matches!(input.code, KeyCode::Backspace)
&& self.draft.textarea.cursor() == 0
{
self.draft.is_bash_mode = false;
return (InputResult::None, true);
}
self.draft.textarea.input(input);
self.sync_bash_mode_from_text();
if let Some(elements_before) = elements_before {
self.reconcile_deleted_elements(elements_before);
}
let crossterm::event::KeyEvent {
code, modifiers, ..
} = input;
match code {
KeyCode::Char(_) => {
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if has_ctrl_or_alt {
self.draft.paste_burst.clear_window_after_non_char();
}
}
KeyCode::Enter => {
}
_ => {
self.draft.paste_burst.clear_window_after_non_char();
}
}
(InputResult::None, true)
}
fn sync_bash_mode_from_text(&mut self) {
if !self.draft.is_bash_mode && self.draft.textarea.text().starts_with('!') {
self.draft.textarea.replace_range(0..1, "");
self.draft.is_bash_mode = true;
}
}
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
let elements_after: HashSet<String> =
self.draft.textarea.element_payloads().into_iter().collect();
let removed_payloads = elements_before
.into_iter()
.filter(|payload| !elements_after.contains(payload))
.collect::<Vec<_>>();
for removed in &removed_payloads {
self.draft.pending_pastes.retain(|(ph, _)| ph != removed);
}
self.attachments
.remove_deleted_local_placeholders(&removed_payloads, &mut self.draft.textarea);
}
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press {
return false;
}
let toggles = self.toggle_shortcuts_keys.is_pressed(*key_event)
&& self.is_empty()
&& !self.is_in_paste_burst();
if !toggles {
return false;
}
let next = toggle_shortcut_mode(
self.footer.mode,
self.quit_shortcut_hint_visible(),
self.is_empty(),
);
let changed = next != self.footer.mode;
self.footer.mode = next;
changed
}
fn footer_props(&self) -> FooterProps {
let mode = self.footer_mode();
let is_wsl = {
#[cfg(target_os = "linux")]
{
mode == FooterMode::ShortcutOverlay && crate::tui_internal::clipboard_paste::is_probably_wsl()
}
#[cfg(not(target_os = "linux"))]
{
false
}
};
FooterProps {
mode,
esc_backtrack_hint: self.footer.esc_backtrack_hint,
use_shift_enter_hint: self.footer.use_shift_enter_hint,
is_task_running: self.is_task_running,
queue_submissions: self.queue_submissions,
quit_shortcut_key: self.footer.quit_shortcut_key,
collaboration_modes_enabled: self.collaboration_modes_enabled,
is_wsl,
status_line_value: self.footer.status_line_value.clone(),
status_line_enabled: self.footer.status_line_enabled,
key_hints: FooterKeyHints {
toggle_shortcuts: self.footer.toggle_shortcuts_key,
queue: self.footer.queue_key,
insert_newline: self.footer.insert_newline_key,
external_editor: self.footer.external_editor_key,
edit_previous: Some(key_hint::plain(KeyCode::Esc)),
show_transcript: self.footer.show_transcript_key,
history_search: self.footer.history_search_key,
reasoning_down: self.footer.reasoning_down_key,
reasoning_up: self.footer.reasoning_up_key,
},
active_agent_label: self.footer.active_agent_label.clone(),
}
}
fn footer_mode(&self) -> FooterMode {
if self.history_search.is_some() {
return FooterMode::HistorySearch;
}
let base_mode = if self.is_empty() {
FooterMode::ComposerEmpty
} else {
FooterMode::ComposerHasDraft
};
match self.footer.mode {
FooterMode::HistorySearch => FooterMode::HistorySearch,
FooterMode::EscHint => FooterMode::EscHint,
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
FooterMode::QuitShortcutReminder
}
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
if self.quit_shortcut_hint_visible() =>
{
FooterMode::QuitShortcutReminder
}
FooterMode::QuitShortcutReminder => base_mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => base_mode,
}
}
fn custom_footer_height(&self) -> Option<u16> {
if self.footer.flash_visible() {
return Some(1);
}
self.footer
.hint_override
.as_ref()
.map(|items| if items.is_empty() { 0 } else { 1 })
}
pub(crate) fn sync_popups(&mut self) {
self.sync_slash_command_elements();
if self.history_search.is_some() {
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
}
self.popups.active = ActivePopup::None;
self.popups.dismissed_file_token = None;
self.popups.dismissed_mention_token = None;
return;
}
if !self.popups_enabled() {
self.popups.active = ActivePopup::None;
return;
}
let mut mentions_v2_token = self.current_mentions_v2_token_range();
let mut file_token = if self.mentions_v2_enabled {
None
} else {
self.current_editable_at_token_range_with_options( false)
};
let browsing_history = self
.history
.should_handle_navigation(&self.current_text(), self.history_navigation_cursor());
if browsing_history {
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
}
self.popups.active = ActivePopup::None;
return;
}
let mut mention_target = self.current_mention_target();
let at_token_start = mentions_v2_token
.as_ref()
.or(file_token.as_ref())
.map(|(range, _)| range.start);
let mention_token_start = mention_target.as_ref().map(|target| target.range.start);
if let (Some(at_token_start), Some(mention_token_start)) =
(at_token_start, mention_token_start)
{
if at_token_start > mention_token_start {
mention_target = None;
} else if mention_token_start > at_token_start {
mentions_v2_token = None;
file_token = None;
}
}
let allow_command_popup = self.slash_commands_enabled()
&& !self.draft.is_bash_mode
&& file_token.is_none()
&& mentions_v2_token.is_none()
&& mention_target.is_none();
self.sync_command_popup(allow_command_popup);
if matches!(self.popups.active, ActivePopup::Command(_)) {
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
}
self.popups.dismissed_file_token = None;
self.popups.dismissed_mention_token = None;
return;
}
if let Some((range, token)) = mentions_v2_token {
self.sync_mentions_v2_popup(range, token);
return;
}
if let Some(target) = mention_target {
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
}
self.sync_mention_popup(target);
return;
}
self.popups.dismissed_mention_token = None;
if let Some((range, token)) = file_token {
self.sync_file_search_popup(range, token);
return;
}
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
}
self.popups.dismissed_file_token = None;
if matches!(
self.popups.active,
ActivePopup::File(_) | ActivePopup::Skill(_) | ActivePopup::MentionV2(_)
) {
self.popups.active = ActivePopup::None;
}
}
fn sync_command_popup(&mut self, allow: bool) {
let text = self.draft.textarea.text();
let first_line_end = text.find('\n').unwrap_or(text.len());
let first_line = &text[..first_line_end];
let command_token = slash_input::command_popup_filter_text(first_line, 0);
if let Some(command_token) = command_token.as_deref()
&& self.popups.dismissed_command_token.as_deref() == Some(command_token)
{
return;
}
self.popups.dismissed_command_token = None;
if !allow {
if matches!(self.popups.active, ActivePopup::Command(_)) {
self.popups.active = ActivePopup::None;
}
return;
}
let cursor = self.draft.textarea.cursor();
let caret_on_first_line = cursor <= first_line_end;
let is_editing_slash_command_name = caret_on_first_line
&& self
.slash_input()
.is_editing_command_name(first_line, cursor);
let command_filter_text = caret_on_first_line
.then(|| slash_input::command_popup_filter_text(first_line, cursor))
.flatten();
if Self::current_at_token(&self.draft.textarea).is_some() {
if matches!(self.popups.active, ActivePopup::Command(_)) {
self.popups.active = ActivePopup::None;
}
return;
}
match &mut self.popups.active {
ActivePopup::Command(popup) => {
if is_editing_slash_command_name {
if let Some(command_filter_text) = command_filter_text.as_deref() {
popup.on_composer_text_change(command_filter_text.to_string());
}
} else {
self.popups.active = ActivePopup::None;
}
}
_ => {
if is_editing_slash_command_name
&& let Some(command_filter_text) = command_filter_text.as_deref()
{
let command_popup = self.slash_input().command_popup(command_filter_text);
self.popups.active = ActivePopup::Command(command_popup);
}
}
}
}
fn sync_file_search_popup(&mut self, range: Range<usize>, query: String) {
if self
.popups
.dismissed_file_token
.as_ref()
.is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query))
{
return;
}
if query.is_empty() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
} else {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
}
match &mut self.popups.active {
ActivePopup::File(popup) => {
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
}
_ => {
let mut popup = FileSearchPopup::new();
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
self.popups.active = ActivePopup::File(popup);
}
}
if query.is_empty() {
self.popups.current_file_query = None;
} else {
self.popups.current_file_query = Some(query);
}
self.popups.dismissed_file_token = None;
}
fn sync_mention_popup(&mut self, target: MentionCompletionTarget) {
let MentionCompletionTarget {
range,
query,
prebuilt_mentions,
} = target;
if self
.popups
.dismissed_mention_token
.as_ref()
.is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query))
{
return;
}
let mentions = prebuilt_mentions.unwrap_or_else(|| self.mention_items());
if mentions.is_empty() {
self.popups.active = ActivePopup::None;
return;
}
match &mut self.popups.active {
ActivePopup::Skill(popup) => {
popup.set_query(&query);
popup.set_mentions(mentions);
}
_ => {
let mut popup = SkillPopup::new(mentions);
popup.set_query(&query);
self.popups.active = ActivePopup::Skill(popup);
}
}
}
fn sync_mentions_v2_popup(&mut self, range: Range<usize>, query: String) {
if self
.popups
.dismissed_mention_token
.as_ref()
.is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query))
{
return;
}
if query.is_empty() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
} else {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
self.popups.current_file_query = Some(query.clone());
}
let candidates = super::mentions_v2::build_search_catalog(
self.skills.as_deref(),
self.plugins.as_deref(),
);
match &mut self.popups.active {
ActivePopup::MentionV2(popup) => {
popup.set_query(&query);
popup.set_candidates(candidates);
}
_ => {
let mut popup = MentionV2Popup::new(candidates);
popup.set_query(&query);
self.popups.active = ActivePopup::MentionV2(popup);
}
}
self.popups.dismissed_mention_token = None;
}
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
if let Some(skills) = self.skills.as_ref() {
for skill in skills {
let display_name = skill_display_name(skill);
let description = skill_description(skill);
let skill_name = skill.name.clone();
let search_terms = if display_name == skill.name {
vec![skill_name.clone()]
} else {
vec![skill_name.clone(), display_name.clone()]
};
mentions.push(MentionItem {
display_name,
description,
insert_text: format!("${skill_name}"),
search_terms,
path: Some(skill.path_to_skills_md.to_string_lossy().into_owned()),
category_tag: Some("[Skill]".to_string()),
sort_rank: 1,
});
}
}
if let Some(plugins) = self.plugins.as_ref() {
for plugin in plugins {
let (plugin_name, marketplace_name) = plugin
.config_name
.split_once('@')
.unwrap_or((plugin.config_name.as_str(), ""));
let mut capability_labels = Vec::new();
if plugin.has_skills {
capability_labels.push("skills".to_string());
}
if !plugin.mcp_server_names.is_empty() {
let mcp_server_count = plugin.mcp_server_names.len();
capability_labels.push(if mcp_server_count == 1 {
"1 MCP server".to_string()
} else {
format!("{mcp_server_count} MCP servers")
});
}
if !plugin.app_connector_ids.is_empty() {
let app_count = plugin.app_connector_ids.len();
capability_labels.push(if app_count == 1 {
"1 app".to_string()
} else {
format!("{app_count} apps")
});
}
let description = plugin.description.clone().or_else(|| {
Some(if capability_labels.is_empty() {
"Plugin".to_string()
} else {
format!("Plugin · {}", capability_labels.join(" · "))
})
});
let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()];
if plugin.display_name != plugin_name {
search_terms.push(plugin.display_name.clone());
}
if !marketplace_name.is_empty() {
search_terms.push(marketplace_name.to_string());
}
mentions.push(MentionItem {
display_name: plugin.display_name.clone(),
description,
insert_text: format!("${plugin_name}"),
search_terms,
path: Some(format!("plugin://{}", plugin.config_name)),
category_tag: Some("[Plugin]".to_string()),
sort_rank: 0,
});
}
}
if self.connectors_enabled
&& let Some(snapshot) = self.connectors_snapshot.as_ref()
{
for connector in &snapshot.connectors {
if !connector.is_accessible || !connector.is_enabled {
continue;
}
let display_name = lemurclaw_core::connectors::metadata::connector_display_label(connector);
let description = Some(Self::connector_brief_description(connector));
let slug = lemurclaw_core::connectors::metadata::connector_mention_slug(connector);
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
let connector_id = connector.id.as_str();
mentions.push(MentionItem {
display_name: display_name.clone(),
description,
insert_text: format!("${slug}"),
search_terms,
path: Some(format!("app://{connector_id}")),
category_tag: Some("[App]".to_string()),
sort_rank: 1,
});
}
}
mentions
}
fn connector_brief_description(connector: &AppInfo) -> String {
Self::connector_description(connector).unwrap_or_default()
}
fn connector_description(connector: &AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn set_has_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
}
#[allow(dead_code)]
pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option<String>) {
self.draft.input_enabled = enabled;
self.draft.input_disabled_placeholder = if enabled { None } else { placeholder };
if !enabled && self.popups.active() {
self.popups.active = ActivePopup::None;
}
}
pub(crate) fn show_shutdown_in_progress(&mut self) {
self.set_input_enabled( false, Some("Shutting down...".to_string()));
self.footer.quit_shortcut_expires_at = None;
self.footer.mode = FooterMode::ComposerEmpty;
self.footer.hint_override = Some(Vec::new());
self.footer.plan_mode_nudge_visible = false;
self.footer.flash = None;
}
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
}
pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) {
self.queue_submissions = queue_submissions;
}
pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) {
if self.footer.context_window_percent == percent
&& self.footer.context_window_used_tokens == used_tokens
{
return;
}
self.footer.context_window_percent = percent;
self.footer.context_window_used_tokens = used_tokens;
}
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
self.footer.esc_backtrack_hint = show;
if show {
self.footer.mode = esc_hint_mode(self.footer.mode, self.is_task_running);
} else {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
}
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) -> bool {
if self.footer.status_line_value == status_line {
return false;
}
self.footer.status_line_value = status_line;
true
}
pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) -> bool {
if self.footer.status_line_hyperlink_url == url {
return false;
}
self.footer.status_line_hyperlink_url = url;
true
}
pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool {
if self.footer.status_line_enabled == enabled {
return false;
}
self.footer.status_line_enabled = enabled;
true
}
pub(crate) fn set_side_conversation_context_label(&mut self, label: Option<String>) -> bool {
if self.footer.side_conversation_context_label == label {
return false;
}
self.footer.side_conversation_context_label = label;
true
}
pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option<String>) -> bool {
if self.footer.active_agent_label == active_agent_label {
return false;
}
self.footer.active_agent_label = active_agent_label;
true
}
}
fn footer_insert_newline_key(
bindings: &[KeyBinding],
enhanced_keys_supported: bool,
) -> Option<KeyBinding> {
let shift_enter = key_hint::shift(KeyCode::Enter);
if enhanced_keys_supported && bindings.contains(&shift_enter) {
return Some(shift_enter);
}
let plain_enter = key_hint::plain(KeyCode::Enter);
bindings
.iter()
.copied()
.find(|binding| *binding != plain_enter)
.or_else(|| bindings.first().copied())
}
fn skill_description(skill: &SkillMetadata) -> Option<String> {
let description = skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
.or(skill.short_description.as_deref())
.unwrap_or(&skill.description);
let trimmed = description.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn ends_plaintext_at_mention(bytes: &[u8], index: usize) -> bool {
bytes.get(index).is_none_or(|byte| {
byte.is_ascii_whitespace()
|| *byte == b'.'
&& bytes.get(index + 1).is_none_or(|next| {
next.is_ascii_whitespace()
|| !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-'
})
|| !matches!(*byte, b'.' | b'/' | b'\\')
&& !byte.is_ascii_alphanumeric()
&& *byte != b'_'
&& *byte != b'-'
})
}
fn ends_plaintext_at_dollar_mention(bytes: &[u8], index: usize) -> bool {
bytes
.get(index)
.is_none_or(|byte| !is_mention_name_char(*byte))
}
fn starts_plaintext_at_mention(text: &str, index: usize) -> bool {
if index == 0 {
return true;
}
text.get(..index)
.and_then(|prefix| prefix.chars().next_back())
.is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch))
}
fn is_mention_name_char_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}
fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option<Range<usize>> {
if token.is_empty() || from >= text.len() {
return None;
}
let bytes = text.as_bytes();
let token_bytes = token.as_bytes();
let sigil = *token_bytes.first()?;
let mut index = from;
while index < bytes.len() {
if bytes[index] != sigil {
index += 1;
continue;
}
let end = index.saturating_add(token_bytes.len());
if end > bytes.len() {
return None;
}
if &bytes[index..end] != token_bytes {
index += 1;
continue;
}
let starts_plaintext_mention = if sigil == b'@' {
starts_plaintext_at_mention(text, index)
} else {
true
};
let ends_plaintext_mention = if sigil == b'@' {
ends_plaintext_at_mention(bytes, end)
} else {
ends_plaintext_at_dollar_mention(bytes, end)
};
if starts_plaintext_mention && ends_plaintext_mention {
return Some(index..end);
}
index = end;
}
None
}
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_textarea_right_reserve(area, 0)
}
fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle {
if self.draft.textarea.uses_vim_insert_cursor() {
crossterm::cursor::SetCursorStyle::SteadyBar
} else {
crossterm::cursor::SetCursorStyle::DefaultUserShape
}
}
fn desired_height(&self, width: u16) -> u16 {
self.desired_height_with_textarea_right_reserve(width, 0)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_with_mask(area, buf, None);
}
}
impl ChatComposer {
pub(crate) fn desired_height_with_textarea_right_reserve(
&self,
width: u16,
textarea_right_reserve: u16,
) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
.unwrap_or_else(|| footer_height(&footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
let inner_width =
width.saturating_sub(COLS_WITH_MARGIN.saturating_add(textarea_right_reserve));
let remote_images_height: u16 = self
.attachments
.remote_image_lines()
.len()
.try_into()
.unwrap_or(u16::MAX);
let remote_images_separator = u16::from(remote_images_height > 0);
self.draft.textarea.desired_height(inner_width)
+ remote_images_height
+ remote_images_separator
+ 2
+ match &self.popups.active {
ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
ActivePopup::Skill(c) => c.calculate_required_height(width),
ActivePopup::MentionV2(c) => c.calculate_required_height(width),
}
}
}
impl ChatComposer {
pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option<char>) {
self.render_with_mask_and_textarea_right_reserve(
area, buf, mask_char, 0,
);
}
pub(crate) fn render_with_mask_and_textarea_right_reserve(
&self,
area: Rect,
buf: &mut Buffer,
mask_char: Option<char>,
textarea_right_reserve: u16,
) {
let [composer_rect, remote_images_rect, textarea_rect, popup_rect] =
self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve);
match &self.popups.active {
ActivePopup::Command(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::File(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::Skill(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::MentionV2(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
let footer_props = self.footer_props();
let show_cycle_hint = !footer_props.is_task_running
&& self.footer.collaboration_mode_indicator.is_some();
let show_shortcuts_hint = match footer_props.mode {
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
FooterMode::ComposerHasDraft => false,
FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let show_queue_hint = match footer_props.mode {
FooterMode::ComposerHasDraft => footer_props.is_task_running,
FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ComposerEmpty
| FooterMode::ShortcutOverlay
| FooterMode::EscHint => false,
};
let custom_height = self.custom_footer_height();
let footer_hint_height =
custom_height.unwrap_or_else(|| footer_height(&footer_props));
let footer_spacing = Self::footer_spacing(footer_hint_height);
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([
Constraint::Length(footer_spacing),
Constraint::Length(footer_hint_height),
])
.areas(popup_rect);
hint_rect
} else {
popup_rect
};
if let Some(line) = self.history_search_footer_line() {
render_footer_line(hint_rect, buf, line);
} else if self.footer.plan_mode_nudge_visible {
let available_width =
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
render_footer_line(
hint_rect,
buf,
truncate_line_with_ellipsis_if_overflow(
plan_mode_nudge_line(),
available_width,
),
);
} else {
let available_width =
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
let status_line_active = uses_passive_footer_status_layout(&footer_props);
let combined_status_line = if status_line_active {
passive_footer_status_line(&footer_props)
} else {
None
};
let mut truncated_status_line = if status_line_active {
combined_status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
})
} else {
None
};
let left_mode_indicator = if status_line_active {
None
} else {
self.footer.collaboration_mode_indicator
};
let active_footer_hint_override = self.footer.hint_override.as_ref();
let mut left_width = if self.footer.flash_visible() {
self.footer
.flash
.as_ref()
.map(|flash| flash.line.width() as u16)
.unwrap_or(0)
} else if let Some(items) = active_footer_hint_override {
footer_hint_items_width(items)
} else if status_line_active {
truncated_status_line
.as_ref()
.map(|line| line.width() as u16)
.unwrap_or(0)
} else {
footer_line_width(
&footer_props,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
)
};
let right_line =
if let Some(label) = self.footer.side_conversation_context_label.as_ref() {
Some(side_conversation_context_line(label))
} else if let Some(line) = self.shell_mode_footer_line() {
Some(line)
} else if status_line_active {
let full = self.mode_indicator_line(show_cycle_hint);
let compact = self.mode_indicator_line( false);
let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0);
if can_show_left_with_context(hint_rect, left_width, full_width) {
full
} else {
compact
}
} else {
Some(self.right_footer_line_with_context())
};
let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0);
if status_line_active
&& let Some(max_left) = max_left_width_for_right(hint_rect, right_width)
&& left_width > max_left
&& let Some(line) = combined_status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize)
})
{
left_width = line.width() as u16;
truncated_status_line = Some(line);
}
let can_show_left_and_context =
can_show_left_with_context(hint_rect, left_width, right_width);
let has_override =
self.footer.flash_visible() || active_footer_hint_override.is_some();
let single_line_layout = if has_override || status_line_active {
None
} else {
match footer_props.mode {
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => {
Some(single_line_footer_layout(
hint_rect,
right_width,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
footer_props.key_hints,
))
}
FooterMode::EscHint
| FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay => None,
}
};
let show_right = if matches!(
footer_props.mode,
FooterMode::EscHint
| FooterMode::HistorySearch
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
) {
false
} else {
single_line_layout
.as_ref()
.map(|(_, show_context)| *show_context)
.unwrap_or(can_show_left_and_context)
};
if let Some((summary_left, _)) = single_line_layout {
match summary_left {
SummaryLeft::Default => {
if status_line_active {
if let Some(line) = truncated_status_line.clone() {
render_footer_line(hint_rect, buf, line);
} else {
render_footer_from_props(
hint_rect,
buf,
&footer_props,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
);
}
} else {
render_footer_from_props(
hint_rect,
buf,
&footer_props,
left_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
);
}
}
SummaryLeft::Custom(line) => {
render_footer_line(hint_rect, buf, line);
}
SummaryLeft::None => {}
}
} else if self.footer.flash_visible() {
if let Some(flash) = self.footer.flash.as_ref() {
flash.line.render(inset_footer_hint_area(hint_rect), buf);
}
} else if let Some(items) = active_footer_hint_override {
render_footer_hint_items(hint_rect, buf, items);
} else if status_line_active {
if let Some(line) = truncated_status_line {
render_footer_line(hint_rect, buf, line);
}
} else {
render_footer_from_props(
hint_rect,
buf,
&footer_props,
self.footer.collaboration_mode_indicator,
show_cycle_hint,
show_shortcuts_hint,
show_queue_hint,
);
}
if show_right && let Some(line) = &right_line {
render_context_right(hint_rect, buf, line);
}
if status_line_active
&& let Some(url) = self.footer.status_line_hyperlink_url.as_deref()
{
mark_underlined_hyperlink(buf, hint_rect, url);
}
}
}
}
let style = user_message_style();
Block::default().style(style).render_ref(composer_rect, buf);
if !remote_images_rect.is_empty() {
Paragraph::new(self.attachments.remote_image_lines())
.style(style)
.render_ref(remote_images_rect, buf);
}
if !textarea_rect.is_empty() {
let prompt = if self.draft.input_enabled {
if self.draft.is_bash_mode {
Span::from("!").light_red().bold()
} else {
"›".bold()
}
} else {
"›".dim()
};
buf.set_span(
textarea_rect.x - LIVE_PREFIX_COLS,
textarea_rect.y,
&prompt,
textarea_rect.width,
);
}
let mut state = self.draft.textarea_state.borrow_mut();
let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode;
if self.draft.input_enabled {
if let Some(mask_char) = mask_char {
self.draft
.textarea
.render_ref_masked(textarea_rect, buf, &mut state, mask_char);
} else {
let mut highlights = self.plugin_at_mention_highlights();
let search_highlight_style =
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
highlights.extend(
self.history_search_highlight_ranges()
.into_iter()
.map(|range| (range, search_highlight_style)),
);
if highlights.is_empty() {
StatefulWidgetRef::render_ref(
&(&self.draft.textarea),
textarea_rect,
buf,
&mut state,
);
} else {
self.draft.textarea.render_ref_styled_with_highlights(
textarea_rect,
buf,
&mut state,
Style::default(),
&highlights,
);
}
}
}
if !self.draft.input_enabled || textarea_is_empty {
let text = if self.draft.input_enabled {
self.placeholder_text.as_str().to_string()
} else {
self.draft
.input_disabled_placeholder
.as_deref()
.unwrap_or("Input disabled.")
.to_string()
};
if !textarea_rect.is_empty() {
let placeholder = Span::from(text).dim();
Line::from(vec![placeholder])
.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
}
}
}
}
#[cfg(test)]
mod tests {
use super::attachment_state::AttachedImage;
use super::*;
use crate::tui_internal::test_support::PathBufExt;
use crate::tui_internal::test_support::test_path_buf;
use image::ImageBuffer;
use image::Rgba;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::tempdir;
use crate::tui_internal::app_event::AppEvent;
use crate::tui_internal::bottom_pane::AppEventSender;
use crate::tui_internal::bottom_pane::ChatComposer;
use crate::tui_internal::bottom_pane::InputResult;
use crate::tui_internal::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::tui_internal::bottom_pane::textarea::TextArea;
use lemurclaw_core::protocol::models::local_image_label_text;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
fn new_test_composer() -> (ChatComposer, UnboundedReceiver<AppEvent>) {
let (tx, rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
(
ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
),
rx,
)
}
#[test]
fn parent_owned_thread_allows_bare_navigation_commands() {
for (command, expected) in [
("/agent", SlashCommand::Agent),
("/side", SlashCommand::Side),
("/btw", SlashCommand::Btw),
("/diff ", SlashCommand::Diff),
] {
let (mut composer, _rx) = new_test_composer();
composer.set_parent_owned_thread();
composer.set_text_content(command.to_string(), Vec::new(), Vec::new());
assert_eq!(
composer.handle_submission( false).0,
InputResult::Command(expected)
);
}
}
#[test]
fn parent_owned_thread_allows_safe_command_selected_from_prefix() {
let (mut composer, _rx) = new_test_composer();
composer.set_parent_owned_thread();
type_chars_humanlike(&mut composer, &['/', 'a', 'g']);
let result = composer
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.0;
assert_eq!(result, InputResult::Command(SlashCommand::Agent));
}
#[test]
fn parent_owned_thread_placeholder_snapshot() {
snapshot_composer_state(
"parent_owned_thread_placeholder",
false,
ChatComposer::set_parent_owned_thread,
);
}
#[test]
fn footer_hint_row_is_separated_from_composer() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let row_to_string = |y: u16| {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
row
};
let mut hint_row: Option<(u16, String)> = None;
for y in 0..area.height {
let row = row_to_string(y);
if row.contains("? for shortcuts") {
hint_row = Some((y, row));
break;
}
}
let (hint_row_idx, hint_row_contents) =
hint_row.expect("expected footer hint row to be rendered");
assert_eq!(
hint_row_idx,
area.height - 1,
"hint row should occupy the bottom line: {hint_row_contents:?}",
);
assert!(
hint_row_idx > 0,
"expected a spacing row above the footer hints",
);
let spacing_row = row_to_string(hint_row_idx - 1);
assert_eq!(
spacing_row.trim(),
"",
"expected blank spacing row above hints but saw: {spacing_row:?}",
);
}
#[test]
fn footer_flash_overrides_footer_hint_override() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())]));
composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10));
let area = Rect::new(0, 0, 60, 6);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let mut bottom_row = String::new();
for x in 0..area.width {
bottom_row.push(
buf[(x, area.height - 1)]
.symbol()
.chars()
.next()
.unwrap_or(' '),
);
}
assert!(
bottom_row.contains("FLASH"),
"expected flash content to render in footer row, saw: {bottom_row:?}",
);
assert!(
!bottom_row.contains("K label"),
"expected flash to override hint override, saw: {bottom_row:?}",
);
}
#[test]
fn footer_flash_expires_and_falls_back_to_hint_override() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())]));
composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10));
composer.footer.flash.as_mut().unwrap().expires_at =
Instant::now() - Duration::from_secs(1);
let area = Rect::new(0, 0, 60, 6);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let mut bottom_row = String::new();
for x in 0..area.width {
bottom_row.push(
buf[(x, area.height - 1)]
.symbol()
.chars()
.next()
.unwrap_or(' '),
);
}
assert!(
bottom_row.contains("K label"),
"expected hint override to render after flash expired, saw: {bottom_row:?}",
);
assert!(
!bottom_row.contains("FLASH"),
"expected expired flash to be hidden, saw: {bottom_row:?}",
);
}
fn snapshot_composer_state_with_width<F>(
name: &str,
width: u16,
enhanced_keys_supported: bool,
setup: F,
) where
F: FnOnce(&mut ChatComposer),
{
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
enhanced_keys_supported,
"Ask Codex to do anything".to_string(),
false,
);
setup(&mut composer);
let footer_props = composer.footer_props();
let footer_lines = footer_height(&footer_props);
let footer_spacing = ChatComposer::footer_spacing(footer_lines);
let height = footer_lines + footer_spacing + 8;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend());
}
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
where
F: FnOnce(&mut ChatComposer),
{
snapshot_composer_state_with_width(
name,
100,
enhanced_keys_supported,
setup,
);
}
#[test]
fn footer_mode_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
snapshot_composer_state(
"footer_mode_shortcut_overlay",
true,
|composer| {
composer.set_esc_backtrack_hint( true);
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_shortcut_overlay_queue_submissions",
true,
|composer| {
composer.set_queue_submissions( true);
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_ctrl_c_quit",
true,
|composer| {
composer.show_quit_shortcut_hint(
key_hint::ctrl(KeyCode::Char('c')),
true,
);
},
);
snapshot_composer_state(
"footer_mode_ctrl_c_interrupt",
true,
|composer| {
composer.set_task_running( true);
composer.show_quit_shortcut_hint(
key_hint::ctrl(KeyCode::Char('c')),
true,
);
},
);
snapshot_composer_state(
"footer_mode_ctrl_c_then_esc_hint",
true,
|composer| {
composer.show_quit_shortcut_hint(
key_hint::ctrl(KeyCode::Char('c')),
true,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_esc_hint_from_overlay",
true,
|composer| {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_esc_hint_backtrack",
true,
|composer| {
composer.set_esc_backtrack_hint( true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_overlay_then_external_esc_hint",
true,
|composer| {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
composer.set_esc_backtrack_hint( true);
},
);
snapshot_composer_state(
"footer_mode_hidden_while_typing",
true,
|composer| {
type_chars_humanlike(composer, &['h']);
},
);
snapshot_composer_state(
"footer_mode_history_search",
true,
|composer| {
composer
.history
.record_local_submission(HistoryEntry::new("cargo test".to_string()));
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_history_search_unavailable",
true,
|composer| {
composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());
composer.begin_history_search();
for ch in ['g', 'i', 't'] {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
composer.apply_history_search_result(HistorySearchResult::Pending);
composer.apply_history_search_result(HistorySearchResult::Unavailable);
},
);
snapshot_composer_state(
"footer_mode_shell_command_absorbs_bang",
true,
|composer| {
composer.set_status_line_enabled( true);
composer.set_status_line(Some(Line::from(
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
)));
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state(
"footer_mode_shell_command_escape_exits_empty_mode",
true,
|composer| {
composer.set_status_line_enabled( true);
composer.set_status_line(Some(Line::from(
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
)));
composer.set_text_content("!".to_string(), Vec::new(), Vec::new());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
},
);
}
#[test]
fn shell_command_cursor_uses_absorbed_prefix() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
let area = Rect::new(0, 0, 40, 5);
composer.set_text_content("!git".to_string(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
assert_eq!(composer.cursor_pos(area), Some((5, 1)));
composer.set_text_content("! git".to_string(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
assert_eq!(composer.cursor_pos(area), Some((6, 1)));
}
#[test]
fn shell_command_uses_shell_accent_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_status_line_enabled( true);
composer.set_status_line(Some(Line::from(
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
)));
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
let area = Rect::new(0, 0, 100, 9);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let prompt_cell = &buf[(0, 1)];
assert_eq!(prompt_cell.symbol(), "!");
assert_eq!(prompt_cell.style().fg, Some(Color::LightRed));
let footer_y = area.height - 1;
let footer_text = (0..area.width)
.map(|x| buf[(x, footer_y)].symbol().chars().next().unwrap_or(' '))
.collect::<String>();
let shell_label_x = footer_text
.find("Shell mode")
.expect("expected shell mode footer label");
assert_eq!(
buf[(shell_label_x as u16, footer_y)].style().fg,
Some(Color::LightRed)
);
}
fn plugin_mention_foreground_color(composer: &ChatComposer) -> Option<Color> {
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let textarea_row = 1;
let row_text = (0..area.width)
.map(|x| {
buf[(x, textarea_row)]
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect::<String>();
let mention_x = row_text
.find("@sample")
.expect("expected plugin mention in composer row");
buf[(mention_x as u16, textarea_row)].style().fg
}
#[test]
fn plugin_at_mentions_use_plugin_accent_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
assert_eq!(
plugin_mention_foreground_color(&composer),
Some(Color::Magenta)
);
}
#[test]
fn plugin_at_mentions_render_with_plugin_accent_snapshot() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let textarea_row = 1;
let mut text = String::new();
let mut magenta = String::new();
for x in 0..area.width {
let cell = &buf[(x, textarea_row)];
text.push(cell.symbol().chars().next().unwrap_or(' '));
magenta.push(if cell.style().fg == Some(Color::Magenta) {
'^'
} else {
' '
});
}
while text.ends_with(' ') {
text.pop();
}
while magenta.ends_with(' ') {
magenta.pop();
}
insta::assert_snapshot!(
"plugin_at_mentions_render_with_plugin_accent",
format!("text: {text}\nmagenta: {magenta}")
);
}
#[test]
fn recalled_plugin_at_mentions_keep_plugin_accent_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
composer.set_text_content(String::new(), Vec::new(), Vec::new());
let (_, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(needs_redraw);
assert_eq!(
plugin_mention_foreground_color(&composer),
Some(Color::Magenta)
);
}
#[test]
fn status_line_hyperlink_marks_pr_number_cells() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
let url = "https://github.com/openai/codex/pull/20252";
composer.set_status_line_enabled( true);
composer.set_status_line(Some(Line::from(Span::styled(
"PR #20252",
Style::default().cyan().underlined(),
))));
composer.set_status_line_hyperlink(Some(url.to_string()));
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let marked_cells = (area.top()..area.bottom())
.flat_map(|y| (area.left()..area.right()).map(move |x| (x, y)))
.filter(|&(x, y)| buf[(x, y)].symbol().contains(url))
.count();
assert_eq!(
marked_cells,
"PR #20252".chars().filter(|ch| !ch.is_whitespace()).count()
);
}
#[test]
fn esc_exits_empty_shell_mode() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['!']);
assert!(composer.draft.is_bash_mode);
assert_eq!(composer.current_text(), "!");
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert!(needs_redraw);
assert!(!composer.draft.is_bash_mode);
assert_eq!(composer.current_text(), "");
}
#[test]
fn esc_keeps_shell_mode_when_paste_burst_flushes_pending_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['!']);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert_eq!(composer.current_text(), "!");
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert!(needs_redraw);
assert!(composer.draft.is_bash_mode);
assert_eq!(composer.current_text(), "!g");
}
#[test]
fn footer_collapse_snapshots() {
fn setup_collab_footer(
composer: &mut ChatComposer,
context_percent: i64,
indicator: Option<CollaborationModeIndicator>,
) {
composer.set_collaboration_modes_enabled( true);
composer.set_collaboration_mode_indicator(indicator);
composer.set_context_window(Some(context_percent), None);
}
snapshot_composer_state_with_width(
"footer_collapse_empty_full",
120,
true,
|composer| {
setup_collab_footer(
composer, 100, None,
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_empty_mode_cycle_with_context",
60,
true,
|composer| {
setup_collab_footer(
composer, 100, None,
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_empty_mode_cycle_without_context",
44,
true,
|composer| {
setup_collab_footer(
composer, 100, None,
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_empty_mode_only",
26,
true,
|composer| {
setup_collab_footer(
composer, 100, None,
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_empty_full",
120,
true,
|composer| {
setup_collab_footer(
composer,
100,
Some(CollaborationModeIndicator::Plan),
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_empty_mode_cycle_with_context",
60,
true,
|composer| {
setup_collab_footer(
composer,
100,
Some(CollaborationModeIndicator::Plan),
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_empty_mode_cycle_without_context",
44,
true,
|composer| {
setup_collab_footer(
composer,
100,
Some(CollaborationModeIndicator::Plan),
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_empty_mode_only",
26,
true,
|composer| {
setup_collab_footer(
composer,
100,
Some(CollaborationModeIndicator::Plan),
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_full",
120,
true,
|composer| {
setup_collab_footer(
composer, 98, None,
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_short_with_context",
50,
true,
|composer| {
setup_collab_footer(
composer, 98, None,
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_message_without_context",
40,
true,
|composer| {
setup_collab_footer(
composer, 98, None,
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_short_without_context",
30,
true,
|composer| {
setup_collab_footer(
composer, 98, None,
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_mode_only",
20,
true,
|composer| {
setup_collab_footer(
composer, 98, None,
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_queue_full",
120,
true,
|composer| {
setup_collab_footer(
composer,
98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_queue_short_with_context",
50,
true,
|composer| {
setup_collab_footer(
composer,
98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_queue_message_without_context",
40,
true,
|composer| {
setup_collab_footer(
composer,
98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_queue_short_without_context",
30,
true,
|composer| {
setup_collab_footer(
composer,
98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_queue_mode_only",
20,
true,
|composer| {
setup_collab_footer(
composer,
98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running( true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
}
#[test]
fn esc_hint_stays_hidden_with_draft_content() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['d']);
assert!(!composer.is_empty());
assert_eq!(composer.current_text(), "d");
assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty);
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty);
assert!(!composer.footer.esc_backtrack_hint);
}
#[test]
fn empty_vim_insert_escape_enters_normal_without_esc_hint() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_vim_enabled( true);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert!(composer.is_empty());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Insert".green())
);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert!(needs_redraw);
assert!(composer.is_empty());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty);
assert!(!composer.footer.esc_backtrack_hint);
}
#[test]
fn slash_opens_command_popup_in_vim_normal_mode() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
true,
);
composer.set_vim_enabled( true);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert!(needs_redraw);
assert_eq!(composer.draft.textarea.text(), "/");
assert_eq!(composer.draft.textarea.cursor(), "/".len());
assert!(matches!(composer.popups.active, ActivePopup::Command(_)));
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Insert".green())
);
}
#[test]
fn slash_command_can_be_typed_and_dispatched_after_vim_normal_slash() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
true,
);
composer.set_vim_enabled( true);
for ch in ['/', 'd', 'i', 'f', 'f'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(composer.draft.textarea.text(), "/diff");
assert!(matches!(composer.popups.active, ActivePopup::Command(_)));
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(needs_redraw);
assert!(composer.is_empty());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
assert!(matches!(result, InputResult::Command(SlashCommand::Diff)));
}
#[test]
fn inline_slash_command_dispatch_resets_vim_mode_to_normal() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
true,
);
composer.set_collaboration_modes_enabled( true);
composer.set_vim_enabled( true);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new());
composer.popups.active = ActivePopup::None;
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(needs_redraw);
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
assert_eq!(cmd, SlashCommand::Plan);
assert_eq!(args, "investigate this");
assert!(text_elements.is_empty());
}
_ => panic!("expected CommandWithArgs"),
}
}
#[test]
fn bang_enters_shell_mode_in_vim_normal_mode() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
true,
);
composer.set_vim_enabled( true);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert!(needs_redraw);
assert!(composer.draft.is_bash_mode);
assert_eq!(composer.current_text(), "!");
assert_eq!(composer.draft.textarea.text(), "");
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Insert".green())
);
}
#[test]
fn shell_command_can_be_typed_after_vim_normal_bang() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
true,
);
composer.set_vim_enabled( true);
for ch in ['!', 'e', 'c', 'h', 'o'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(composer.draft.is_bash_mode);
assert_eq!(composer.current_text(), "!echo");
assert_eq!(composer.draft.textarea.text(), "echo");
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn base_footer_mode_tracks_empty_state_after_quit_hint_expires() {
use crossterm::event::KeyCode;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['d']);
composer
.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
composer.footer.quit_shortcut_expires_at =
Some(Instant::now() - std::time::Duration::from_secs(1));
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
composer.set_text_content(String::new(), Vec::new(), Vec::new());
assert_eq!(composer.footer_mode(), FooterMode::ComposerEmpty);
}
#[test]
fn clear_for_ctrl_c_records_cleared_draft() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new());
assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string()));
assert!(composer.is_empty());
assert_eq!(
composer.history.navigate_up(&composer.app_event_tx),
Some(HistoryEntry::new("draft text".to_string()))
);
}
#[test]
fn clear_for_ctrl_c_preserves_pending_paste_history_entry() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
composer.handle_paste(large.clone());
let char_count = large.chars().count();
let placeholder = format!("[Pasted Content {char_count} chars]");
assert_eq!(composer.draft.textarea.text(), placeholder);
assert_eq!(
composer.draft.pending_pastes,
vec![(placeholder.clone(), large.clone())]
);
composer.clear_for_ctrl_c();
assert!(composer.is_empty());
let history_entry = composer
.history
.navigate_up(&composer.app_event_tx)
.expect("expected history entry");
let text_elements = vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.clone()),
)];
assert_eq!(
history_entry,
HistoryEntry::with_pending(
placeholder.clone(),
text_elements,
Vec::new(),
vec![(placeholder.clone(), large.clone())]
)
);
composer.apply_history_entry(history_entry);
assert_eq!(composer.draft.textarea.text(), placeholder);
assert_eq!(
composer.draft.pending_pastes,
vec![(placeholder.clone(), large)]
);
assert_eq!(
composer.draft.textarea.element_payloads(),
vec![placeholder]
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
assert_eq!(text, "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5));
assert!(text_elements.is_empty());
}
_ => panic!("expected Submitted"),
}
}
#[test]
fn large_paste_numbering_reuses_after_ctrl_c_clear() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let base = format!("[Pasted Content {} chars]", paste.chars().count());
composer.handle_paste(paste.clone());
assert_eq!(composer.draft.textarea.text(), base);
assert_eq!(composer.draft.pending_pastes.len(), 1);
assert_eq!(composer.clear_for_ctrl_c(), Some(base.clone()));
assert!(composer.draft.textarea.text().is_empty());
assert!(composer.draft.pending_pastes.is_empty());
composer.handle_paste(paste);
assert_eq!(composer.draft.textarea.text(), base);
assert_eq!(composer.draft.pending_pastes.len(), 1);
assert_eq!(composer.draft.pending_pastes[0].0, base);
}
#[test]
fn vim_mode_resets_to_normal_after_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled( true);
composer.set_vim_enabled( true);
assert!(composer.draft.textarea.is_vim_enabled());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
composer.set_text_content("h".to_string(), Vec::new(), Vec::new());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(composer.draft.textarea.is_vim_enabled());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
assert!(composer.is_empty());
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, "h"),
_ => panic!("expected Submitted"),
}
}
#[test]
fn vim_mode_resets_to_normal_after_queued_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled( true);
composer.set_task_running( true);
composer.set_vim_enabled( true);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
composer.set_text_content("queued".to_string(), Vec::new(), Vec::new());
let (result, _) = composer.handle_submission( true);
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
assert!(composer.is_empty());
match result {
InputResult::Queued { text, .. } => assert_eq!(text, "queued"),
_ => panic!("expected Queued"),
}
}
#[test]
fn vim_mode_stays_insert_after_suppressed_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled( true);
composer.set_vim_enabled( true);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
composer.set_text_content("/not-a-command".to_string(), Vec::new(), Vec::new());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert_eq!(composer.draft.textarea.text(), "/not-a-command");
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Insert".green())
);
}
#[test]
fn esc_switches_vim_insert_to_normal() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_vim_enabled( true);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
composer.set_text_content("hey".to_string(), Vec::new(), Vec::new());
composer
.draft
.textarea
.set_cursor(composer.draft.textarea.text().len());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Insert".green())
);
assert_eq!(composer.draft.textarea.cursor(), "hey".len());
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
assert_eq!(composer.draft.textarea.cursor(), "he".len());
}
#[test]
fn vim_insert_uses_bar_cursor_style() {
use crate::tui_internal::render::renderable::Renderable;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use crossterm::queue;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
let area = Rect::new(0, 0, 80, 10);
let style_output = |style| {
let mut output = Vec::new();
queue!(output, style).expect("queue cursor style");
output
};
let default = style_output(SetCursorStyle::DefaultUserShape);
let steady_bar = style_output(SetCursorStyle::SteadyBar);
assert_eq!(style_output(composer.cursor_style(area)), default,);
composer.set_vim_enabled( true);
assert_eq!(style_output(composer.cursor_style(area)), default,);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
composer.set_text_content("hey".to_string(), Vec::new(), Vec::new());
assert_eq!(style_output(composer.cursor_style(area)), steady_bar);
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(style_output(composer.cursor_style(area)), default,);
}
#[test]
fn clear_for_ctrl_c_preserves_image_draft_state() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("example.png");
composer.attach_image(path.clone());
let placeholder = local_image_label_text( 1);
composer.clear_for_ctrl_c();
assert!(composer.is_empty());
let history_entry = composer
.history
.navigate_up(&composer.app_event_tx)
.expect("expected history entry");
let text_elements = vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.clone()),
)];
assert_eq!(
history_entry,
HistoryEntry::with_pending(
placeholder.clone(),
text_elements,
vec![path.clone()],
Vec::new()
)
);
composer.apply_history_entry(history_entry);
assert_eq!(composer.draft.textarea.text(), placeholder);
assert_eq!(composer.local_image_paths(), vec![path]);
assert_eq!(
composer.draft.textarea.element_payloads(),
vec![placeholder]
);
}
#[test]
fn clear_for_ctrl_c_preserves_remote_offset_image_labels() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let remote_image_url = "https://example.com/one.png".to_string();
composer.set_remote_image_urls(vec![remote_image_url.clone()]);
let text = "[Image #2] draft".to_string();
let text_elements = vec![TextElement::new(
(0.."[Image #2]".len()).into(),
Some("[Image #2]".to_string()),
)];
let local_image_path = PathBuf::from("/tmp/local-draft.png");
composer.set_text_content(text, text_elements, vec![local_image_path.clone()]);
let expected_text = composer.current_text();
let expected_elements = composer.text_elements();
assert_eq!(expected_text, "[Image #2] draft");
assert_eq!(
expected_elements[0].placeholder(&expected_text),
Some("[Image #2]")
);
assert_eq!(composer.clear_for_ctrl_c(), Some(expected_text.clone()));
assert_eq!(
composer.history.navigate_up(&composer.app_event_tx),
Some(HistoryEntry::with_pending_and_remote(
expected_text,
expected_elements,
vec![local_image_path],
Vec::new(),
vec![remote_image_url],
))
);
}
#[test]
fn apply_history_entry_preserves_local_placeholders_after_remote_prefix() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let remote_image_url = "https://example.com/one.png".to_string();
let local_image_path = PathBuf::from("/tmp/local-draft.png");
composer.apply_history_entry(HistoryEntry::with_pending_and_remote(
"[Image #2] draft".to_string(),
vec![TextElement::new(
(0.."[Image #2]".len()).into(),
Some("[Image #2]".to_string()),
)],
vec![local_image_path.clone()],
Vec::new(),
vec![remote_image_url.clone()],
));
let restored_text = composer.current_text();
assert_eq!(restored_text, "[Image #2] draft");
let restored_elements = composer.text_elements();
assert_eq!(restored_elements.len(), 1);
assert_eq!(
restored_elements[0].placeholder(&restored_text),
Some("[Image #2]")
);
assert_eq!(composer.local_image_paths(), vec![local_image_path]);
assert_eq!(composer.remote_image_urls(), vec![remote_image_url]);
}
#[test]
fn question_mark_only_toggles_on_first_char() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(needs_redraw, "toggling overlay should request redraw");
assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty);
type_chars_humanlike(&mut composer, &['h']);
assert_eq!(composer.draft.textarea.text(), "h");
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(needs_redraw, "typing should still mark the view dirty");
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.draft.textarea.text(), "h?");
assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty);
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
}
#[test]
fn shift_question_mark_toggles_shortcut_overlay_when_empty() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT));
assert_eq!(result, InputResult::None);
assert!(needs_redraw, "toggling overlay should request redraw");
assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay);
}
#[test]
fn question_mark_does_not_toggle_during_paste_burst() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
for ch in ['h', 'i', '?', 't', 'h', 'e', 'r', 'e'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(composer.is_in_paste_burst());
assert_eq!(composer.draft.textarea.text(), "");
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.draft.textarea.text(), "hi?there");
assert_ne!(composer.footer.mode, FooterMode::ShortcutOverlay);
}
#[test]
fn set_connector_mentions_refreshes_open_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_connectors_enabled( true);
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
assert!(matches!(composer.popups.active, ActivePopup::None));
let connectors = vec![AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected mention popup to open after connectors update");
};
let mention = popup
.selected_mention()
.expect("expected connector mention to be selected");
assert_eq!(mention.insert_text, "$notion".to_string());
assert_eq!(mention.path, Some("app://connector_1".to_string()));
}
#[test]
fn set_connector_mentions_skips_disabled_connectors() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_connectors_enabled( true);
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
assert!(matches!(composer.popups.active, ActivePopup::None));
let connectors = vec![AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
assert!(
matches!(composer.popups.active, ActivePopup::None),
"disabled connectors should not appear in the mention popup"
);
}
#[test]
fn set_plugin_mentions_refreshes_open_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
assert!(matches!(composer.popups.active, ActivePopup::None));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "Sample Plugin".to_string(),
description: None,
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
}]));
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected mention popup to open after plugin update");
};
let mention = popup
.selected_mention()
.expect("expected plugin mention to be selected");
assert_eq!(mention.insert_text, "$sample".to_string());
assert_eq!(mention.path, Some("plugin://sample@test".to_string()));
}
fn test_skill_metadata(name: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
description: "Example skill used in tests.".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(),
scope: crate::tui_internal::test_support::skill_scope_user(),
plugin_id: None,
}
}
fn test_skill_binding(name: &str) -> MentionBinding {
MentionBinding {
sigil: '$',
mention: name.to_string(),
path: test_path_buf(&format!("/tmp/{name}/SKILL.md"))
.abs()
.display()
.to_string(),
}
}
fn test_plugin_binding(name: &str) -> MentionBinding {
MentionBinding {
sigil: '@',
mention: name.to_string(),
path: format!("plugin://{name}@test"),
}
}
fn test_plugin_summary(name: &str, description: &str) -> PluginCapabilitySummary {
PluginCapabilitySummary {
config_name: format!("{name}@test"),
display_name: name.to_string(),
description: Some(description.to_string()),
has_skills: false,
mcp_server_names: vec![name.to_string()],
app_connector_ids: Vec::new(),
}
}
fn configure_partially_bound_skill_mentions(composer: &mut ChatComposer) {
composer.set_skill_mentions(Some(vec![test_skill_metadata("unbound-skill")]));
composer.set_text_content_with_mention_bindings(
"$unbound-skill $bound-skill continue".to_string(),
Vec::new(),
Vec::new(),
vec![test_skill_binding("bound-skill")],
);
composer.draft.textarea.set_cursor("$unbound-skill ".len());
composer.sync_popups();
}
fn configure_bound_skill_left_of_unbound_skill(composer: &mut ChatComposer) {
composer.set_skill_mentions(Some(vec![test_skill_metadata("unbound-skill")]));
composer.set_text_content_with_mention_bindings(
"$bound-skill$unbound-skill".to_string(),
Vec::new(),
Vec::new(),
vec![test_skill_binding("bound-skill")],
);
composer.draft.textarea.set_cursor("$bound-skill".len());
composer.sync_popups();
}
fn configure_skill_target_between_bound_mentions(composer: &mut ChatComposer) {
composer.set_skill_mentions(Some(vec![test_skill_metadata("other")]));
composer.set_text_content_with_mention_bindings(
"$bound1 $oth $bound2".to_string(),
Vec::new(),
Vec::new(),
vec![test_skill_binding("bound1"), test_skill_binding("bound2")],
);
composer
.draft
.textarea
.replace_range("$bound1".len().."$bound1 ".len(), "");
composer
.draft
.textarea
.replace_range("$bound1$oth".len().."$bound1$oth ".len(), "");
composer.draft.textarea.set_cursor("$bound1$o".len());
composer.sync_popups();
}
fn configure_skill_mention_with_trailing_space(composer: &mut ChatComposer) {
composer.set_skill_mentions(Some(vec![test_skill_metadata("figma")]));
let text = "$figma ";
composer.set_text_content(text.to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor(text.len());
composer.sync_popups();
}
fn configure_bound_plugin_left_of_unbound_plugin(composer: &mut ChatComposer) {
composer.set_mentions_v2_enabled( true);
composer.set_plugin_mentions(Some(vec![test_plugin_summary(
"other",
"Plugin used to test adjacent mention targeting.",
)]));
composer.set_text_content_with_mention_bindings(
"@sample@other".to_string(),
Vec::new(),
Vec::new(),
vec![test_plugin_binding("sample")],
);
composer.draft.textarea.set_cursor("@sample".len());
composer.sync_popups();
}
fn configure_unbound_plugin_left_of_bound_plugin(composer: &mut ChatComposer) {
composer.set_mentions_v2_enabled( true);
composer.set_plugin_mentions(Some(vec![test_plugin_summary(
"left",
"Plugin used to test bound mention fallback.",
)]));
composer.set_text_content_with_mention_bindings(
"@left @bound".to_string(),
Vec::new(),
Vec::new(),
vec![test_plugin_binding("bound")],
);
composer.draft.textarea.set_cursor("@left ".len());
composer.sync_popups();
}
fn configure_plugin_target_between_bound_mentions(composer: &mut ChatComposer) {
composer.set_mentions_v2_enabled( true);
composer.set_plugin_mentions(Some(vec![test_plugin_summary(
"other",
"Plugin used to test middle mention targeting.",
)]));
composer.set_text_content_with_mention_bindings(
"@bound1 @oth @bound2".to_string(),
Vec::new(),
Vec::new(),
vec![test_plugin_binding("bound1"), test_plugin_binding("bound2")],
);
composer
.draft
.textarea
.replace_range("@bound1".len().."@bound1 ".len(), "");
composer
.draft
.textarea
.replace_range("@bound1@oth".len().."@bound1@oth ".len(), "");
composer.draft.textarea.set_cursor("@bound1@o".len());
composer.sync_popups();
}
#[test]
fn skill_popup_targets_unbound_mention_left_of_bound_mention() {
let (mut composer, _rx) = new_test_composer();
configure_partially_bound_skill_mentions(&mut composer);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
composer.current_text(),
"$unbound-skill $bound-skill continue"
);
assert_eq!(
composer.mention_bindings(),
vec![
test_skill_binding("unbound-skill"),
test_skill_binding("bound-skill"),
]
);
composer.insert_str("foo");
assert_eq!(
composer.current_text(),
"$unbound-skill foo $bound-skill continue"
);
}
#[test]
fn skill_popup_targets_unbound_mention_left_of_bound_mention_snapshot() {
snapshot_composer_state(
"skill_popup_targets_unbound_mention_left_of_bound_mention",
false,
configure_partially_bound_skill_mentions,
);
}
#[test]
fn skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention_snapshot() {
snapshot_composer_state(
"skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention",
false,
configure_bound_skill_left_of_unbound_skill,
);
}
#[test]
fn skill_popup_falls_back_from_bound_skill_with_path_suffix_snapshot() {
snapshot_composer_state(
"skill_popup_falls_back_from_bound_skill_with_path_suffix",
false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("left")]));
composer.set_text_content_with_mention_bindings(
"$left $bound/path".to_string(),
Vec::new(),
Vec::new(),
vec![test_skill_binding("bound")],
);
composer.draft.textarea.set_cursor("$left ".len());
composer.sync_popups();
},
);
}
#[test]
fn skill_popup_closes_after_trailing_space_snapshot() {
snapshot_composer_state(
"skill_popup_closes_after_trailing_space",
false,
configure_skill_mention_with_trailing_space,
);
}
#[test]
fn unified_mention_popup_falls_back_from_bound_plugin_on_right_snapshot() {
snapshot_composer_state(
"unified_mention_popup_falls_back_from_bound_plugin_on_right",
false,
configure_unbound_plugin_left_of_bound_plugin,
);
}
#[test]
fn adjacent_plugin_completion_inserts_separator_snapshot() {
snapshot_composer_state(
"adjacent_plugin_completion_inserts_separator",
false,
|composer| {
configure_bound_plugin_left_of_unbound_plugin(composer);
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
},
);
}
#[test]
fn skill_completion_replaces_only_middle_adjacent_target() {
let (mut composer, _rx) = new_test_composer();
configure_skill_target_between_bound_mentions(&mut composer);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "$bound1 $other $bound2");
assert_eq!(
composer.mention_bindings(),
vec![
test_skill_binding("bound1"),
test_skill_binding("other"),
test_skill_binding("bound2"),
]
);
}
#[test]
fn unified_completion_replaces_only_middle_adjacent_target() {
let (mut composer, _rx) = new_test_composer();
configure_plugin_target_between_bound_mentions(&mut composer);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "@bound1 @other @bound2");
assert_eq!(
composer.mention_bindings(),
vec![
test_plugin_binding("bound1"),
test_plugin_binding("other"),
test_plugin_binding("bound2"),
]
);
}
#[test]
fn skill_popup_falls_back_from_shell_variable_to_skill_on_right() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")]));
composer.set_text_content("$HOME $rustdoc".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$HOME ".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected skill popup to fall back to the right token");
};
assert_eq!(
popup
.selected_mention()
.expect("expected rustdoc selection")
.insert_text,
"$rustdoc"
);
}
#[test]
fn file_popup_ignores_shell_positional_parameter_snapshot() {
snapshot_composer_state(
"file_popup_ignores_shell_positional_parameter",
false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("available")]));
composer.set_text_content("@src $1_suffix".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
},
);
}
#[test]
fn file_popup_ignores_bare_shell_parameter_with_matching_skill_snapshot() {
snapshot_composer_state(
"file_popup_ignores_bare_shell_parameter_with_matching_skill",
false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("_tool")]));
composer.set_text_content("@src $_".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
},
);
}
#[test]
fn file_popup_ignores_definite_shell_parameters_with_matching_skills() {
for (query, skill_name) in [("12", "12factor"), ("-", "-tool")] {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata(skill_name)]));
composer.set_text_content(format!("@src ${query}"), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
}
}
#[test]
fn skill_popup_accepts_loaded_hyphen_leading_skill_name() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("-tool")]));
composer.set_text_content("$-t".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$-t".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected skill popup for -tool");
};
assert_eq!(
popup
.selected_mention()
.expect("expected -tool selection")
.insert_text,
"$-tool"
);
}
#[test]
fn file_popup_ignores_unbindable_qualified_skill_snapshot() {
snapshot_composer_state(
"file_popup_ignores_unbindable_qualified_skill",
false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("1plugin:deploy")]));
composer.set_text_content("@src $1plugin:d".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
},
);
}
#[test]
fn skill_popup_preserves_normal_target_after_ambiguous_probe_snapshot() {
snapshot_composer_state(
"skill_popup_preserves_normal_target_after_ambiguous_probe",
false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("plugin:deploy")]));
composer.set_text_content("$1x $plugin:d".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$1x ".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected the right qualified skill popup");
};
assert_eq!(
popup
.selected_mention()
.expect("expected qualified skill selection")
.insert_text,
"$plugin:deploy"
);
},
);
}
#[test]
fn mention_target_prebuilds_catalog_only_for_ambiguous_query() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("1password")]));
composer.set_text_content("$normal".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$normal".len());
let normal_target = composer
.current_mention_target()
.expect("expected normal mention target");
assert!(normal_target.prebuilt_mentions.is_none());
composer.set_text_content("$1p".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$1p".len());
let ambiguous_target = composer
.current_mention_target()
.expect("expected ambiguous mention target");
assert_eq!(
ambiguous_target.prebuilt_mentions.map(|mentions| {
mentions
.into_iter()
.map(|mention| mention.display_name)
.collect::<Vec<_>>()
}),
Some(vec!["1password".to_string()])
);
}
#[test]
fn skill_popup_accepts_digit_leading_skill_snapshot() {
snapshot_composer_state(
"skill_popup_accepts_digit_leading_skill",
false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("1password")]));
composer.set_text_content("$1p".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$1p".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::Skill(_)));
},
);
}
#[test]
fn skill_popup_does_not_fuzzy_match_shell_variable_snapshot() {
snapshot_composer_state(
"skill_popup_does_not_fuzzy_match_shell_variable",
false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("home")]));
composer.set_text_content("$HOME".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$HOME".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::None));
},
);
}
#[test]
fn skill_popup_preserves_loaded_shell_like_skill_at_separator() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![
test_skill_metadata("1password"),
test_skill_metadata("rustdoc"),
]));
composer.set_text_content("$1p $rustdoc".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$1p ".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected the left shell-like skill popup");
};
assert_eq!(
popup
.selected_mention()
.expect("expected 1password selection")
.insert_text,
"$1password"
);
}
#[test]
fn skill_popup_accepts_lowercase_skill_named_like_env_var() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("home")]));
composer.set_text_content("$home".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$home".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected skill popup for lowercase home skill");
};
assert_eq!(
popup
.selected_mention()
.expect("expected home skill selection")
.insert_text,
"$home"
);
}
#[test]
fn legacy_file_popup_falls_back_when_right_skill_is_unavailable() {
let (mut composer, _rx) = new_test_composer();
composer.set_text_content("@src $missing".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
}
#[test]
fn unified_popup_falls_back_when_right_skill_is_unavailable() {
let (mut composer, _rx) = new_test_composer();
composer.set_mentions_v2_enabled( true);
composer.set_text_content("@src $missing".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
}
#[test]
fn skill_popup_falls_back_when_right_at_mention_is_bound() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("old")]));
composer.set_text_content_with_mention_bindings(
"$old @bound".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "bound".to_string(),
path: "plugin://bound@test".to_string(),
}],
);
composer.draft.textarea.set_cursor("$old ".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected the left skill popup");
};
assert_eq!(
popup
.selected_mention()
.expect("expected old skill selection")
.insert_text,
"$old"
);
}
#[test]
fn legacy_popup_prefers_right_at_token_over_left_skill() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("old")]));
composer.set_text_content("$old @new".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$old ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
}
#[test]
fn unified_popup_prefers_right_skill_over_left_at_token() {
let (mut composer, _rx) = new_test_composer();
composer.set_mentions_v2_enabled( true);
composer.set_skill_mentions(Some(vec![test_skill_metadata("new")]));
composer.set_text_content("@old $new".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@old ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::Skill(_)));
}
#[test]
fn skill_popup_closes_at_plain_text_after_whitespace() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("old")]));
composer.set_text_content("$old word".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$old ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn skill_popup_closes_between_spaces_before_plain_text_snapshot() {
snapshot_composer_state(
"skill_popup_closes_between_spaces_before_plain_text",
false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("old")]));
composer.set_text_content("$old word".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$old ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::None));
},
);
}
fn assert_typed_skill_prefix_starts_new_mention(inserted: &str) {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")]));
composer.set_text_content("$simplify-code".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor( 0);
composer.insert_str(inserted);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "$rustdoc $simplify-code");
assert_eq!(
composer.mention_bindings(),
vec![test_skill_binding("rustdoc")]
);
}
#[test]
fn typing_empty_skill_prefix_before_existing_skill_starts_new_mention() {
assert_typed_skill_prefix_starts_new_mention("$");
}
#[test]
fn typing_partial_skill_prefix_before_existing_skill_starts_new_mention() {
assert_typed_skill_prefix_starts_new_mention("$r");
}
#[test]
fn set_skill_mentions_refreshes_open_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
assert!(matches!(composer.popups.active, ActivePopup::None));
let skill_path = test_path_buf("/tmp/skill/SKILL.md").abs();
composer.set_skill_mentions(Some(vec![SkillMetadata {
name: "codex".to_string(),
description: "Primary personal Codex repo skill.".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: skill_path.clone(),
scope: crate::tui_internal::test_support::skill_scope_user(),
plugin_id: None,
}]));
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected mention popup to open after skills update");
};
let mention = popup
.selected_mention()
.expect("expected skill mention to be selected");
assert_eq!(mention.insert_text, "$codex".to_string());
assert_eq!(mention.path, Some(skill_path.display().to_string()));
}
#[test]
fn mention_items_show_plugin_owned_skill_and_app_duplicates() {
let skill_path = test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs();
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_connectors_enabled( true);
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
composer.set_skill_mentions(Some(vec![SkillMetadata {
name: "google-calendar:availability".to_string(),
description: "Find availability and plan event changes".to_string(),
short_description: None,
interface: Some(SkillInterface {
display_name: Some("Google Calendar".to_string()),
short_description: None,
icon_small: None,
icon_large: None,
brand_color: None,
default_prompt: None,
}),
dependencies: None,
policy: None,
path_to_skills_md: skill_path.clone(),
scope: crate::tui_internal::test_support::skill_scope_repo(),
plugin_id: None,
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),
display_name: "Google Calendar".to_string(),
description: Some(
"Connect Google Calendar for scheduling, availability, and event management."
.to_string(),
),
has_skills: true,
mcp_server_names: vec!["google-calendar".to_string()],
app_connector_ids: vec![AppConnectorId("google_calendar".to_string())],
}]));
composer.set_connector_mentions(Some(ConnectorsSnapshot {
connectors: vec![AppInfo {
id: "google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Look up events and availability".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: vec!["Google Calendar".to_string()],
}],
}));
let mentions = composer.mention_items();
assert_eq!(mentions.len(), 3);
assert_eq!(mentions[0].category_tag, Some("[Skill]".to_string()));
assert_eq!(mentions[0].path, Some(skill_path.display().to_string()));
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
assert_eq!(mentions[1].category_tag, Some("[Plugin]".to_string()));
assert_eq!(
mentions[1].path,
Some("plugin://google-calendar@debug".to_string())
);
assert_eq!(mentions[2].category_tag, Some("[App]".to_string()));
assert_eq!(mentions[2].path, Some("app://google_calendar".to_string()));
}
#[test]
fn plugin_mention_popup_snapshot() {
snapshot_composer_state(
"plugin_mention_popup",
false,
|composer| {
composer.set_text_content("$sa".to_string(), Vec::new(), Vec::new());
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "Sample Plugin".to_string(),
description: Some(
"Plugin that includes the Figma MCP server and Skills for common workflows"
.to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: vec![AppConnectorId("calendar".to_string())],
}]));
},
);
}
#[test]
fn default_unified_mention_popup_snapshot() {
snapshot_composer_state(
"default_unified_mention_popup",
false,
|composer| {
let features = lemurclaw_core::features::Features::with_defaults();
composer
.set_mentions_v2_enabled(features.enabled(lemurclaw_core::features::Feature::MentionsV2));
composer.set_text_content("@sa".to_string(), Vec::new(), Vec::new());
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "Sample Plugin".to_string(),
description: Some("Plugin with skills and an MCP server".to_string()),
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
}]));
},
);
}
#[test]
fn mention_popup_type_prefixes_snapshot() {
snapshot_composer_state_with_width(
"mention_popup_type_prefixes",
72,
false,
|composer| {
composer.set_connectors_enabled( true);
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
composer.set_skill_mentions(Some(vec![SkillMetadata {
name: "google-calendar-skill".to_string(),
description: "Find availability and plan event changes".to_string(),
short_description: None,
interface: Some(SkillInterface {
display_name: Some("Google Calendar".to_string()),
short_description: None,
icon_small: None,
icon_large: None,
brand_color: None,
default_prompt: None,
}),
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs(),
scope: crate::tui_internal::test_support::skill_scope_repo(),
plugin_id: None,
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),
display_name: "Google Calendar".to_string(),
description: Some(
"Connect Google Calendar for scheduling, availability, and event management."
.to_string(),
),
has_skills: false,
mcp_server_names: vec!["google-calendar".to_string()],
app_connector_ids: Vec::new(),
}]));
composer.set_connector_mentions(Some(ConnectorsSnapshot {
connectors: vec![AppInfo {
id: "google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Look up events and availability".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}],
}));
},
);
}
#[test]
fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_connectors_enabled( true);
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
let connectors = vec![AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn shortcut_overlay_persists_while_task_running() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay);
composer.set_task_running( true);
assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay);
assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay);
}
#[test]
fn test_current_at_token_basic_cases() {
let test_cases = vec![
("@hello", 3, Some("hello".to_string()), "Basic ASCII token"),
(
"@file.txt",
4,
Some("file.txt".to_string()),
"ASCII with extension",
),
(
"hello @world test",
8,
Some("world".to_string()),
"ASCII token in middle",
),
(
"@test123",
5,
Some("test123".to_string()),
"ASCII with numbers",
),
("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"),
(
"@testЙЦУ.rs",
8,
Some("testЙЦУ.rs".to_string()),
"Mixed ASCII and Cyrillic",
),
("@诶", 2, Some("诶".to_string()), "Chinese character"),
("@👍", 2, Some("👍".to_string()), "Emoji token"),
("hello", 2, None, "No @ symbol"),
(
"@",
1,
Some("".to_string()),
"Only @ symbol triggers empty query",
),
("@ hello", 2, None, "@ followed by space"),
("test @ world", 6, None, "@ with spaces around"),
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
result, expected,
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
);
}
}
#[test]
fn test_current_at_token_cursor_positions() {
let test_cases = vec![
("@test", 0, Some("test".to_string()), "Cursor at @"),
("@test", 1, Some("test".to_string()), "Cursor after @"),
("@test", 5, Some("test".to_string()), "Cursor at end"),
("@file1 @file2", 0, Some("file1".to_string()), "First token"),
(
"@file1 @file2",
8,
Some("file2".to_string()),
"Second token",
),
("@", 0, Some("".to_string()), "Only @ symbol"),
("@a", 2, Some("a".to_string()), "Single character after @"),
("", 0, None, "Empty input"),
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
result, expected,
"Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}",
);
}
}
#[test]
fn test_current_at_token_whitespace_boundaries() {
let test_cases = vec![
(
"aaa@aaa",
4,
None,
"Connected @ token - no completion by design",
),
(
"aaa @aaa",
5,
Some("aaa".to_string()),
"@ token after space",
),
(
"test @file.txt",
7,
Some("file.txt".to_string()),
"@ token after space",
),
(
"test @İstanbul",
8,
Some("İstanbul".to_string()),
"@ token after full-width space",
),
(
"@ЙЦУ @诶",
10,
Some("诶".to_string()),
"Full-width space between Unicode tokens",
),
(
"test\t@file",
6,
Some("file".to_string()),
"@ token after tab",
),
];
for (input, cursor_pos, expected, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
result, expected,
"Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}",
);
}
}
#[test]
fn test_current_at_token_tracks_tokens_with_second_at() {
let input = "npx -y @kaeawc/auto-mobile@latest";
let token_start = input.find("@kaeawc").expect("scoped npm package present");
let version_at = input
.rfind("@latest")
.expect("version suffix present in scoped npm package");
let test_cases = vec![
(token_start, "Cursor at leading @"),
(token_start + 8, "Cursor inside scoped package name"),
(version_at, "Cursor at version @"),
(input.len(), "Cursor at end of token"),
];
for (cursor_pos, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
result,
Some("kaeawc/auto-mobile@latest".to_string()),
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
);
}
}
#[test]
fn test_current_at_token_allows_file_queries_with_second_at() {
let input = "@icons/icon@2x.png";
let version_at = input
.rfind("@2x")
.expect("second @ in file token should be present");
let test_cases = vec![
(0, "Cursor at leading @"),
(8, "Cursor before second @"),
(version_at, "Cursor at second @"),
(input.len(), "Cursor at end of token"),
];
for (cursor_pos, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert!(
result.is_some(),
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
);
}
}
#[test]
fn test_current_at_token_ignores_mid_word_at() {
let input = "foo@bar";
let at_pos = input.find('@').expect("@ present");
let test_cases = vec![
(at_pos, "Cursor at mid-word @"),
(input.len(), "Cursor at end of word containing @"),
];
for (cursor_pos, description) in test_cases {
let mut textarea = TextArea::new();
textarea.insert_str(input);
textarea.set_cursor(cursor_pos);
let result = ChatComposer::current_at_token(&textarea);
assert_eq!(
result, None,
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
);
}
}
#[test]
fn set_text_content_rebinds_at_sigiled_mentions() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let mention_bindings = vec![MentionBinding {
sigil: '@',
mention: "figma".to_string(),
path: "/tmp/user/figma/SKILL.md".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"@figma please".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_matching_sigil_only() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let mention_bindings = vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"@figma then $figma".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let bound_tokens = composer
.current_mention_elements()
.into_iter()
.map(|(_, sigil, mention)| (sigil, mention))
.collect::<Vec<_>>();
assert_eq!(bound_tokens, vec![('$', "figma".to_string())]);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_both_sigil_forms() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let mention_bindings = vec![
MentionBinding {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@test".to_string(),
},
MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
},
];
composer.set_text_content_with_mention_bindings(
"@figma then $figma".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let bound_tokens = composer
.current_mention_elements()
.into_iter()
.map(|(_, sigil, mention)| (sigil, mention))
.collect::<Vec<_>>();
assert_eq!(
bound_tokens,
vec![('@', "figma".to_string()), ('$', "figma".to_string())]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_at_mentions_after_email_substrings() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let text = "foo@sample.com then @sample".to_string();
let mention_start = text.rfind("@sample").expect("expected bound mention token");
let mention_range = mention_start..mention_start + "@sample".len();
let mention_bindings = vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}];
composer.set_text_content_with_mention_bindings(
text,
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
assert_eq!(
composer
.draft
.textarea
.text_element_snapshots()
.into_iter()
.map(|snapshot| snapshot.range)
.collect::<Vec<_>>(),
vec![mention_range]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_at_mentions_after_punctuation() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let text = "Please ask (@sample)".to_string();
let mention_start = text.find("@sample").expect("expected bound mention token");
let mention_range = mention_start..mention_start + "@sample".len();
let mention_bindings = vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}];
composer.set_text_content_with_mention_bindings(
text,
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
assert_eq!(
composer
.draft
.textarea
.text_element_snapshots()
.into_iter()
.map(|snapshot| snapshot.range)
.collect::<Vec<_>>(),
vec![mention_range]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn bound_at_mentions_do_not_block_arrow_navigation() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content_with_mention_bindings(
"go @figma now".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@debug".to_string(),
}],
);
composer.draft.textarea.set_cursor("go".len());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go".len() + 1);
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go @figma".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go ".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn restored_bound_at_mentions_do_not_open_mention_popup() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
for (text, move_cursor_to_end) in [
("@sample".to_string(), false),
("Please ask @sample.".to_string(), true),
] {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
description: None,
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
}]));
composer.set_text_content_with_mention_bindings(
text.clone(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
if move_cursor_to_end {
composer.move_cursor_to_end();
}
assert!(matches!(composer.popups.active, ActivePopup::None));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted {
text: submitted, ..
} => assert_eq!(submitted, text),
_ => panic!("expected restored bound mention to submit"),
}
}
}
#[test]
fn enter_submits_when_file_popup_has_no_selection() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let input = "npx -y @kaeawc/auto-mobile@latest";
composer.draft.textarea.insert_str(input);
composer.draft.textarea.set_cursor(input.len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, input),
_ => panic!("expected Submitted"),
}
}
#[test]
fn enter_submits_when_unified_mention_popup_has_no_selection() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_mentions_v2_enabled( true);
let input = "npx -y @kaeawc/auto-mobile@latest";
composer.draft.textarea.insert_str(input);
composer.draft.textarea.set_cursor(input.len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, input),
_ => panic!("expected Submitted"),
}
}
#[test]
fn ascii_prefix_survives_non_ascii_followup() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"),
_ => panic!("expected Submitted"),
}
}
#[test]
fn non_ascii_char_inserts_immediately_without_burst_state() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "あ");
assert!(!composer.is_in_paste_burst());
}
#[test]
fn non_ascii_burst_buffers_enter_and_flushes_multiline() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert!(composer.draft.textarea.text().is_empty());
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.draft.textarea.text(), "你好\nhi");
}
#[test]
fn non_ascii_burst_preserves_ideographic_space_and_ascii() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
for ch in ['你', ' ', '好'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
for ch in ['h', 'i'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(composer.draft.textarea.text().is_empty());
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.draft.textarea.text(), "你 好\nhi");
}
#[test]
fn non_ascii_burst_buffers_large_multiline_mixed_ascii_and_unicode() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
const LARGE_MIXED_PAYLOAD: &str = "天地玄黄 宇宙洪荒\n\
日月盈昃 辰宿列张\n\
寒来暑往 秋收冬藏\n\
\n\
你好世界 编码测试\n\
汉字处理 UTF-8\n\
终端显示 正确无误\n\
\n\
风吹竹林 月照大江\n\
白云千载 青山依旧\n\
程序员 与 Unicode 同行";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
for ch in LARGE_MIXED_PAYLOAD.chars() {
let code = if ch == '\n' {
KeyCode::Enter
} else {
KeyCode::Char(ch)
};
let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
assert!(composer.draft.textarea.text().is_empty());
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.draft.textarea.text(), LARGE_MIXED_PAYLOAD);
}
#[test]
fn ascii_burst_treats_enter_as_newline_even_when_parent_owned() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
for parent_owned in [false, true] {
let (mut composer, _rx) = new_test_composer();
if parent_owned {
composer.set_parent_owned_thread();
}
let mut now = Instant::now();
let step = Duration::from_millis(1);
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
now,
);
now += step;
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
now,
);
now += step;
let (result, _) =
composer.handle_submission_with_time( false, now);
assert!(
matches!(result, InputResult::None),
"Enter during a burst should insert newline, not submit"
);
for ch in ['t', 'h', 'e', 'r', 'e'] {
now += step;
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
now,
);
}
assert!(composer.draft.textarea.text().is_empty());
let flush_time = now + PasteBurst::recommended_active_flush_delay() + step;
let flushed = composer.handle_paste_burst_flush(flush_time);
assert!(flushed, "expected paste burst to flush");
assert_eq!(composer.draft.textarea.text(), "hi\nthere");
}
}
#[test]
fn queued_submission_flushes_ascii_burst_instead_of_inserting_newline() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let mut now = Instant::now();
let step = Duration::from_millis(1);
for ch in ['h', 'i'] {
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
now,
);
now += step;
}
assert!(composer.is_in_paste_burst());
let (result, _) = composer.handle_submission_with_time( true, now);
assert_eq!(
result,
InputResult::Queued {
text: "hi".to_string(),
text_elements: Vec::new(),
action: QueuedInputAction::Plain,
pending_pastes: Vec::new(),
}
);
assert!(composer.draft.textarea.text().is_empty());
assert!(!composer.is_in_paste_burst());
}
#[test]
fn slash_context_enter_ignores_paste_burst_enter_suppression() {
use crate::tui_internal::slash_command::SlashCommand;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.draft.textarea.set_text_clearing_elements("/diff");
composer.draft.textarea.set_cursor("/diff".len());
composer
.draft
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Command(SlashCommand::Diff)));
}
#[test]
fn non_char_key_flushes_active_burst_before_input() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert!(composer.draft.textarea.text().is_empty());
assert!(composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "hi");
assert_eq!(composer.draft.textarea.cursor(), 1);
assert!(!composer.is_in_paste_burst());
}
#[test]
fn disable_paste_burst_flushes_pending_first_char_and_inserts_immediately() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert!(composer.draft.textarea.text().is_empty());
composer.set_disable_paste_burst( true);
assert_eq!(composer.draft.textarea.text(), "a");
assert!(!composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "ab");
assert!(!composer.is_in_paste_burst());
}
#[test]
fn handle_paste_small_inserts_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let sanitized = "_count_rows\tindent\ntwo";
let needs_redraw =
composer.handle_paste("_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}".to_string());
assert!(needs_redraw);
assert_eq!(composer.draft.textarea.text(), sanitized);
assert!(composer.draft.pending_pastes.is_empty());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, sanitized),
_ => panic!("expected Submitted"),
}
}
#[test]
fn empty_enter_returns_none() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
assert!(composer.draft.textarea.text().is_empty());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::None => {}
other => panic!("expected None for empty enter, got: {other:?}"),
}
}
#[test]
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
let needs_redraw = composer.handle_paste(large.clone());
assert!(needs_redraw);
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
assert_eq!(composer.draft.textarea.text(), placeholder);
assert_eq!(composer.draft.pending_pastes.len(), 1);
assert_eq!(composer.draft.pending_pastes[0].0, placeholder);
assert_eq!(composer.draft.pending_pastes[0].1, large);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, large),
_ => panic!("expected Submitted"),
}
assert!(composer.draft.pending_pastes.is_empty());
}
#[test]
fn submit_at_character_limit_succeeds() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS);
composer.draft.textarea.set_text_clearing_elements(&input);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
result,
InputResult::Submitted { text, .. } if text == input
));
}
#[test]
fn oversized_submit_reports_error_and_restores_draft() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1);
composer.draft.textarea.set_text_clearing_elements(&input);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!(composer.draft.textarea.text(), input);
let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines( 80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains(&user_input_too_large_message(input.chars().count())));
found_error = true;
break;
}
}
assert!(found_error, "expected oversized-input error history cell");
}
#[test]
fn oversized_queued_submission_reports_error_and_restores_draft() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(false);
let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1);
composer.draft.textarea.set_text_clearing_elements(&input);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!(composer.draft.textarea.text(), input);
let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines( 80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains(&user_input_too_large_message(input.chars().count())));
found_error = true;
break;
}
}
assert!(found_error, "expected oversized-input error history cell");
}
#[test]
fn edit_clears_pending_paste() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.handle_paste(large);
assert_eq!(composer.draft.pending_pastes.len(), 1);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(composer.draft.pending_pastes.is_empty());
}
#[test]
fn ui_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
let test_cases = vec![
("empty", None),
("small", Some("short".to_string())),
("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))),
("multiple_pastes", None),
("backspace_after_pastes", None),
];
for (name, input) in test_cases {
let mut composer = ChatComposer::new(
true,
sender.clone(),
false,
"Ask Codex to do anything".to_string(),
false,
);
if let Some(text) = input {
composer.handle_paste(text);
} else if name == "multiple_pastes" {
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3));
composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7));
composer.handle_paste(" another short paste".to_string());
} else if name == "backspace_after_pastes" {
composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2));
composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4));
composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6));
composer
.draft
.textarea
.set_cursor(composer.draft.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
insta::assert_snapshot!(name, terminal.backend());
}
}
#[test]
fn image_placeholder_snapshots() {
snapshot_composer_state(
"image_placeholder_single",
false,
|composer| {
composer.attach_image(PathBuf::from("/tmp/image1.png"));
},
);
snapshot_composer_state(
"image_placeholder_multiple",
false,
|composer| {
composer.attach_image(PathBuf::from("/tmp/image1.png"));
composer.attach_image(PathBuf::from("/tmp/image2.png"));
},
);
}
#[test]
fn remote_image_rows_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
snapshot_composer_state(
"remote_image_rows",
false,
|composer| {
composer.set_remote_image_urls(vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
]);
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state(
"remote_image_rows_selected",
false,
|composer| {
composer.set_remote_image_urls(vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
]);
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor( 0);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
},
);
snapshot_composer_state(
"remote_image_rows_after_delete_first",
false,
|composer| {
composer.set_remote_image_urls(vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
]);
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor( 0);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
},
);
}
#[test]
fn slash_popup_model_first_for_mo_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let mut terminal = match Terminal::new(TestBackend::new(60, 5)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
insta::assert_snapshot!("slash_popup_mo", terminal.backend());
}
#[test]
fn slash_popup_model_first_for_mo_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
match &composer.popups.active {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "model")
}
Some(CommandItem::ServiceTier(command)) => {
panic!("expected model command, got service tier {command:?}")
}
None => panic!("no selected command for '/mo'"),
},
_ => panic!("slash popup not active after typing '/mo'"),
}
}
#[test]
fn slash_popup_resume_for_res_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.expect("draw composer");
insta::assert_snapshot!("slash_popup_res", terminal.backend());
}
#[test]
fn slash_popup_archive_for_ar_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'a', 'r']);
let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.expect("draw composer");
insta::assert_snapshot!("slash_popup_ar", terminal.backend());
}
#[test]
fn slash_popup_resume_for_res_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
match &composer.popups.active {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "resume")
}
Some(CommandItem::ServiceTier(command)) => {
panic!("expected resume command, got service tier {command:?}")
}
None => panic!("no selected command for '/res'"),
},
_ => panic!("slash popup not active after typing '/res'"),
}
}
#[test]
fn slash_popup_pets_for_pet_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']);
let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.expect("draw composer");
insta::assert_snapshot!("slash_popup_pet", terminal.backend());
}
#[test]
fn slash_popup_pets_for_pet_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']);
match &composer.popups.active {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "pets")
}
Some(CommandItem::ServiceTier(command)) => {
panic!("expected pets command, got service tier {command:?}")
}
None => panic!("no selected command for '/pet'"),
},
_ => panic!("slash popup not active after typing '/pet'"),
}
}
#[test]
fn slash_popup_btw_for_bt_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'b', 't']);
let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.expect("draw composer");
insta::assert_snapshot!("slash_popup_bt", terminal.backend());
}
#[test]
fn slash_popup_btw_for_bt_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'b', 't']);
match &composer.popups.active {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "btw")
}
Some(CommandItem::ServiceTier(command)) => {
panic!("expected btw command, got service tier {command:?}")
}
None => panic!("no selected command for '/bt'"),
},
_ => panic!("slash popup not active after typing '/bt'"),
}
}
#[test]
fn slash_popup_side_for_si_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 's', 'i']);
let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.expect("draw composer");
insta::assert_snapshot!("slash_popup_si", terminal.backend());
}
#[test]
fn slash_popup_side_for_si_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 's', 'i']);
match &composer.popups.active {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "side")
}
Some(CommandItem::ServiceTier(command)) => {
panic!("expected side command, got service tier {command:?}")
}
None => panic!("no selected command for '/si'"),
},
_ => panic!("slash popup not active after typing '/si'"),
}
}
#[test]
fn service_tier_slash_command_dispatches_from_catalog_name() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_service_tier_commands_enabled( true);
composer.set_service_tier_commands(vec![ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "Fastest inference with increased plan usage".to_string(),
}]);
type_chars_humanlike(&mut composer, &['/', 'f', 'a', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
result,
InputResult::ServiceTierCommand(ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "Fastest inference with increased plan usage".to_string(),
})
);
}
fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool {
std::thread::sleep(PasteBurst::recommended_active_flush_delay());
composer.flush_paste_burst_if_due()
}
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
for &ch in chars {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let _ = composer.flush_paste_burst_if_due();
if ch == ' ' {
let _ = composer.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Char(' '),
KeyModifiers::NONE,
KeyEventKind::Release,
));
}
}
}
#[test]
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "init");
}
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::ServiceTierCommand(command) => {
panic!("expected init command, got service tier {command:?}")
}
InputResult::Submitted { text, .. } => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::Queued { .. } => {
panic!("expected command dispatch, but composer queued literal text")
}
InputResult::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/init'"),
}
assert!(
composer.draft.textarea.is_empty(),
"composer should be cleared"
);
}
#[test]
fn kill_buffer_persists_after_submit() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.draft.textarea.insert_str("restore me");
composer.draft.textarea.set_cursor( 0);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert!(composer.draft.textarea.is_empty());
composer.draft.textarea.insert_str("hello");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
assert!(composer.draft.textarea.is_empty());
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
assert_eq!(composer.draft.textarea.text(), "restore me");
}
#[test]
fn kill_buffer_persists_after_slash_command_dispatch() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.draft.textarea.insert_str("restore me");
composer.draft.textarea.set_cursor( 0);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert!(composer.draft.textarea.is_empty());
composer.draft.textarea.insert_str("/diff");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "diff");
}
_ => panic!("expected Command result for '/diff'"),
}
assert!(composer.draft.textarea.is_empty());
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
assert_eq!(composer.draft.textarea.text(), "restore me");
}
#[test]
fn slash_command_disabled_while_task_running_keeps_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running( true);
composer
.draft
.textarea
.set_text_clearing_elements("/review these changes");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!("/review these changes", composer.draft.textarea.text());
let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines( 80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains("disabled while a task is in progress"));
found_error = true;
break;
}
}
assert!(found_error, "expected error history cell to be sent");
}
#[test]
fn enter_queues_when_queue_submissions_is_enabled() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_queue_submissions( true);
composer
.draft
.textarea
.set_text_clearing_elements("queued before session");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
result,
InputResult::Queued {
text: "queued before session".to_string(),
text_elements: Vec::new(),
action: QueuedInputAction::Plain,
pending_pastes: Vec::new(),
}
);
}
#[test]
fn tab_queues_slash_led_prompts_while_task_running_without_validation() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
fn assert_queued_slash(input: &str) {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running( true);
composer.draft.textarea.set_text_clearing_elements(input);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
match result {
InputResult::Queued {
text,
text_elements,
action,
..
} => {
assert_eq!(text, input);
assert!(text_elements.is_empty());
assert_eq!(action, QueuedInputAction::ParseSlash);
}
other => panic!("expected slash-led input to queue, got {other:?}"),
}
assert!(composer.draft.textarea.is_empty());
assert!(
rx.try_recv().is_err(),
"queueing should not report slash errors"
);
}
assert_queued_slash("/compact");
assert_queued_slash("/review check regressions");
assert_queued_slash("/fast");
assert_queued_slash("/does-not-exist");
}
#[test]
fn remapped_submit_does_not_fall_back_to_enter() {
use crate::tui_internal::key_hint;
use crate::tui_internal::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.textarea
.set_text_clearing_elements("explain the change");
composer
.draft
.textarea
.set_cursor(composer.draft.textarea.text().len());
let mut keymap = RuntimeKeymap::defaults();
keymap.composer.submit = vec![key_hint::ctrl(KeyCode::Char('j'))];
composer.set_keymap_bindings(&keymap);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!("explain the change\n", composer.draft.textarea.text());
}
#[test]
fn remapped_queue_does_not_fall_back_to_tab() {
use crate::tui_internal::key_hint;
use crate::tui_internal::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running( true);
composer
.draft
.textarea
.set_text_clearing_elements("queue me");
let mut keymap = RuntimeKeymap::defaults();
keymap.composer.queue = vec![key_hint::ctrl(KeyCode::Char('q'))];
composer.set_keymap_bindings(&keymap);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!("queue me", composer.draft.textarea.text());
}
#[test]
fn remapped_history_search_does_not_fall_back_to_ctrl_r() {
use crate::tui_internal::key_hint;
use crate::tui_internal::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let mut keymap = RuntimeKeymap::defaults();
keymap.composer.history_search_previous = vec![key_hint::plain(KeyCode::F(2))];
composer.set_keymap_bindings(&keymap);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(!composer.history_search_active());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
assert!(composer.history_search_active());
}
#[test]
fn tab_queues_leading_space_slash_as_plain_text_while_task_running() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running( true);
composer
.draft
.textarea
.set_text_clearing_elements(" /does-not-exist");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
match result {
InputResult::Queued { text, action, .. } => {
assert_eq!(text, "/does-not-exist");
assert_eq!(action, QueuedInputAction::Plain);
}
other => panic!("expected leading-space slash input to queue, got {other:?}"),
}
}
#[test]
fn tab_queues_bang_shell_prompts_while_task_running_without_execution() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
fn assert_queued_shell(input: &str, expected_text: &str) {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running( true);
composer.draft.textarea.set_text_clearing_elements(input);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
match result {
InputResult::Queued {
text,
text_elements,
action,
..
} => {
assert_eq!(text, expected_text);
assert!(text_elements.is_empty());
assert_eq!(action, QueuedInputAction::RunShell);
}
other => panic!("expected bang shell input to queue, got {other:?}"),
}
assert!(composer.draft.textarea.is_empty());
assert!(
rx.try_recv().is_err(),
"queueing should not show shell help immediately"
);
}
assert_queued_shell("!echo hi", "!echo hi");
assert_queued_shell("!", "!");
assert_queued_shell(" !echo hi", "!echo hi");
}
#[test]
fn slash_tab_completion_moves_cursor_to_end() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'c']);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "/compact ");
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
}
#[test]
fn slash_tab_completion_wins_over_queueing_while_task_running() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running( true);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/model ");
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
}
#[test]
fn slash_key_completes_selected_slash_command_as_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/model ");
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
}
#[test]
fn slash_tab_then_enter_dispatches_builtin_command() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'd', 'i']);
let (_res, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "/diff ");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::ServiceTierCommand(command) => {
panic!("expected diff command, got service tier {command:?}")
}
InputResult::Submitted { text, .. } => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
InputResult::Queued { .. } => {
panic!("expected command dispatch after Tab completion, got literal queue")
}
InputResult::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/diff'"),
}
assert!(composer.draft.textarea.is_empty());
}
#[test]
fn slash_command_elementizes_on_space() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_collaboration_modes_enabled( true);
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
let text = composer.draft.textarea.text().to_string();
let elements = composer.draft.textarea.text_elements();
assert_eq!(text, "/plan ");
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].placeholder(&text), Some("/plan"));
}
#[test]
fn slash_command_elementizes_only_known_commands() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_collaboration_modes_enabled( true);
type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']);
let text = composer.draft.textarea.text().to_string();
let elements = composer.draft.textarea.text_elements();
assert_eq!(text, "/Users ");
assert!(elements.is_empty());
}
#[test]
fn slash_command_element_removed_when_not_at_start() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
let text = composer.draft.textarea.text().to_string();
let elements = composer.draft.textarea.text_elements();
assert_eq!(text, "/review ");
assert_eq!(elements.len(), 1);
composer.draft.textarea.set_cursor( 0);
type_chars_humanlike(&mut composer, &['x']);
let text = composer.draft.textarea.text().to_string();
let elements = composer.draft.textarea.text_elements();
assert_eq!(text, "x/review ");
assert!(elements.is_empty());
}
#[test]
fn tab_submits_when_no_task_running() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['h', 'i']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(
result,
InputResult::Submitted { ref text, .. } if text == "hi"
));
assert!(composer.draft.textarea.is_empty());
}
#[test]
fn tab_does_not_submit_for_bang_shell_command() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_task_running( false);
type_chars_humanlike(&mut composer, &['!', 'l', 's']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert!(
composer.current_text().starts_with("!ls"),
"expected Tab not to submit or clear a `!` command"
);
}
#[test]
fn bang_prefixed_slash_text_submits_literal_shell_command() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['!', '/', 'd', 'i', 'f', 'f']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
result,
InputResult::Submitted { ref text, .. } if text == "!/diff"
));
}
#[test]
fn slash_mention_dispatches_command_and_inserts_at() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "mention");
}
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::ServiceTierCommand(command) => {
panic!("expected mention command, got service tier {command:?}")
}
InputResult::Submitted { text, .. } => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::Queued { .. } => {
panic!("expected command dispatch, but composer queued literal text")
}
InputResult::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/mention'"),
}
assert!(
composer.draft.textarea.is_empty(),
"composer should be cleared"
);
composer.insert_str("@");
assert_eq!(composer.draft.textarea.text(), "@");
}
#[test]
fn slash_plan_args_preserve_text_elements() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_collaboration_modes_enabled( true);
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
let placeholder = local_image_label_text( 1);
composer.attach_image(PathBuf::from("/tmp/plan.png"));
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
assert_eq!(cmd.command(), "plan");
assert_eq!(args, placeholder);
assert_eq!(text_elements.len(), 1);
assert_eq!(
text_elements[0].placeholder(&args),
Some(placeholder.as_str())
);
}
_ => panic!("expected CommandWithArgs for /plan with args"),
}
}
#[test]
fn file_completion_preserves_large_paste_placeholder_elements() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
composer.handle_paste(large.clone());
composer.insert_str(" @ma");
composer.on_file_search_result(
"ma".to_string(),
vec![FileMatch {
score: 1,
path: PathBuf::from("src/main.rs"),
match_type: lemurclaw_core::file_search::MatchType::File,
root: PathBuf::from("/tmp"),
indices: None,
}],
);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let text = composer.draft.textarea.text().to_string();
assert_eq!(text, format!("{placeholder} src/main.rs "));
let elements = composer.draft.textarea.text_elements();
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].placeholder(&text), Some(placeholder.as_str()));
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
assert_eq!(text, format!("{large} src/main.rs"));
assert!(text_elements.is_empty());
}
_ => panic!("expected Submitted"),
}
}
fn complete_file(
composer: &mut ChatComposer,
text: &str,
cursor: usize,
query: &str,
selected_path: PathBuf,
) {
composer.set_text_content(text.to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor(cursor);
composer.sync_popups();
let root = selected_path
.parent()
.filter(|_| selected_path.is_absolute())
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("/tmp"));
composer.on_file_search_result(
query.to_string(),
vec![FileMatch {
score: 1,
path: selected_path,
match_type: lemurclaw_core::file_search::MatchType::File,
root,
indices: None,
}],
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
#[test]
fn legacy_file_completion_ignores_shell_variable_to_the_right() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("available")]));
complete_file(
&mut composer,
"@src $HOME",
"@src ".len(),
"src",
PathBuf::from("src/main.rs"),
);
assert_eq!(composer.current_text(), "src/main.rs $HOME");
}
#[test]
fn unified_popup_preserves_empty_at_before_existing_text() {
let (mut composer, _rx) = new_test_composer();
composer.set_mentions_v2_enabled( true);
composer.set_text_content("@ word".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
}
#[test]
fn adjacent_file_completion_inserts_leading_separator() {
let (mut composer, _rx) = new_test_composer();
configure_bound_plugin_left_of_unbound_plugin(&mut composer);
composer.insert_selected_file_path("@sample".len().."@sample@other".len(), "src/main.rs");
assert_eq!(composer.current_text(), "@sample src/main.rs ");
}
#[test]
fn adjacent_image_completion_inserts_leading_separator() {
let tmp = tempdir().expect("create TempDir");
let image_path = tmp.path().join("image.png");
let image: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255]));
image.save(&image_path).expect("write temp png");
let (mut composer, _rx) = new_test_composer();
configure_bound_plugin_left_of_unbound_plugin(&mut composer);
composer.insert_selected_file_path(
"@sample".len().."@sample@other".len(),
image_path.to_str().expect("UTF-8 path"),
);
assert_eq!(composer.current_text(), "@sample [Image #1] ");
assert_eq!(composer.local_image_paths(), vec![image_path]);
}
#[test]
fn file_completion_preserves_separator_before_existing_suffix() {
let (mut composer, _rx) = new_test_composer();
complete_file(
&mut composer,
"@ma next",
"@ma".len(),
"ma",
PathBuf::from("src/main.rs"),
);
composer.insert_str("foo");
assert_eq!(composer.current_text(), "src/main.rs foo next");
}
#[test]
fn file_completion_preserves_separator_before_sigiled_suffix() {
for suffix in ["@next", "$next"] {
let (mut composer, _rx) = new_test_composer();
composer.set_text_content(format!("@ma {suffix}"), Vec::new(), Vec::new());
composer.insert_selected_path(0.."@ma".len(), "src/main.rs");
composer.insert_str("foo");
assert_eq!(composer.current_text(), format!("src/main.rs foo {suffix}"));
}
}
#[test]
fn file_completion_inserts_separator_before_line_break() {
let (mut composer, _rx) = new_test_composer();
complete_file(
&mut composer,
"@ma\nnext",
"@ma".len(),
"ma",
PathBuf::from("src/main.rs"),
);
composer.insert_str("foo");
assert_eq!(composer.current_text(), "src/main.rs foo\nnext");
}
#[test]
fn file_completion_for_sigil_path_does_not_reopen_popup() {
let (mut composer, _rx) = new_test_composer();
complete_file(
&mut composer,
"@ma\nnext",
"@ma".len(),
"ma",
PathBuf::from("@scope/main.rs"),
);
assert_eq!(composer.current_text(), "@scope/main.rs \nnext");
assert!(matches!(composer.popups.active, ActivePopup::None));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, "@scope/main.rs \nnext"),
_ => panic!("expected completed path to submit"),
}
}
#[test]
fn file_completion_does_not_dismiss_identical_next_token() {
let (mut composer, _rx) = new_test_composer();
complete_file(
&mut composer,
"@ma @scope/main.rs",
"@ma".len(),
"ma",
PathBuf::from("@scope/main.rs"),
);
composer.draft.textarea.set_cursor("@scope/main.rs ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
}
#[test]
fn dismissed_file_popup_tracks_token_across_leading_whitespace_edits() {
let (mut composer, _rx) = new_test_composer();
composer.set_text_content("@ma".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@ma".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.popups.active, ActivePopup::None));
composer.draft.textarea.set_cursor( 0);
composer.insert_str(" ");
assert!(matches!(composer.popups.active, ActivePopup::None));
for _ in 0..3 {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(matches!(composer.popups.active, ActivePopup::None));
}
}
#[test]
fn dismissed_file_popup_ignores_token_substrings_in_leading_paste() {
let (mut composer, _rx) = new_test_composer();
composer.set_text_content("@ma".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@ma".len());
composer.sync_popups();
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.popups.active, ActivePopup::None));
composer.draft.textarea.set_cursor( 0);
composer.handle_paste("email@ma.com ".to_string());
assert_eq!(composer.current_text(), "email@ma.com @ma");
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let test_cases = [
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
(" and ".to_string(), false),
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
];
let mut expected_text = String::new();
let mut expected_pending_count = 0;
let states: Vec<_> = test_cases
.iter()
.map(|(content, is_large)| {
composer.handle_paste(content.clone());
if *is_large {
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
expected_text.push_str(&placeholder);
expected_pending_count += 1;
} else {
expected_text.push_str(content);
}
(expected_text.clone(), expected_pending_count)
})
.collect();
assert_eq!(
states,
vec![
(
format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()),
1
),
(
format!(
"[Pasted Content {} chars] and ",
test_cases[0].0.chars().count()
),
1
),
(
format!(
"[Pasted Content {} chars] and [Pasted Content {} chars]",
test_cases[0].0.chars().count(),
test_cases[2].0.chars().count()
),
2
),
]
);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted { text, .. } = result {
assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
} else {
panic!("expected Submitted");
}
}
#[test]
fn test_placeholder_deletion() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let test_cases = [
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
(" and ".to_string(), false),
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
];
let mut current_pos = 0;
let states: Vec<_> = test_cases
.iter()
.map(|(content, is_large)| {
composer.handle_paste(content.clone());
if *is_large {
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
current_pos += placeholder.len();
} else {
current_pos += content.len();
}
(
composer.draft.textarea.text().to_string(),
composer.draft.pending_pastes.len(),
current_pos,
)
})
.collect();
let mut deletion_states = vec![];
composer.draft.textarea.set_cursor(states[0].2);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.draft.textarea.text().to_string(),
composer.draft.pending_pastes.len(),
));
composer
.draft
.textarea
.set_cursor(composer.draft.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.draft.textarea.text().to_string(),
composer.draft.pending_pastes.len(),
));
assert_eq!(
deletion_states,
vec![
(" and [Pasted Content 1006 chars]".to_string(), 1),
(" and ".to_string(), 0),
]
);
}
#[test]
fn deleting_duplicate_length_pastes_removes_only_target() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count());
let placeholder_second = format!("{placeholder_base} #2");
composer.handle_paste(paste.clone());
composer.handle_paste(paste.clone());
assert_eq!(
composer.draft.textarea.text(),
format!("{placeholder_base}{placeholder_second}")
);
assert_eq!(composer.draft.pending_pastes.len(), 2);
composer
.draft
.textarea
.set_cursor(composer.draft.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), placeholder_base);
assert_eq!(composer.draft.pending_pastes.len(), 1);
assert_eq!(composer.draft.pending_pastes[0].0, placeholder_base);
assert_eq!(composer.draft.pending_pastes[0].1, paste);
}
#[test]
fn large_paste_numbering_continues_with_same_length_placeholder() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let base = format!("[Pasted Content {} chars]", paste.chars().count());
let second = format!("{base} #2");
let third = format!("{base} #3");
composer.handle_paste(paste.clone());
composer.handle_paste(paste.clone());
assert_eq!(composer.draft.textarea.text(), format!("{base}{second}"));
composer.draft.textarea.set_cursor(base.len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), second);
assert_eq!(composer.draft.pending_pastes.len(), 1);
assert_eq!(composer.draft.pending_pastes[0].0, second);
composer
.draft
.textarea
.set_cursor(composer.draft.textarea.text().len());
composer.handle_paste(paste);
assert_eq!(composer.draft.textarea.text(), format!("{second}{third}"));
assert_eq!(composer.draft.pending_pastes.len(), 2);
assert_eq!(composer.draft.pending_pastes[0].0, second);
assert_eq!(composer.draft.pending_pastes[1].0, third);
}
#[test]
fn large_paste_numbering_reuses_after_all_deleted() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let base = format!("[Pasted Content {} chars]", paste.chars().count());
composer.handle_paste(paste.clone());
assert_eq!(composer.draft.textarea.text(), base);
assert_eq!(composer.draft.pending_pastes.len(), 1);
composer
.draft
.textarea
.set_cursor(composer.draft.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(composer.draft.textarea.text().is_empty());
assert!(composer.draft.pending_pastes.is_empty());
composer.handle_paste(paste);
assert_eq!(composer.draft.textarea.text(), base);
assert_eq!(composer.draft.pending_pastes.len(), 1);
assert_eq!(composer.draft.pending_pastes[0].0, base);
}
#[test]
fn test_partial_placeholder_deletion() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let test_cases = [
5, 0, ];
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let placeholder = format!("[Pasted Content {} chars]", paste.chars().count());
let states: Vec<_> = test_cases
.into_iter()
.map(|pos_from_end| {
composer.handle_paste(paste.clone());
composer
.draft
.textarea
.set_cursor(placeholder.len() - pos_from_end);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let result = (
composer.draft.textarea.text().contains(&placeholder),
composer.draft.pending_pastes.len(),
);
composer.draft.textarea.set_text_clearing_elements("");
result
})
.collect();
assert_eq!(
states,
vec![
(false, 0), (false, 0), ]
);
}
#[test]
fn attach_image_and_submit_includes_local_image_paths() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image1.png");
composer.attach_image(path.clone());
composer.handle_paste(" hi".into());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
assert_eq!(text, "[Image #1] hi");
assert_eq!(text_elements.len(), 1);
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
assert_eq!(
text_elements[0].byte_range,
ByteRange {
start: 0,
end: "[Image #1]".len()
}
);
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(vec![path], imgs);
}
#[test]
fn submit_captures_recent_mention_bindings_before_clearing_textarea() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let mention_bindings = vec![MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "/tmp/user/figma/SKILL.md".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"$figma please".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
assert_eq!(
composer.take_recent_submission_mention_bindings(),
mention_bindings
);
assert!(composer.take_mention_bindings().is_empty());
}
#[test]
fn history_navigation_restores_remote_and_local_image_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let remote_image_url = "https://example.com/remote.png".to_string();
composer.set_remote_image_urls(vec![remote_image_url.clone()]);
let path = PathBuf::from("/tmp/image1.png");
composer.attach_image(path.clone());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
let _ = composer.take_remote_image_urls();
composer.set_text_content(String::new(), Vec::new(), Vec::new());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let text = composer.current_text();
assert_eq!(text, "[Image #2]");
let text_elements = composer.text_elements();
assert_eq!(text_elements.len(), 1);
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]"));
assert_eq!(composer.local_image_paths(), vec![path]);
assert_eq!(composer.remote_image_urls(), vec![remote_image_url]);
}
#[test]
fn history_navigation_restores_remote_only_submissions() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let remote_image_urls = vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
];
composer.set_remote_image_urls(remote_image_urls.clone());
let (submitted_text, submitted_elements) = composer
.prepare_submission_text( true)
.expect("remote-only submission should be prepared");
assert_eq!(submitted_text, "");
assert!(submitted_elements.is_empty());
let _ = composer.take_remote_image_urls();
composer.set_text_content(String::new(), Vec::new(), Vec::new());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "");
assert!(composer.text_elements().is_empty());
assert_eq!(composer.remote_image_urls(), remote_image_urls);
}
#[test]
fn history_navigation_leaves_cursor_at_end_of_line() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "second");
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "first");
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "second");
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(composer.draft.textarea.is_empty());
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
}
#[test]
fn vim_normal_j_k_navigate_history_at_history_boundaries() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
composer.set_vim_enabled( true);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "second");
assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "first");
assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "second");
assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(composer.draft.textarea.is_empty());
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
}
#[test]
fn remapped_vim_normal_history_navigation_does_not_fall_back_to_j_k() {
use crate::tui_internal::key_hint;
use crate::tui_internal::keymap::RuntimeKeymap;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
let mut keymap = RuntimeKeymap::defaults();
keymap.vim_normal.move_up = vec![key_hint::plain(KeyCode::F(2))];
keymap.vim_normal.move_down = vec![key_hint::plain(KeyCode::F(3))];
composer.set_keymap_bindings(&keymap);
composer.set_vim_enabled( true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert!(composer.draft.textarea.is_empty());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "first");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE));
assert!(composer.draft.textarea.is_empty());
}
#[test]
fn vim_normal_j_k_fall_back_to_multiline_cursor_movement() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.textarea
.set_text_clearing_elements("one\ntwo");
composer.draft.textarea.set_cursor( 0);
composer.set_vim_enabled( true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "one\n".len());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), 0);
}
#[test]
fn vim_normal_operator_motion_does_not_navigate_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
composer.set_vim_enabled( true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "second");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert!(composer.draft.textarea.is_empty());
assert_eq!(composer.current_text(), "");
}
#[test]
fn vim_normal_operator_pending_consumes_submit_key() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("hello".to_string(), Vec::new(), Vec::new());
composer.set_vim_enabled( true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert!(composer.draft.textarea.is_vim_operator_pending());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert_eq!(composer.draft.textarea.text(), "hello");
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
assert!(!composer.draft.textarea.is_vim_operator_pending());
}
#[test]
fn remapped_editor_history_navigation_does_not_fall_back_to_up() {
use crate::tui_internal::key_hint;
use crate::tui_internal::keymap::RuntimeKeymap;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
let mut keymap = RuntimeKeymap::defaults();
keymap.editor.move_up = vec![key_hint::plain(KeyCode::F(2))];
composer.set_keymap_bindings(&keymap);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(composer.draft.textarea.is_empty());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "first");
}
#[test]
fn history_navigation_from_start_of_bang_command_recalls_older_entry() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "!git");
composer.draft.textarea.set_cursor( 0);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "first");
}
#[test]
fn vim_normal_history_navigation_from_start_of_bang_command_recalls_older_entry() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
composer.set_vim_enabled( true);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(composer.current_text(), "!git");
assert_eq!(composer.draft.textarea.cursor(), "git".len() - 1);
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(composer.current_text(), "first");
assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1);
}
#[test]
fn set_text_content_reattaches_images_without_placeholder_metadata() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = local_image_label_text( 1);
let text = format!("{placeholder} restored");
let text_elements = vec![TextElement::new(
(0..placeholder.len()).into(),
None,
)];
let path = PathBuf::from("/tmp/image1.png");
composer.set_text_content(text, text_elements, vec![path.clone()]);
assert_eq!(composer.local_image_paths(), vec![path]);
}
#[test]
fn large_paste_preserves_image_text_elements_on_submit() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
composer.handle_paste(large_content.clone());
composer.handle_paste(" ".into());
let path = PathBuf::from("/tmp/image_with_paste.png");
composer.attach_image(path.clone());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
let expected = format!("{large_content} [Image #1]");
assert_eq!(text, expected);
assert_eq!(text_elements.len(), 1);
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
assert_eq!(
text_elements[0].byte_range,
ByteRange {
start: large_content.len() + 1,
end: large_content.len() + 1 + "[Image #1]".len(),
}
);
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(vec![path], imgs);
}
#[test]
fn large_paste_with_leading_whitespace_trims_and_shifts_elements() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5));
composer.handle_paste(large_content.clone());
composer.handle_paste(" ".into());
let path = PathBuf::from("/tmp/image_with_trim.png");
composer.attach_image(path.clone());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
let trimmed = large_content.trim().to_string();
assert_eq!(text, format!("{trimmed} [Image #1]"));
assert_eq!(text_elements.len(), 1);
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
assert_eq!(
text_elements[0].byte_range,
ByteRange {
start: trimmed.len() + 1,
end: trimmed.len() + 1 + "[Image #1]".len(),
}
);
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(vec![path], imgs);
}
#[test]
fn pasted_crlf_normalizes_newlines_for_elements() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let pasted = "line1\r\nline2\r\n".to_string();
composer.handle_paste(pasted);
composer.handle_paste(" ".into());
let path = PathBuf::from("/tmp/image_crlf.png");
composer.attach_image(path.clone());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
assert_eq!(text, "line1\nline2\n [Image #1]");
assert!(!text.contains('\r'));
assert_eq!(text_elements.len(), 1);
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
assert_eq!(
text_elements[0].byte_range,
ByteRange {
start: "line1\nline2\n ".len(),
end: "line1\nline2\n [Image #1]".len(),
}
);
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(vec![path], imgs);
}
#[test]
fn suppressed_submission_restores_pending_paste_payload() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.textarea
.set_text_clearing_elements("/unknown ");
composer.draft.textarea.set_cursor("/unknown ".len());
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
composer.handle_paste(large_content.clone());
let placeholder = composer
.draft
.pending_pastes
.first()
.expect("expected pending paste")
.0
.clone();
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
assert_eq!(composer.draft.pending_pastes.len(), 1);
assert_eq!(
composer.draft.textarea.text(),
format!("/unknown {placeholder}")
);
composer.draft.textarea.set_cursor( 0);
composer.draft.textarea.insert_str(" ");
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
assert_eq!(text, format!("/unknown {large_content}"));
assert!(text_elements.is_empty());
}
_ => panic!("expected Submitted"),
}
assert!(composer.draft.pending_pastes.is_empty());
}
#[test]
fn attach_image_without_text_submits_empty_text_and_images() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image2.png");
composer.attach_image(path.clone());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
assert_eq!(text, "[Image #1]");
assert_eq!(text_elements.len(), 1);
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
assert_eq!(
text_elements[0].byte_range,
ByteRange {
start: 0,
end: "[Image #1]".len()
}
);
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(imgs.len(), 1);
assert_eq!(imgs[0], path);
assert!(composer.attachments.local_images.is_empty());
}
#[test]
fn duplicate_image_placeholders_get_suffix() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image_dup.png");
composer.attach_image(path.clone());
composer.handle_paste(" ".into());
composer.attach_image(path);
let text = composer.draft.textarea.text().to_string();
assert!(text.contains("[Image #1]"));
assert!(text.contains("[Image #2]"));
assert_eq!(
composer.attachments.local_images[0].placeholder,
"[Image #1]"
);
assert_eq!(
composer.attachments.local_images[1].placeholder,
"[Image #2]"
);
}
#[test]
fn image_placeholder_backspace_behaves_like_text_placeholder() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image3.png");
composer.attach_image(path.clone());
let placeholder = composer.attachments.local_images[0].placeholder.clone();
composer
.draft
.textarea
.move_cursor_to_end_of_line( false);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(!composer.draft.textarea.text().contains(&placeholder));
assert!(composer.attachments.local_images.is_empty());
composer.attach_image(path);
let placeholder2 = composer.attachments.local_images[0].placeholder.clone();
if let Some(start_pos) = composer.draft.textarea.text().find(&placeholder2) {
let mid_pos = start_pos + (placeholder2.len() / 2);
composer.draft.textarea.set_cursor(mid_pos);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(composer.draft.textarea.text().contains(&placeholder2));
assert_eq!(composer.attachments.local_images.len(), 1);
} else {
panic!("Placeholder not found in textarea");
}
}
#[test]
fn backspace_with_multibyte_text_before_placeholder_does_not_panic() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("/tmp/image_multibyte.png");
composer.attach_image(path);
composer.draft.textarea.insert_str("日本語");
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(composer.attachments.local_images.len(), 1);
assert!(composer.draft.textarea.text().starts_with("[Image #1]"));
}
#[test]
fn deleting_one_of_duplicate_image_placeholders_removes_one_entry() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path1 = PathBuf::from("/tmp/image_dup1.png");
let path2 = PathBuf::from("/tmp/image_dup2.png");
composer.attach_image(path1);
composer.handle_paste(" ".into());
composer.attach_image(path2.clone());
let placeholder1 = composer.attachments.local_images[0].placeholder.clone();
let placeholder2 = composer.attachments.local_images[1].placeholder.clone();
let text = composer.draft.textarea.text().to_string();
let start1 = text.find(&placeholder1).expect("first placeholder present");
let end1 = start1 + placeholder1.len();
composer.draft.textarea.set_cursor(end1);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let new_text = composer.draft.textarea.text().to_string();
assert_eq!(
1,
new_text.matches(&placeholder1).count(),
"one placeholder remains after deletion"
);
assert_eq!(
0,
new_text.matches(&placeholder2).count(),
"second placeholder was relabeled"
);
assert_eq!(
1,
new_text.matches("[Image #1]").count(),
"remaining placeholder relabeled to #1"
);
assert_eq!(
vec![AttachedImage {
path: path2,
placeholder: "[Image #1]".to_string()
}],
composer.attachments.local_images,
"one image mapping remains"
);
}
#[test]
fn deleting_reordered_image_one_renumbers_text_in_place() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path1 = PathBuf::from("/tmp/image_first.png");
let path2 = PathBuf::from("/tmp/image_second.png");
let placeholder1 = local_image_label_text( 1);
let placeholder2 = local_image_label_text( 2);
let text = format!("Test {placeholder2} test {placeholder1}");
let start2 = text.find(&placeholder2).expect("placeholder2 present");
let start1 = text.find(&placeholder1).expect("placeholder1 present");
let text_elements = vec![
TextElement::new(
ByteRange {
start: start2,
end: start2 + placeholder2.len(),
},
Some(placeholder2),
),
TextElement::new(
ByteRange {
start: start1,
end: start1 + placeholder1.len(),
},
Some(placeholder1.clone()),
),
];
composer.set_text_content(text, text_elements, vec![path1, path2.clone()]);
let end1 = start1 + placeholder1.len();
composer.draft.textarea.set_cursor(end1);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
composer.draft.textarea.text(),
format!("Test {placeholder1} test ")
);
assert_eq!(
vec![AttachedImage {
path: path2,
placeholder: placeholder1
}],
composer.attachments.local_images,
"attachment renumbered after deletion"
);
}
#[test]
fn deleting_first_text_element_renumbers_following_text_element() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path1 = PathBuf::from("/tmp/image_first.png");
let path2 = PathBuf::from("/tmp/image_second.png");
composer.attach_image(path1);
composer.attach_image(path2.clone());
assert_eq!(composer.draft.textarea.text(), "[Image #1][Image #2]");
assert_eq!(composer.attachments.local_images.len(), 2);
composer.draft.textarea.set_cursor( 0);
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(composer.attachments.local_images.len(), 1);
assert_eq!(composer.attachments.local_images[0].path, path2);
assert_eq!(
composer.attachments.local_images[0].placeholder,
"[Image #1]"
);
assert_eq!(composer.draft.textarea.text(), "[Image #1]");
}
#[test]
fn pasting_filepath_attaches_image() {
let tmp = tempdir().expect("create TempDir");
let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png");
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255]));
img.save(&tmp_path).expect("failed to write temp png");
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
assert!(needs_redraw);
assert!(composer.draft.textarea.text().starts_with("[Image #1] "));
let imgs = composer.take_recent_submission_images();
assert_eq!(imgs, vec![tmp_path]);
}
#[test]
fn slash_path_input_submits_without_command_error() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.textarea
.set_text_clearing_elements("/Users/example/project/src/main.rs");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted { text, .. } = result {
assert_eq!(text, "/Users/example/project/src/main.rs");
} else {
panic!("expected Submitted");
}
assert!(composer.draft.textarea.is_empty());
match rx.try_recv() {
Ok(event) => panic!("unexpected event: {event:?}"),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(err) => panic!("unexpected channel state: {err:?}"),
}
}
#[test]
fn slash_with_leading_space_submits_as_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.draft
.textarea
.set_text_clearing_elements(" /this-looks-like-a-command");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted { text, .. } = result {
assert_eq!(text, "/this-looks-like-a-command");
} else {
panic!("expected Submitted");
}
assert!(composer.draft.textarea.is_empty());
match rx.try_recv() {
Ok(event) => panic!("unexpected event: {event:?}"),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(err) => panic!("unexpected channel state: {err:?}"),
}
}
#[test]
fn pending_first_ascii_char_flushes_as_typed() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert!(composer.draft.textarea.text().is_empty());
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let flushed = composer.flush_paste_burst_if_due();
assert!(flushed, "expected pending first char to flush");
assert_eq!(composer.draft.textarea.text(), "h");
assert!(!composer.is_in_paste_burst());
}
#[test]
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let count = 32;
let mut now = Instant::now();
let step = Duration::from_millis(1);
for _ in 0..count {
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
now,
);
assert!(
composer.is_in_paste_burst(),
"expected active paste burst during fast typing"
);
assert!(
composer.draft.textarea.text().is_empty(),
"text should not appear during burst"
);
now += step;
}
assert!(
composer.draft.textarea.text().is_empty(),
"text should remain empty until flush"
);
let flush_time = now + PasteBurst::recommended_active_flush_delay() + step;
let flushed = composer.handle_paste_burst_flush(flush_time);
assert!(flushed, "expected buffered text to flush after stop");
assert_eq!(composer.draft.textarea.text(), "a".repeat(count));
assert!(
composer.draft.pending_pastes.is_empty(),
"no placeholder for small burst"
);
}
#[test]
fn burst_paste_fast_large_inserts_placeholder_on_flush() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let count = LARGE_PASTE_CHAR_THRESHOLD + 1; let mut now = Instant::now();
let step = Duration::from_millis(1);
for _ in 0..count {
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
now,
);
now += step;
}
assert!(composer.draft.textarea.text().is_empty());
let flush_time = now + PasteBurst::recommended_active_flush_delay() + step;
let flushed = composer.handle_paste_burst_flush(flush_time);
assert!(flushed, "expected flush after stopping fast input");
let expected_placeholder = format!("[Pasted Content {count} chars]");
assert_eq!(composer.draft.textarea.text(), expected_placeholder);
assert_eq!(composer.draft.pending_pastes.len(), 1);
assert_eq!(composer.draft.pending_pastes[0].0, expected_placeholder);
assert_eq!(composer.draft.pending_pastes[0].1.len(), count);
assert!(composer.draft.pending_pastes[0].1.chars().all(|c| c == 'x'));
}
#[test]
fn humanlike_typing_1000_chars_appears_live_no_placeholder() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let count = LARGE_PASTE_CHAR_THRESHOLD; let chars: Vec<char> = vec!['z'; count];
type_chars_humanlike(&mut composer, &chars);
assert_eq!(composer.draft.textarea.text(), "z".repeat(count));
assert!(composer.draft.pending_pastes.is_empty());
}
#[test]
fn slash_popup_not_activated_for_slash_space_text_history_like_input() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new());
assert!(
matches!(composer.popups.active, ActivePopup::None),
"expected no slash popup for '/ test'"
);
let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
}
#[test]
fn slash_popup_activated_for_bare_slash_and_valid_prefixes() {
use tokio::sync::mpsc::unbounded_channel;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("/".to_string(), Vec::new(), Vec::new());
assert!(
matches!(composer.popups.active, ActivePopup::Command(_)),
"bare '/' should activate slash popup"
);
composer.set_text_content("/re".to_string(), Vec::new(), Vec::new());
assert!(
matches!(composer.popups.active, ActivePopup::Command(_)),
"'/re' should activate slash popup via prefix match"
);
composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new());
assert!(
matches!(composer.popups.active, ActivePopup::Command(_)),
"'/ac' should activate slash popup via fuzzy match"
);
composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new());
assert!(
matches!(composer.popups.active, ActivePopup::None),
"'/zzz' should not activate slash popup because it is not a prefix of any built-in command"
);
}
#[test]
fn bare_slash_command_can_be_recalled_after_recording_pending_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("/diff".to_string(), Vec::new(), Vec::new());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
composer.record_pending_slash_command_history();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/diff");
}
#[test]
fn popup_selected_slash_command_records_canonical_command_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("/di".to_string(), Vec::new(), Vec::new());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
composer.record_pending_slash_command_history();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/diff");
}
#[test]
fn inline_slash_command_can_be_recalled_after_recording_pending_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_collaboration_modes_enabled( true);
composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new());
composer.popups.active = ActivePopup::None;
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
assert_eq!(cmd, SlashCommand::Plan);
assert_eq!(args, "investigate this");
assert!(text_elements.is_empty());
}
other => panic!("expected inline /plan command, got {other:?}"),
}
composer.record_pending_slash_command_history();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/plan investigate this");
}
#[test]
fn apply_external_edit_rebuilds_text_and_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = local_image_label_text( 1);
composer.draft.textarea.insert_element(&placeholder);
composer.attachments.local_images.push(AttachedImage {
placeholder: placeholder.clone(),
path: PathBuf::from("img.png"),
});
composer
.draft
.pending_pastes
.push(("[Pasted]".to_string(), "data".to_string()));
composer.apply_external_edit(format!("Edited {placeholder} text"));
assert_eq!(
composer.current_text(),
format!("Edited {placeholder} text")
);
assert!(composer.draft.pending_pastes.is_empty());
assert_eq!(composer.attachments.local_images.len(), 1);
assert_eq!(
composer.attachments.local_images[0].placeholder,
placeholder
);
assert_eq!(
composer.draft.textarea.cursor(),
composer.current_text().len()
);
}
#[test]
fn apply_external_edit_absorbs_bash_prefix_without_duplication() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
composer.apply_external_edit("!git status".to_string());
assert!(composer.draft.is_bash_mode);
assert_eq!(composer.draft.textarea.text(), "git status");
assert_eq!(composer.current_text(), "!git status");
}
#[test]
fn apply_external_edit_can_leave_bash_mode() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
composer.apply_external_edit("git status".to_string());
assert!(!composer.draft.is_bash_mode);
assert_eq!(composer.draft.textarea.text(), "git status");
assert_eq!(composer.current_text(), "git status");
}
#[test]
fn apply_external_edit_can_enter_bash_mode() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("git status".to_string(), Vec::new(), Vec::new());
composer.apply_external_edit("!git status".to_string());
assert!(composer.draft.is_bash_mode);
assert_eq!(composer.draft.textarea.text(), "git status");
assert_eq!(composer.current_text(), "!git status");
}
#[test]
fn apply_external_edit_drops_missing_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = local_image_label_text( 1);
composer.draft.textarea.insert_element(&placeholder);
composer.attachments.local_images.push(AttachedImage {
placeholder: placeholder.clone(),
path: PathBuf::from("img.png"),
});
composer.apply_external_edit("No images here".to_string());
assert_eq!(composer.current_text(), "No images here".to_string());
assert!(composer.attachments.local_images.is_empty());
}
#[test]
fn apply_external_edit_renumbers_image_placeholders() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let first_path = PathBuf::from("img1.png");
let second_path = PathBuf::from("img2.png");
composer.attach_image(first_path);
composer.attach_image(second_path.clone());
let placeholder2 = local_image_label_text( 2);
composer.apply_external_edit(format!("Keep {placeholder2}"));
let placeholder1 = local_image_label_text( 1);
assert_eq!(composer.current_text(), format!("Keep {placeholder1}"));
assert_eq!(composer.attachments.local_images.len(), 1);
assert_eq!(
composer.attachments.local_images[0].placeholder,
placeholder1
);
assert_eq!(composer.local_image_paths(), vec![second_path]);
assert_eq!(
composer.draft.textarea.element_payloads(),
vec![placeholder1]
);
}
#[test]
fn current_text_with_pending_expands_placeholders() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = "[Pasted Content 5 chars]".to_string();
composer.draft.textarea.insert_element(&placeholder);
composer
.draft
.pending_pastes
.push((placeholder.clone(), "hello".to_string()));
assert_eq!(
composer.current_text_with_pending(),
"hello".to_string(),
"placeholder should expand to actual text"
);
}
#[test]
fn current_text_with_pending_expands_overlapping_placeholders() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let first_paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let second_paste = "b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let base = format!("[Pasted Content {} chars]", first_paste.chars().count());
let second = format!("{base} #2");
composer.handle_paste(first_paste.clone());
composer.handle_paste(second_paste.clone());
assert_eq!(composer.current_text(), format!("{base}{second}"));
assert_eq!(
composer.current_text_with_pending(),
format!("{first_paste}{second_paste}")
);
}
#[test]
fn apply_external_edit_limits_duplicates_to_occurrences() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let placeholder = local_image_label_text( 1);
composer.draft.textarea.insert_element(&placeholder);
composer.attachments.local_images.push(AttachedImage {
placeholder: placeholder.clone(),
path: PathBuf::from("img.png"),
});
composer.apply_external_edit(format!("{placeholder} extra {placeholder}"));
assert_eq!(
composer.current_text(),
format!("{placeholder} extra {placeholder}")
);
assert_eq!(composer.attachments.local_images.len(), 1);
}
#[test]
fn remote_images_do_not_modify_textarea_text_or_elements() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_remote_image_urls(vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
]);
assert_eq!(composer.current_text(), "");
assert_eq!(composer.text_elements(), Vec::<TextElement>::new());
}
#[test]
fn attach_image_after_remote_prefix_uses_offset_label() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_remote_image_urls(vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
]);
composer.attach_image(PathBuf::from("/tmp/local.png"));
assert_eq!(
composer.attachments.local_images[0].placeholder,
"[Image #3]"
);
assert_eq!(composer.current_text(), "[Image #3]");
}
#[test]
fn prepare_submission_keeps_remote_offset_local_placeholder_numbering() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
let base_text = "[Image #2] hello".to_string();
let base_elements = vec![TextElement::new(
(0.."[Image #2]".len()).into(),
Some("[Image #2]".to_string()),
)];
composer.set_text_content(
base_text,
base_elements,
vec![PathBuf::from("/tmp/local.png")],
);
let (submitted_text, submitted_elements) = composer
.prepare_submission_text( true)
.expect("remote+local submission should be generated");
assert_eq!(submitted_text, "[Image #2] hello");
assert_eq!(
submitted_elements,
vec![TextElement::new(
(0.."[Image #2]".len()).into(),
Some("[Image #2]".to_string())
)]
);
}
#[test]
fn prepare_submission_with_only_remote_images_returns_empty_text() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
let (submitted_text, submitted_elements) = composer
.prepare_submission_text( true)
.expect("remote-only submission should be generated");
assert_eq!(submitted_text, "");
assert!(submitted_elements.is_empty());
}
#[test]
fn delete_selected_remote_image_relabels_local_placeholders() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_remote_image_urls(vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
]);
composer.attach_image(PathBuf::from("/tmp/local.png"));
composer.draft.textarea.set_cursor( 0);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(
composer.remote_image_urls(),
vec!["https://example.com/one.png".to_string()]
);
assert_eq!(composer.current_text(), "[Image #2]");
assert_eq!(
composer.attachments.local_images[0].placeholder,
"[Image #2]"
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(composer.remote_image_urls(), Vec::<String>::new());
assert_eq!(composer.current_text(), "[Image #1]");
assert_eq!(
composer.attachments.local_images[0].placeholder,
"[Image #1]"
);
}
#[test]
fn input_disabled_ignores_keypresses_and_hides_cursor() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("hello".to_string(), Vec::new(), Vec::new());
composer.set_input_enabled(
false,
Some("Input disabled for test.".to_string()),
);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(!needs_redraw);
assert_eq!(composer.current_text(), "hello");
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 5,
};
assert_eq!(composer.cursor_pos(area), None);
}
#[test]
fn shutdown_in_progress_disables_input_and_uses_hint_without_footer() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_text_content("hello".to_string(), Vec::new(), Vec::new());
composer.show_shutdown_in_progress();
assert!(!composer.input_enabled());
assert_eq!(composer.current_text(), "hello");
assert_eq!(composer.custom_footer_height(), Some(0));
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 5,
};
assert_eq!(composer.cursor_pos(area), None);
let mut terminal = Terminal::new(TestBackend::new(40, 5)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap();
insta::assert_snapshot!("shutdown_in_progress", terminal.backend());
}
}