Files
codex/codex-rs/tui/src/bottom_pane/chat_composer.rs
Eric Traut 7fa0007ea8 tui: split remaining composer draft and footer state (#22656)
## Why

[#22581](https://github.com/openai/codex/pull/22581) started separating
the chat composer’s responsibilities, but `ChatComposer` still owned the
remaining editable draft state alongside footer/status presentation
state. This follow-up makes those ownership lines explicit so future
composer changes have a smaller blast radius and `BottomPane` does not
need to keep exposing scattered draft getters.

This is just a refactor. No functional or behavioral changes are
intended.

## What changed

- Move the remaining editable composer state into
`bottom_pane/chat_composer/draft_state.rs`.
- Move footer and status-row presentation state into
`bottom_pane/chat_composer/footer_state.rs`.
- Add an internal `ComposerDraftSnapshot` for restore flows, replacing
several ad hoc `BottomPane` pass-through reads.
- Rewire the related history-search and thread-input restore paths to
use the extracted state.

## Verification

- `RUST_MIN_STACK=8388608 cargo test -p codex-tui`
- `cargo insta pending-snapshots`
2026-05-15 09:12:52 -07:00

10688 lines
409 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The chat composer is the bottom-pane text input state machine.
//!
//! It is responsible for:
//!
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
//! - Promoting typed slash commands into atomic elements when the command name is completed.
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
//!
//! # Key Event Routing
//!
//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a
//! popup-specific handler if a popup is visible and otherwise to
//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call
//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor.
//!
//! # History Navigation (↑/↓)
//!
//! The Up/Down history path is managed by [`ChatComposerHistory`]. It merges:
//!
//! - Persistent cross-session history (text-only; no element ranges or attachments).
//! - Local in-session history (full text + text elements + local/remote image attachments).
//!
//! When recalling a local entry, the composer rehydrates text elements and both attachment kinds
//! (local image paths + remote image URLs).
//! When recalling a persistent entry, only the text is restored.
//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like
//! history traversal semantics instead of dropping to column 0.
//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the
//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as
//! an editable draft and `Esc` restores the draft that was active when search started.
//!
//! Slash commands are staged for local history instead of being recorded immediately. Command
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after
//! `ChatWidget` dispatches the command.
//!
//! # Submission and Prompt Expansion
//!
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
//! running, `Tab` submits just like Enter so input is never dropped.
//! `Tab` does not submit when entering a `!` shell command.
//!
//! On submit/queue paths, the composer:
//!
//! - Expands pending paste placeholders so element ranges align with the final text.
//! - Trims whitespace and rebases text elements accordingly.
//! - Prunes local attached images so only placeholders that survive expansion are sent.
//! - Preserves remote image URLs as separate attachments even when text is empty.
//!
//! When these paths clear the visible textarea after a successful submit or slash-command
//! dispatch, they intentionally preserve the textarea kill buffer. That lets users `Ctrl+K` part
//! of a draft, perform a composer action such as changing reasoning level, and then `Ctrl+Y` the
//! killed text back into the now-empty draft.
//!
//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion
//! and attachment pruning, and clears pending paste state on success.
//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so
//! pasted content and text elements are preserved when extracting args.
//!
//! # Large Paste Placeholders
//!
//! Large pastes insert an element placeholder in the buffer and store the full text in
//! `pending_pastes`. The placeholder label is derived from the pasted character count:
//!
//! - First paste of a given size uses `[Pasted Content N chars]`.
//! - Additional pending pastes of the same size add a numeric suffix (`#2`, `#3`, ...), where the
//! next suffix is computed from the placeholders that still exist in `pending_pastes`.
//! - When all placeholders for a size are cleared or deleted, the next paste of that size reuses
//! the base label without a suffix.
//!
//! # Remote Image Rows (Up/Down/Delete)
//!
//! Remote image URLs are rendered as non-editable `[Image #N]` rows above the textarea (inside the
//! same composer block). These rows represent image attachments rehydrated from app-server/backtrack
//! history; TUI users can remove them, but cannot type into that row region.
//!
//! Keyboard behavior:
//!
//! - `Up` at textarea cursor `0` enters remote-row selection at the last remote image.
//! - `Up`/`Down` move selection between remote rows.
//! - `Down` on the last row clears selection and returns control to the textarea.
//! - `Delete`/`Backspace` remove the selected remote image row.
//!
//! Placeholder numbering is unified across remote and local images:
//!
//! - Remote rows occupy `[Image #1]..[Image #M]`.
//! - Local placeholders are offset after that range (`[Image #M+1]..`).
//! - Deleting a remote row relabels local placeholders to keep numbering contiguous.
//!
//! # Non-bracketed Paste Bursts
//!
//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of
//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event.
//!
//! To avoid misinterpreting these bursts as real typing (and to prevent transient UI effects like
//! shortcut overlays toggling on a pasted `?`), we feed "plain" character events into
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
//! through [`ChatComposer::handle_paste`].
//!
//! The burst detector intentionally treats ASCII and non-ASCII differently:
//!
//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the
//! stream is paste-like.
//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow
//! burst detection for actual paste streams.
//!
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the
//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input.
//!
//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`.
//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`.
//!
//! # PasteBurst Integration Points
//!
//! The burst detector is consulted in a few specific places:
//!
//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char
//! input to either buffer it or insert normally.
//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the
//! first char, while still allowing paste detection via retro-capture.
//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called
//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a
//! normal typed character.
//!
//! # Input Disabled Mode
//!
//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
//! overall state machine, since it affects which transitions are even possible from a given UI
//! state.
//!
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::has_ctrl_or_alt;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::ui_consts::FOOTER_INDENT_COLS;
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::command_popup::CommandItem;
use super::command_popup::CommandPopup;
use super::command_popup::CommandPopupFlags;
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::skill_popup::MentionItem;
use super::skill_popup::SkillPopup;
use super::slash_commands::BuiltinCommandFlags;
use super::slash_commands::ServiceTierCommand;
use super::slash_commands::SlashCommandItem;
use super::slash_commands::find_slash_command;
use super::slash_commands::has_slash_command_prefix;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::EditorKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::primary_binding;
use crate::onboarding::mark_underlined_hyperlink;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::style::user_message_style;
use codex_protocol::ThreadId;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_protocol::user_input::TextElement;
mod attachment_state;
mod draft_state;
mod footer_state;
mod history_search;
mod popup_state;
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::PopupState;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::MentionBinding;
use crate::bottom_pane::textarea::TextArea;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::skills_helpers::skill_display_name;
use crate::tui::FrameRequester;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_app_server_protocol::AppInfo;
#[cfg(test)]
use codex_core_skills::model::SkillInterface;
use codex_core_skills::model::SkillMetadata;
use codex_file_search::FileMatch;
#[cfg(test)]
use codex_plugin::AppConnectorId;
use codex_plugin::PluginCapabilitySummary;
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;
#[cfg(test)]
use ratatui::style::Color;
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
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)."
)
}
/// Result returned when the user interacts with the text area.
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted {
text: String,
text_elements: Vec<TextElement>,
},
Queued {
text: String,
text_elements: Vec<TextElement>,
action: QueuedInputAction,
},
/// A bare slash command parsed by the composer.
///
/// Callers that dispatch this variant are also responsible for resolving any pending local
/// command-history entry that the composer staged before clearing the visible input.
Command(SlashCommand),
/// A bare model service-tier command parsed by the composer.
ServiceTierCommand(ServiceTierCommand),
/// An inline slash command and its trimmed argument text.
///
/// The `TextElement` ranges are rebased into the argument string, while any pending local
/// command-history entry still represents the original command invocation that should be
/// committed only if dispatch accepts it.
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueuedInputAction {
Plain,
ParseSlash,
RunShell,
}
/// Feature flags for reusing the chat composer in other bottom-pane surfaces.
///
/// The default keeps today's behavior intact. Other call sites can opt out of
/// specific behaviors by constructing a config with those flags set to `false`.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ChatComposerConfig {
/// Whether command/file/skill popups are allowed to appear.
pub(crate) popups_enabled: bool,
/// Whether `/...` input is parsed and dispatched as slash commands.
pub(crate) slash_commands_enabled: bool,
/// Whether pasting a file path can attach local images.
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 {
/// A minimal preset for plain-text inputs embedded in other surfaces.
///
/// This disables popups, slash commands, and image-path attachment behavior
/// so the composer behaves like a simple notes field.
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,
is_task_running: bool,
/// Slash-command draft staged for local recall after application-level dispatch.
///
/// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can
/// prepare their argument text without also double-recording the full command invocation.
pending_slash_command_history: Option<HistoryEntry>,
// Monotonically increasing identifier for textarea elements we insert.
#[cfg(not(target_os = "linux"))]
next_element_id: u64,
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,
service_tier_commands_enabled: bool,
service_tier_commands: Vec<ServiceTierCommand>,
mentions_v2_enabled: bool,
goal_command_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_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,
}
#[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)]
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)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashValidation {
Immediate,
Deferred,
}
const FOOTER_SPACING_HEIGHT: u16 = 0;
/// Builds the one-line nudge that replaces the ambient footer without adding layout height.
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 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,
service_tier_commands_enabled: self.service_tier_commands_enabled,
goal_command_enabled: self.goal_command_enabled,
personality_command_enabled: self.personality_command_enabled,
realtime_conversation_enabled: self.realtime_conversation_enabled,
audio_device_selection_enabled: self.audio_device_selection_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(),
)
}
/// Construct a composer with explicit feature gating.
///
/// This enables reuse in contexts like request-user-input where we want
/// the same visuals and editing behavior without slash commands or popups.
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,
is_task_running: false,
pending_slash_command_history: None,
#[cfg(not(target_os = "linux"))]
next_element_id: 0,
skills: None,
plugins: None,
connectors_snapshot: None,
collaboration_modes_enabled: false,
config,
connectors_enabled: false,
plugins_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,
realtime_conversation_enabled: false,
audio_device_selection_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,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
this
}
#[cfg(not(target_os = "linux"))]
fn next_id(&mut self) -> String {
let id = self.next_element_id;
self.next_element_id = self.next_element_id.wrapping_add(1);
id.to_string()
}
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_mentions_v2_enabled(&mut self, enabled: bool) {
self.mentions_v2_enabled = enabled;
self.sync_popups();
}
/// Toggle composer-side image paste handling.
///
/// This only affects whether image-like paste content is converted into attachments; the
/// `ChatWidget` layer still performs capability checks before images are submitted.
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, mention) in elements {
if let Some(binding) = self.draft.mention_bindings.remove(&id)
&& binding.mention == mention
{
ordered.push(MentionBinding {
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;
}
/// Replace composer, editor, and footer-hint key bindings from one runtime snapshot.
///
/// Submit and queue bindings are cached here because composer dispatch must
/// check them before generic textarea editing. The embedded textarea receives
/// the same snapshot's editor bindings so a live remap cannot leave submit
/// keys updated while cursor/editing keys still use old defaults.
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_realtime_conversation_enabled(&mut self, enabled: bool) {
self.realtime_conversation_enabled = enabled;
}
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.audio_device_selection_enabled = enabled;
}
pub fn set_side_conversation_active(&mut self, active: bool) {
self.side_conversation_active = active;
}
/// Compatibility shim for tests that still toggle the removed steer mode flag.
#[cfg(test)]
pub fn set_steer_enabled(&mut self, _enabled: bool) {}
/// Centralized feature gating keeps config checks out of call sites.
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, /*textarea_right_reserve*/ 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(
/*top*/ 1,
LIVE_PREFIX_COLS,
/*bottom*/ 1,
/*right*/ 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, /*textarea_right_reserve*/ 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)
}
/// Returns true if the composer currently contains no user-entered input.
pub(crate) fn is_empty(&self) -> bool {
self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty()
}
/// Record local persistent-history metadata so the composer can navigate
/// cross-session history.
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);
}
/// Integrate an asynchronous response to an on-demand history lookup.
///
/// If the entry is present and the offset still matches the active history cursor, the
/// composer rehydrates the entry immediately. This path intentionally routes through
/// [`Self::apply_history_entry`] so cursor placement remains aligned with keyboard history
/// recall semantics.
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) => {
// Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting
// attachments), but local in-session ↑/↓ history can rehydrate elements and image paths.
self.apply_history_entry(entry);
true
}
HistoryEntryResponse::Search(result) => {
self.apply_history_search_result(result);
true
}
HistoryEntryResponse::Ignored => false,
}
}
/// Integrate pasted text into the composer.
///
/// Acts as the only place where paste text is integrated, both for:
///
/// - Real/explicit paste events surfaced by the terminal, and
/// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers
/// and later flushes here.
///
/// Behavior:
///
/// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder
/// element (expanded on submit) and stores the full text in `pending_pastes`.
/// - Otherwise, if the paste looks like an image path, attaches the image and inserts a
/// trailing space so the user can keep typing naturally.
/// - Otherwise, inserts the pasted text directly into the textarea.
///
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
/// the next user Enter key, then syncs popup state.
pub fn handle_paste(&mut self, pasted: String) -> bool {
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
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;
};
// normalize_pasted_path already handles Windows → WSL path conversion,
// so we can directly try to read the image dimensions.
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
}
}
}
/// Enable or disable paste-burst handling.
///
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
/// is unwanted or has already been handled elsewhere.
///
/// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it
/// cannot affect subsequent normal typing:
///
/// - First, flush any held/buffered text immediately via
/// [`PasteBurst::flush_before_modified_input`], and feed it through `handle_paste(String)`.
/// This preserves user input and routes it through the same integration path as explicit
/// pastes (large-paste placeholders, image-path detection, and popup sync).
/// - Then clear the burst timing and Enter-suppression window via
/// [`PasteBurst::clear_after_explicit_paste`].
///
/// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state
/// without emitting any buffered text, which can leave a non-empty buffer unable to flush
/// later (because `flush_if_due()` relies on `last_plain_char_time` to time out).
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();
}
}
/// Replace the composer content with text from an external editor.
/// Clears pending paste placeholders and keeps only attachments whose
/// placeholder labels still appear in the new text. Image placeholders
/// are renumbered to `[Image #M+1]..[Image #N]` (where `M` is the number of
/// remote images). Cursor is placed at the end after rebuilding elements.
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());
// Count placeholder occurrences in the new text.
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);
}
}
// Keep attachments only while we have matching occurrences left.
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;
// Rebuild textarea so placeholders become elements again.
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..]);
}
// Keep local image placeholders normalized in attachment order after the
// remote-image prefix.
self.attachments
.relabel_local_images(&mut self.draft.textarea);
self.draft
.textarea
.set_cursor(self.draft.textarea.text().len());
self.sync_popups();
}
/// Enable or disable Vim editing for the composer textarea.
///
/// The composer clears any in-flight paste-burst state when the mode
/// changes because Vim normal mode treats rapid character sequences as
/// commands, not as candidate literal paste text. It also resets transient
/// footer mode so the visible hints match the new editing surface.
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);
}
/// Toggle Vim editing and return the new enabled state.
///
/// This is the app-level command target for the configurable Vim toggle
/// keybinding; callers should use the returned value for status messages
/// instead of rereading state after additional composer mutations.
pub(crate) fn toggle_vim_enabled(&mut self) -> bool {
let enabled = !self.draft.textarea.is_vim_enabled();
self.set_vim_enabled(enabled);
enabled
}
/// Return whether Vim editing is enabled for tests that assert mode transitions.
#[cfg(test)]
pub(crate) fn is_vim_enabled(&self) -> bool {
self.draft.textarea.is_vim_enabled()
}
/// Return whether Escape should be routed to the textarea before popups.
///
/// Vim insert mode owns Escape as a transition back to normal mode. The app
/// event layer asks this before running generic Escape behavior so the same
/// key does not both leave insert mode and dismiss unrelated UI.
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
}
/// Returns whether the composer currently accepts interactive draft edits.
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();
}
/// Override the footer hint items displayed beneath the composer. Passing
/// `None` restores the default shortcut footer.
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
self.footer.hint_override = items;
}
/// Updates whether the Plan-mode nudge replaces the ambient footer row.
///
/// Returns `true` only when the rendered footer can change so callers can avoid scheduling
/// redundant redraws while reevaluating nudge policy on routine composer updates.
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);
}
/// Replace the entire composer content with `text` and reset cursor.
///
/// This is the "fresh draft" path: it clears pending paste payloads and
/// mention link targets. Callers restoring a previously submitted draft
/// that must keep `$name -> path` resolution should use
/// [`Self::set_text_content_with_mention_bindings`] instead.
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(),
);
}
/// Replace the entire composer content while restoring mention link targets.
///
/// Mention popup insertion stores both visible text (for example `$file`)
/// and hidden mention bindings used to resolve the canonical target during
/// submission. Use this method when restoring an interrupted or blocked
/// draft; if callers restore only text and images, mentions can appear
/// intact to users while resolving to the wrong target or dropping on
/// retry.
///
/// This helper intentionally places the cursor at the start of the restored text. Callers
/// that need end-of-line restore behavior (for example shell-style history recall) should call
/// [`Self::move_cursor_to_end`] after this method.
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>,
) {
// Clear any existing content, placeholders, and attachments first.
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(/*pos*/ 0);
self.sync_popups();
}
fn current_cursor(&self) -> usize {
self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 }
}
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();
}
/// Update the placeholder text without changing input enablement.
pub(crate) fn set_placeholder_text(&mut self, placeholder: String) {
self.placeholder_text = placeholder;
}
/// Move the cursor to the end of the current text buffer.
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();
}
/// Convert canonical composer text into the textarea's internal representation.
///
/// Shell mode stores the leading `!` as prompt state instead of editable text,
/// so full-buffer imports must absorb that prefix before rebuilding the textarea.
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, /*shift*/ -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)
}
/// Get the current composer text.
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()
}
}
/// Rehydrate a history entry into the composer with shell-like cursor placement.
///
/// This path restores text, elements, images, mention bindings, and pending paste payloads,
/// then moves the cursor to the active mode's history boundary. If a caller reused
/// [`Self::set_text_content_with_mention_bindings`] directly for history recall and forgot the
/// final cursor move, repeated Up/Down would stop navigating history because cursor-gating
/// treats interior positions as normal editing mode.
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)
}
/// Commit the staged slash-command draft to local Up-arrow recall.
///
/// Call this after command dispatch. Calling it more than once is harmless because the pending
/// slot is consumed on the first call.
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);
}
}
/// Insert an attachment placeholder and track it for the next submission.
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()
}
/// Flushes any due paste-burst state.
///
/// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits:
///
/// - If a burst times out, flush it via `handle_paste(String)`.
/// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it
/// as normal typed input.
///
/// This also allows a single "held" ASCII char to render even when it turns out not to be part
/// of a paste burst.
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
self.handle_paste_burst_flush(Instant::now())
}
/// Returns whether the composer is currently in any paste-burst related transient state.
///
/// This includes actively buffering, having a non-empty burst buffer, or holding the first
/// ASCII char for flicker suppression.
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.draft.paste_burst.is_active()
}
/// Returns a delay that reliably exceeds the paste-burst timing threshold.
///
/// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout.
pub(crate) fn recommended_paste_flush_delay() -> Duration {
PasteBurst::recommended_flush_delay()
}
/// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Only apply if user is still editing a token starting with `query`.
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);
}
_ => {}
}
}
/// Show the transient "press again to quit" hint for `key`.
///
/// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a
/// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear
/// even when the UI is otherwise idle.
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);
}
/// Clear the "press again to quit" hint immediately.
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);
}
/// Whether the quit shortcut hint should currently be shown.
///
/// This is time-based rather than event-based: it may become false without
/// any additional user input, so the UI schedules a redraw when the hint
/// expires.
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();
}
/// Handle a key event coming from the main UI.
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);
// Update (or hide/show) popup after processing the key.
self.sync_popups();
result
}
/// Return true if either the slash-command popup or the file-search popup is active.
pub(crate) fn popup_active(&self) -> bool {
self.history_search.is_some() || self.popups.active()
}
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_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::Command(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, ..
} => {
// Dismiss the slash popup; keep the current input untouched.
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
// Ensure popup filtering/selection reflects the latest composer text
// before applying completion.
let first_line = self.draft.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
if let Some(selected_cmd) = popup.selected_item() {
let selected_command_text = format!("/{}", selected_cmd.command());
if let CommandItem::Builtin(cmd) = selected_cmd
&& cmd == SlashCommand::Skills
{
self.stage_selected_slash_command_history(&CommandItem::Builtin(cmd));
self.draft.textarea.set_text_clearing_elements("");
self.draft.is_bash_mode = false;
return (InputResult::Command(cmd), true);
}
let starts_with_cmd =
first_line.trim_start().starts_with(&selected_command_text);
if !starts_with_cmd {
self.draft
.textarea
.set_text_clearing_elements(&format!("{selected_command_text} "));
if !self.draft.textarea.text().is_empty() {
self.draft
.textarea
.set_cursor(self.draft.textarea.text().len());
}
return (InputResult::None, true);
}
}
if self.is_task_running {
return self.handle_submission(/*should_queue*/ true);
}
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
..
} => {
// Treat "/" as accepting the highlighted command as text completion
// while the slash-command popup is active.
let first_line = self.draft.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
if let Some(selected_cmd) = popup.selected_item() {
let selected_command_text = format!("/{}", selected_cmd.command());
let starts_with_cmd =
first_line.trim_start().starts_with(&selected_command_text);
if !starts_with_cmd {
self.draft
.textarea
.set_text_clearing_elements(&format!("{selected_command_text} "));
self.draft.is_bash_mode = false;
}
if !self.draft.textarea.text().is_empty() {
self.draft
.textarea
.set_cursor(self.draft.textarea.text().len());
}
}
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
if let Some(sel) = popup.selected_item() {
self.stage_selected_slash_command_history(&sel);
self.draft.textarea.set_text_clearing_elements("");
self.draft.is_bash_mode = false;
return (
match sel {
CommandItem::Builtin(cmd) => InputResult::Command(cmd),
CommandItem::ServiceTier(command) => {
InputResult::ServiceTierCommand(command)
}
},
true,
);
}
// Fallback to default newline handling if no command selected.
self.handle_key_event_without_popup(key_event)
}
input => self.handle_input_basic(input),
}
}
#[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
}
/// Handle non-ASCII character input (often IME) while still supporting paste-burst detection.
///
/// This handler exists because non-ASCII input often comes from IMEs, where characters can
/// legitimately arrive in short bursts that should **not** be treated as paste.
///
/// The key differences from the ASCII path:
///
/// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a
/// non-ASCII char can feel like dropped input.
/// - If a burst is detected, we may need to retroactively remove already-inserted text before
/// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`).
///
/// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp
/// the cursor to a UTF-8 char boundary before slicing `textarea.text()`.
#[inline]
fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) {
if self.draft.disable_paste_burst {
// When burst detection is disabled, treat IME/non-ASCII input as normal typing.
// In particular, do not retro-capture or buffer already-inserted prefix text.
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);
}
// Non-ASCII input often comes from IMEs and can arrive in quick bursts.
// We do not want to hold the first char (flicker suppression) on this path, but we
// still want to detect paste-like bursts. Before applying any non-ASCII input, flush
// any existing burst buffer (including a pending first char from the ASCII path) so
// we don't carry that transient state forward.
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 } => {
// For non-ASCII we inserted prior chars immediately, so if this turns out
// to be paste-like we need to retroactively grab & remove the already-
// inserted prefix from the textarea before buffering the burst.
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, "");
}
// seed the paste burst buffer with everything (grabbed + new)
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
// If decide_begin_buffer opted not to start buffering,
// fall through to normal insertion below.
}
_ => 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)
}
/// Handle key events when file search popup is visible.
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, ..
} => {
// Hide popup without modifying text, remember token to avoid immediate reopen.
if let Some(tok) = Self::current_at_token(&self.draft.textarea) {
self.popups.dismissed_file_token = Some(tok);
}
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 selected path looks like an image (png/jpeg), attach as image instead of inserting text.
let is_image = Self::is_image_path(&sel_path);
if is_image {
// Determine dimensions; if that fails fall back to normal path insertion.
let path_buf = PathBuf::from(&sel_path);
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::debug!("selected image dimensions={}x{}", width, height);
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
// using the flat text and byte-offset cursor API.
let cursor_offset = self.draft.textarea.cursor();
let text = self.draft.textarea.text();
// Clamp to a valid char boundary to avoid panics when slicing.
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Determine token boundaries in the full text.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
self.draft.textarea.replace_range(start_idx..end_idx, "");
self.draft.textarea.set_cursor(start_idx);
self.attach_image(path_buf);
// Add a trailing space to keep typing fluid.
self.draft.textarea.insert_str(" ");
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
// Fallback to plain path insertion if metadata read fails.
self.insert_selected_path(&sel_path);
}
}
} else {
// Non-image: inserting file path.
self.insert_selected_path(&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(tok) = self.current_mention_token() {
self.popups.dismissed_mention_token = Some(tok);
}
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 {
self.insert_selected_mention(&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 ActivePopup::MentionV2(popup) = &mut self.popups.active else {
unreachable!();
};
let mut selected: Option<MentionV2Selection> = 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::Left,
modifiers: KeyModifiers::NONE,
..
} => {
popup.previous_search_mode();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} => {
popup.next_search_mode();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some(tok) = self.current_mentions_v2_token() {
self.popups.dismissed_mention_token = Some(tok);
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
selected = popup.selected();
close_popup = true;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
};
if close_popup {
if let Some(selected) = selected {
match selected {
MentionV2Selection::File(path) => {
self.insert_selected_file_path(path.to_string_lossy().as_ref());
}
MentionV2Selection::Tool { insert_text, path } => {
self.insert_selected_mention(&insert_text, path.as_deref());
}
}
}
self.popups.active = ActivePopup::None;
}
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 insert_selected_file_path(&mut self, selected_path: &str) {
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 cursor_offset = self.draft.textarea.cursor();
let text = self.draft.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
self.draft.textarea.replace_range(start_idx..end_idx, "");
self.draft.textarea.set_cursor(start_idx);
self.attach_image(path_buf);
self.draft.textarea.insert_str(" ");
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
self.insert_selected_path(selected_path);
}
}
} else {
self.insert_selected_path(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()
}
/// Expand large-paste placeholders using element ranges and rebuild other element spans.
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);
}
// Stage 1: index pending paste payloads by placeholder for deterministic replacements.
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());
}
// Stage 2: walk elements in order and rebuild text/spans in a single pass.
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 {
// Stage 3: inline actual paste payloads and drop their placeholder elements.
rebuilt.push_str(actual);
} else {
// Stage 4: keep non-paste elements, updating their byte ranges for the new text.
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;
}
// Stage 5: append any trailing text that followed the last element.
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
}
/// Extract a token prefixed with `prefix` under the cursor, if any.
///
/// The returned string **does not** include the prefix.
///
/// Behavior:
/// - The cursor may be anywhere *inside* the token (including on the
/// leading prefix). It does **not** need to be at the end of the line.
/// - A token is delimited by ASCII whitespace (space, tab, newline).
/// - If the cursor is on `prefix` inside an existing token (for example the
/// second `@` in `@scope/pkg@latest`), keep treating the surrounding
/// whitespace-delimited token as the active token rather than starting a
/// new token at that nested prefix.
/// - If the token under the cursor starts with `prefix`, that token is
/// returned without the leading prefix. When `allow_empty` is true, a
/// lone prefix character yields `Some(String::new())` to surface hints.
fn current_prefixed_token(
textarea: &TextArea,
prefix: char,
allow_empty: bool,
) -> Option<String> {
let cursor_offset = textarea.cursor();
let text = textarea.text();
// Adjust the provided byte offset to the nearest valid char boundary at or before it.
let mut safe_cursor = cursor_offset.min(text.len());
// If we're not on a char boundary, move back to the start of the current char.
if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) {
// Find the last valid boundary <= cursor_offset.
safe_cursor = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= cursor_offset)
.last()
.unwrap_or(0);
}
// Split the line around the (now safe) cursor position.
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Detect whether we're on whitespace at the cursor boundary.
let at_whitespace = if safe_cursor < text.len() {
text[safe_cursor..]
.chars()
.next()
.map(char::is_whitespace)
.unwrap_or(false)
} else {
false
};
// Left candidate: token containing the cursor position.
let start_left = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_left_rel = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_left = safe_cursor + end_left_rel;
let token_left = if start_left < end_left {
Some(&text[start_left..end_left])
} else {
None
};
// Right candidate: token immediately after any whitespace from the cursor.
let ws_len_right: usize = after_cursor
.chars()
.take_while(|c| c.is_whitespace())
.map(char::len_utf8)
.sum();
let start_right = safe_cursor + ws_len_right;
let end_right_rel = text[start_right..]
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(text.len() - start_right);
let end_right = start_right + end_right_rel;
let token_right = if start_right < end_right {
Some(&text[start_right..end_right])
} else {
None
};
let prefix_str = prefix.to_string();
let left_match = token_left.filter(|t| t.starts_with(prefix));
let right_match = token_right.filter(|t| t.starts_with(prefix));
let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string());
let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string());
if at_whitespace {
if right_prefixed.is_some() {
return right_prefixed;
}
if token_left.is_some_and(|t| t == prefix_str) {
return allow_empty.then(String::new);
}
return left_prefixed;
}
if after_cursor.starts_with(prefix) {
let prefix_starts_token = before_cursor
.chars()
.next_back()
.is_none_or(char::is_whitespace);
return if prefix_starts_token {
right_prefixed.or(left_prefixed)
} else {
left_prefixed
};
}
left_prefixed.or(right_prefixed)
}
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
fn current_at_token(textarea: &TextArea) -> Option<String> {
Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false)
}
fn current_mentions_v2_token(&self) -> Option<String> {
if !self.mentions_v2_enabled {
return None;
}
Self::current_prefixed_token(&self.draft.textarea, '@', /*allow_empty*/ true)
}
fn current_mention_token(&self) -> Option<String> {
if !self.mentions_enabled() {
return None;
}
Self::current_prefixed_token(&self.draft.textarea, '$', /*allow_empty*/ true)
}
/// Replace the active `@token` (the one under the cursor) with `path`.
///
/// The algorithm mirrors `current_at_token` so replacement works no matter
/// where the cursor is within the token and regardless of how many
/// `@tokens` exist in the line.
fn insert_selected_path(&mut self, path: &str) {
let cursor_offset = self.draft.textarea.cursor();
let text = self.draft.textarea.text();
// Clamp to a valid char boundary to avoid panics when slicing.
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
// Determine token boundaries.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
// If the path contains whitespace, wrap it in double quotes so the
// local prompt arg parser treats it as a single argument. Avoid adding
// quotes when the path already contains one to keep behavior simple.
let needs_quotes = path.chars().any(char::is_whitespace);
let inserted = if needs_quotes && !path.contains('"') {
format!("\"{path}\"")
} else {
path.to_string()
};
// Replace just the active `@token` so unrelated text elements, such as
// large-paste placeholders, remain atomic and can still expand on submit.
self.draft
.textarea
.replace_range(start_idx..end_idx, &format!("{inserted} "));
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
self.draft.textarea.set_cursor(new_cursor);
}
fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) {
let cursor_offset = self.draft.textarea.cursor();
let text = self.draft.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
// Remove the active token and insert the selected mention as an atomic element.
self.draft.textarea.replace_range(start_idx..end_idx, "");
self.draft.textarea.set_cursor(start_idx);
let id = self.draft.textarea.insert_element(insert_text);
if let (Some(path), Some(mention)) =
(path, Self::mention_name_from_insert_text(insert_text))
{
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
mention,
path: path.to_string(),
},
);
}
self.draft.textarea.insert_str(" ");
let new_cursor = start_idx
.saturating_add(insert_text.len())
.saturating_add(1);
self.draft.textarea.set_cursor(new_cursor);
}
fn mention_name_from_insert_text(insert_text: &str) -> Option<String> {
let name = insert_text.strip_prefix('$')?;
if name.is_empty() {
return None;
}
if name
.as_bytes()
.iter()
.all(|byte| is_mention_name_char(*byte))
{
Some(name.to_string())
} else {
None
}
}
fn current_mention_elements(&self) -> Vec<(u64, String)> {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
Self::mention_name_from_insert_text(snapshot.text.as_str())
.map(|mention| (snapshot.id, mention))
})
.collect()
}
fn snapshot_mention_bindings(&self) -> Vec<MentionBinding> {
let mut ordered = Vec::new();
for (id, mention) in self.current_mention_elements() {
if let Some(binding) = self.draft.mention_bindings.get(&id)
&& binding.mention == mention
{
ordered.push(MentionBinding {
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.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 {
mention: binding.mention,
path: binding.path,
},
);
scan_from = range.end;
}
}
}
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
/// On success, clears pending paste payloads because placeholders have been expanded.
///
/// When `record_history` is true, the final submission is stored for ↑/↓ recall.
fn prepare_submission_text(
&mut self,
record_history: bool,
) -> Option<(String, Vec<TextElement>)> {
self.prepare_submission_text_with_options(record_history, SlashValidation::Immediate)
}
fn prepare_submission_text_with_options(
&mut self,
record_history: bool,
slash_validation: SlashValidation,
) -> 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 !self.draft.pending_pastes.is_empty() {
// Expand placeholders so element byte ranges stay aligned.
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();
// If there is neither text nor attachments, suppress submission entirely.
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
if slash_validation == SlashValidation::Immediate
&& self.slash_commands_enabled()
&& let Some((name, _rest, _rest_offset)) = parse_slash_name(&text)
{
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_known = find_slash_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
.is_some();
if !is_known {
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, /*hint*/ 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: Vec::new(),
});
}
self.draft.pending_pastes.clear();
Some((text, text_elements))
}
/// Common logic for handling message submission/queuing.
/// Returns the appropriate InputResult based on `should_queue`.
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) {
if should_queue {
let raw_text = self.draft.textarea.text();
let defer_slash_validation =
self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text);
if let Some((text, text_elements)) = self.prepare_submission_text_with_options(
/*record_history*/ true,
if defer_slash_validation {
SlashValidation::Deferred
} else {
SlashValidation::Immediate
},
) {
let action = self.queued_input_action(&text, defer_slash_validation);
return (
InputResult::Queued {
text,
text_elements,
action,
},
true,
);
}
return (InputResult::None, true);
}
// If the first line is a bare built-in slash command (no args),
// dispatch it even when the slash popup isn't visible. This preserves
// the workflow: type a prefix ("/di"), press Tab to complete to
// "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond
// the '/name' token and our caret-based heuristic hides the popup,
// but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit
// literal text.
if let Some(result) = self.try_dispatch_bare_slash_command() {
return (result, true);
}
// If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat as paste inside a slash-command context.
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 !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);
}
// During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit.
if !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);
}
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(/*record_history*/ true)
{
if should_queue {
(
InputResult::Queued {
text,
text_elements,
action: QueuedInputAction::Plain,
},
true,
)
} else {
// Do not clear local attachments here; ChatWidget drains them via
// take_recent_submission_images().
(
InputResult::Submitted {
text,
text_elements,
},
true,
)
}
} else {
// Restore text if submission was suppressed.
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)
}
}
/// Check if the first line is a bare slash command (no args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
if !self.slash_commands_enabled() || self.draft.is_bash_mode {
return None;
}
let text = self.draft.textarea.text();
let first_line = text.lines().next().unwrap_or("");
let (name, rest, _rest_offset) = parse_slash_name(first_line)?;
if !rest.is_empty() {
return None;
}
let command = find_slash_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)?;
if command.supports_inline_args()
&& parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty())
{
return None;
}
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),
})
}
/// Check if the input is a slash command with args (e.g., /review args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_slash_command_with_args(&mut self) -> Option<InputResult> {
if !self.slash_commands_enabled() || self.draft.is_bash_mode {
return None;
}
let text = self.draft.textarea.text().to_string();
if text.starts_with(' ') {
return None;
}
let (name, rest, rest_offset) = parse_slash_name(&text)?;
if rest.is_empty() || name.contains('/') {
return None;
}
let command = find_slash_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)?;
if !command.supports_inline_args() {
return None;
}
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 = Self::slash_command_args_elements(
rest,
rest_offset,
&self.draft.textarea.text_elements(),
);
let trimmed_rest = rest.trim();
args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements);
let SlashCommandItem::Builtin(cmd) = command else {
return None;
};
Some(InputResult::CommandWithArgs(
cmd,
trimmed_rest.to_string(),
args_elements,
))
}
/// Expand pending placeholders and extract normalized inline-command args.
///
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
/// not consume user input. Once a command needs its args, this helper performs the usual
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
/// full-text offsets to command-arg offsets.
///
/// Callers that already staged slash-command history should normally pass `false` for
/// `record_history`; otherwise a command such as `/plan investigate` would be entered into
/// local recall through both the slash-command path and the message-submission path.
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) = parse_slash_name(&prepared_text)?;
let mut args_elements = Self::slash_command_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 should_parse_as_slash_on_dequeue_from_raw_text(&self, text: &str) -> bool {
self.slash_commands_enabled() && !text.starts_with(' ') && text.trim().starts_with('/')
}
fn queued_input_action(
&self,
prepared_text: &str,
defer_slash_validation: bool,
) -> QueuedInputAction {
if defer_slash_validation && prepared_text.starts_with('/') {
QueuedInputAction::ParseSlash
} else if prepared_text.starts_with('!') {
QueuedInputAction::RunShell
} else {
QueuedInputAction::Plain
}
}
/// Stage the current slash-command text for later local recall.
///
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
/// commits the staged entry after dispatch so command recall follows the submitted text, not
/// the command outcome.
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());
}
/// Stage a popup-selected command using its canonical command text.
///
/// Popup filtering text can be partial, so recording the selected command avoids recalling
/// `/di` after the user actually accepted `/diff`.
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()));
}
/// Store the provided command text and the current composer adornments in the pending slot.
///
/// The pending entry intentionally has the same shape as other local history entries so recall
/// can rehydrate attachments, mention bindings, and pending paste placeholders if command
/// workflows start carrying those through in the future.
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(),
});
}
/// Translate full-text element ranges into command-argument ranges.
///
/// `rest_offset` is the byte offset where `rest` begins in the full text.
fn slash_command_args_elements(
rest: &str,
rest_offset: usize,
text_elements: &[TextElement],
) -> Vec<TextElement> {
if rest.is_empty() || text_elements.is_empty() {
return Vec::new();
}
text_elements
.iter()
.filter_map(|elem| {
if elem.byte_range.end <= rest_offset {
return None;
}
let start = elem.byte_range.start.saturating_sub(rest_offset);
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
if start >= rest.len() {
return None;
}
end = end.min(rest.len());
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
})
.collect()
}
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)
}
/// Handle key event when no popup is visible.
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.is_bang_shell_command())
{
return self.handle_submission(self.is_task_running);
}
if self.submit_keys.is_pressed(key_event) {
return self.handle_submission(/*should_queue*/ false);
}
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()]))
}
/// Applies any due `PasteBurst` flush at time `now`.
///
/// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations.
///
/// Callers:
///
/// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render.
/// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag.
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,
}
}
/// Handles keys that mutate the textarea, including paste-burst detection.
///
/// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain
/// character streams are converted into explicit paste operations on terminals that do not
/// reliably provide bracketed paste.
///
/// Ordering is important:
///
/// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated
/// edits.
/// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input.
/// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key;
/// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a
/// timestamp to time out against.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// Ignore key releases here to avoid treating them as additional input
// (e.g., appending the same character twice via paste-burst logic).
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) {
// If we have a buffered non-bracketed paste burst and enough time has
// elapsed since the last char, flush it before handling a new input.
self.handle_paste_burst_flush(now);
if !matches!(input.code, KeyCode::Esc) {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
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);
}
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
//
// This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their
// normal semantics, and so we can aggressively flush/clear any burst state when non-char
// keys are pressed.
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()
{
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
// holding the first char while still allowing burst detection for paste input.
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);
}
// If decide_begin_buffer opted not to start buffering,
// fall through to normal insertion below.
}
CharDecision::BeginBufferFromPending => {
// First char was held; now append the current one.
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::RetainFirstChar => {
// Keep the first fast char pending momentarily.
return (InputResult::None, true);
}
}
}
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
}
// Flush any buffered burst before applying a non-char input (arrow keys, etc).
//
// `clear_window_after_non_char()` clears `last_plain_char_time`. If we cleared that while
// `PasteBurst.buffer` is non-empty, `flush_if_due()` would no longer have a timestamp to
// time out against, and the buffered paste could remain stuck until another plain char
// arrives.
if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter)
&& let Some(pasted) = self.draft.paste_burst.flush_before_modified_input()
{
self.handle_paste(pasted);
}
// For non-char inputs (or after flushing), handle normally.
// Track element removals so we can drop any corresponding placeholders without scanning
// the full text. (Placeholders are atomic elements; when deleted, the element disappears.)
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);
}
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
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 => {
// Keep burst window alive (supports blank lines in paste).
}
_ => {
// Other keys: clear burst window (buffer should have been flushed above if needed).
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);
}
/// Handle the dedicated shortcut-overlay toggle key(s).
///
/// This only toggles when the composer is empty and no paste burst is in
/// progress, so typing/pasting `?` still inserts text instead of opening
/// help. The bound key list intentionally supports terminal-variant
/// modifier reporting (for example `?` vs `shift-?`).
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::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,
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(),
}
}
/// Resolve the effective footer mode via a small priority waterfall.
///
/// The base mode is derived solely from whether the composer is empty:
/// `ComposerEmpty` iff empty, otherwise `ComposerHasDraft`. Transient
/// modes (Esc hint, overlay, quit reminder) can override that base when
/// their conditions are active.
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 mentions_v2_token = self.current_mentions_v2_token();
let file_token = if self.mentions_v2_enabled {
None
} else {
Self::current_at_token(&self.draft.textarea)
};
let browsing_history = self
.history
.should_handle_navigation(&self.current_text(), self.history_navigation_cursor());
// When browsing input history (shell-style Up/Down recall), skip all popup
// synchronization so nothing steals focus from continued history navigation.
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 mention_token = self.current_mention_token();
let allow_command_popup = self.slash_commands_enabled()
&& !self.draft.is_bash_mode
&& file_token.is_none()
&& mentions_v2_token.is_none()
&& mention_token.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(token) = mentions_v2_token {
self.sync_mentions_v2_popup(token);
return;
}
if let Some(token) = mention_token {
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(token);
return;
}
self.popups.dismissed_mention_token = None;
if let Some(token) = file_token {
self.sync_file_search_popup(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;
}
}
/// Keep slash command elements aligned with the current first line.
fn sync_slash_command_elements(&mut self) {
if !self.slash_commands_enabled() {
return;
}
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 desired_range = self.slash_command_element_range(first_line);
// Slash commands are only valid at byte 0 of the first line.
// Any slash-shaped element not matching the current desired prefix is stale.
let mut has_desired = false;
let mut stale_ranges = Vec::new();
for elem in self.draft.textarea.text_elements() {
let Some(payload) = elem.placeholder(text) else {
continue;
};
if payload.strip_prefix('/').is_none() {
continue;
}
let range = elem.byte_range.start..elem.byte_range.end;
if desired_range.as_ref() == Some(&range) {
has_desired = true;
} else {
stale_ranges.push(range);
}
}
for range in stale_ranges {
self.draft.textarea.remove_element_range(range);
}
if let Some(range) = desired_range
&& !has_desired
{
self.draft.textarea.add_element_range(range);
}
}
fn slash_command_element_range(&self, first_line: &str) -> Option<Range<usize>> {
if self.draft.is_bash_mode {
return None;
}
let (name, _rest, _rest_offset) = parse_slash_name(first_line)?;
if name.contains('/') {
return None;
}
let element_end = 1 + name.len();
let has_space_after = first_line
.get(element_end..)
.and_then(|tail| tail.chars().next())
.is_some_and(char::is_whitespace);
if !has_space_after {
return None;
}
if self.is_known_slash_name(name) {
Some(0..element_end)
} else {
None
}
}
fn is_known_slash_name(&self, name: &str) -> bool {
find_slash_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
.is_some()
}
/// If the cursor is currently within a slash command on the first line,
/// extract the command name and the rest of the line after it.
/// Returns None if the cursor is outside a slash command.
fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> {
if !first_line.starts_with('/') {
return None;
}
let name_start = 1usize;
let name_end = first_line[name_start..]
.find(char::is_whitespace)
.map(|idx| name_start + idx)
.unwrap_or_else(|| first_line.len());
if cursor > name_end {
return None;
}
let name = &first_line[name_start..name_end];
let rest_start = first_line[name_end..]
.find(|c: char| !c.is_whitespace())
.map(|idx| name_end + idx)
.unwrap_or(name_end);
let rest = &first_line[rest_start..];
Some((name, rest))
}
/// Heuristic for whether the typed slash command looks like a valid
/// prefix for any known built-in command.
/// Empty names only count when there is no extra content after the '/'.
fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool {
if !self.slash_commands_enabled() {
return false;
}
if name.is_empty() {
return rest_after_name.is_empty();
}
has_slash_command_prefix(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
fn sync_command_popup(&mut self, allow: bool) {
if !allow {
if matches!(self.popups.active, ActivePopup::Command(_)) {
self.popups.active = ActivePopup::None;
}
return;
}
// Determine whether the caret is inside the initial '/name' token on the first line.
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 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_command_under_cursor(first_line, cursor)
.is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest));
// If the cursor is currently positioned within an `@token`, prefer the
// file-search popup over the slash popup so users can insert a file path
// as an argument to the command (e.g., "/review @docs/...").
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 {
popup.on_composer_text_change(first_line.to_string());
} else {
self.popups.active = ActivePopup::None;
}
}
_ => {
if is_editing_slash_command_name {
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let connectors_enabled = self.connectors_enabled;
let plugins_command_enabled = self.plugins_command_enabled;
let service_tier_commands_enabled = self.service_tier_commands_enabled;
let goal_command_enabled = self.goal_command_enabled;
let personality_command_enabled = self.personality_command_enabled;
let realtime_conversation_enabled = self.realtime_conversation_enabled;
let audio_device_selection_enabled = self.audio_device_selection_enabled;
let mut command_popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
plugins_command_enabled,
service_tier_commands_enabled,
goal_command_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
side_conversation_active: self.side_conversation_active,
},
self.service_tier_commands.clone(),
);
command_popup.on_composer_text_change(first_line.to_string());
self.popups.active = ActivePopup::Command(command_popup);
}
}
}
}
/// Synchronize `self.file_search_popup` with the current text in the textarea.
/// Note this is only called when the active popup is NOT Command.
fn sync_file_search_popup(&mut self, query: String) {
// If user dismissed popup for this exact query, don't reopen until text changes.
if self.popups.dismissed_file_token.as_ref() == Some(&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, query: String) {
if self.popups.dismissed_mention_token.as_ref() == Some(&query) {
return;
}
let mentions = 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, query: String) {
if self.popups.dismissed_mention_token.as_ref() == Some(&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 = codex_connectors::metadata::connector_display_label(connector);
let description = Some(Self::connector_brief_description(connector));
let slug = codex_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 };
// Avoid leaving interactive popups open while input is blocked.
if !enabled && self.popups.active() {
self.popups.active = ActivePopup::None;
}
}
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
}
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
}
/// Replaces the contextual footer label for the currently viewed agent.
///
/// Returning `false` means the value was unchanged, so callers can skip redraw work. This
/// field is intentionally just cached presentation state; `ChatComposer` does not infer which
/// thread is active on its own.
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())
}
#[cfg(not(target_os = "linux"))]
impl ChatComposer {
pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
self.draft.textarea.update_named_element_by_id(id, text)
}
pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
let id = self.next_id();
self.draft.textarea.insert_named_element(text, id.clone());
id
}
pub fn remove_recording_meter_placeholder(&mut self, id: &str) {
let _ = self.draft.textarea.replace_element_by_id(id, "");
}
}
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 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 mut index = from;
while index < bytes.len() {
if bytes[index] != b'$' {
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;
}
if bytes
.get(end)
.is_none_or(|byte| !is_mention_name_char(*byte))
{
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, /*textarea_right_reserve*/ 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, /*textarea_right_reserve*/ 0)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_with_mask(area, buf, /*mask_char*/ 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, /*textarea_right_reserve*/ 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(/*show_cycle_hint*/ 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 => {
// Both of these modes render the single-line footer style (with
// either the shortcuts hint or the optional queue hint). We still
// want the single-line collapse rules so the mode label can win over
// the context indicator on narrow widths.
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 let Some(mask_char) = mask_char {
self.draft
.textarea
.render_ref_masked(textarea_rect, buf, &mut state, mask_char);
} else {
let highlight_ranges = self.history_search_highlight_ranges();
if highlight_ranges.is_empty() {
StatefulWidgetRef::render_ref(
&(&self.draft.textarea),
textarea_rect,
buf,
&mut state,
);
} else {
let highlight_style =
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
let highlights = highlight_ranges
.into_iter()
.map(|range| (range, highlight_style))
.collect::<Vec<_>>();
self.draft.textarea.render_ref_styled_with_highlights(
textarea_rect,
buf,
&mut state,
Style::default(),
&highlights,
);
}
}
if 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::test_support::PathBufExt;
use crate::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::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::textarea::TextArea;
use codex_protocol::models::local_image_label_text;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn footer_hint_row_is_separated_from_composer() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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:?}",
);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn remove_recording_meter_placeholder_clears_placeholder_text() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let id = composer.insert_recording_meter_placeholder("⠤⠤⠤⠤");
composer.remove_recording_meter_placeholder(&id);
assert_eq!(composer.draft.textarea.text(), "");
assert!(composer.draft.textarea.named_element_range(&id).is_none());
}
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
enhanced_keys_supported,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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,
/*width*/ 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",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_esc_backtrack_hint(/*show*/ true);
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_ctrl_c_quit",
/*enhanced_keys_supported*/ true,
|composer| {
composer.show_quit_shortcut_hint(
key_hint::ctrl(KeyCode::Char('c')),
/*has_focus*/ true,
);
},
);
snapshot_composer_state(
"footer_mode_ctrl_c_interrupt",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_task_running(/*running*/ true);
composer.show_quit_shortcut_hint(
key_hint::ctrl(KeyCode::Char('c')),
/*has_focus*/ true,
);
},
);
snapshot_composer_state(
"footer_mode_ctrl_c_then_esc_hint",
/*enhanced_keys_supported*/ true,
|composer| {
composer.show_quit_shortcut_hint(
key_hint::ctrl(KeyCode::Char('c')),
/*has_focus*/ true,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_esc_hint_from_overlay",
/*enhanced_keys_supported*/ 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",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_esc_backtrack_hint(/*show*/ true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
},
);
snapshot_composer_state(
"footer_mode_overlay_then_external_esc_hint",
/*enhanced_keys_supported*/ true,
|composer| {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
composer.set_esc_backtrack_hint(/*show*/ true);
},
);
snapshot_composer_state(
"footer_mode_hidden_while_typing",
/*enhanced_keys_supported*/ true,
|composer| {
type_chars_humanlike(composer, &['h']);
},
);
snapshot_composer_state(
"footer_mode_history_search",
/*enhanced_keys_supported*/ 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_shell_command_absorbs_bang",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_status_line_enabled(/*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",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_status_line_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_status_line_enabled(/*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)
);
}
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let url = "https://github.com/openai/codex/pull/20252";
composer.set_status_line_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*enabled*/ true);
composer.set_collaboration_mode_indicator(indicator);
composer.set_context_window(Some(context_percent), /*used_tokens*/ None);
}
// Empty textarea, agent idle: shortcuts hint can show, and cycle hint is hidden.
snapshot_composer_state_with_width(
"footer_collapse_empty_full",
/*width*/ 120,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 100, /*indicator*/ None,
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_empty_mode_cycle_with_context",
/*width*/ 60,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 100, /*indicator*/ None,
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_empty_mode_cycle_without_context",
/*width*/ 44,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 100, /*indicator*/ None,
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_empty_mode_only",
/*width*/ 26,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 100, /*indicator*/ None,
);
},
);
// Empty textarea, plan mode idle: shortcuts hint and cycle hint are available.
snapshot_composer_state_with_width(
"footer_collapse_plan_empty_full",
/*width*/ 120,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 100,
Some(CollaborationModeIndicator::Plan),
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_empty_mode_cycle_with_context",
/*width*/ 60,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 100,
Some(CollaborationModeIndicator::Plan),
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_empty_mode_cycle_without_context",
/*width*/ 44,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 100,
Some(CollaborationModeIndicator::Plan),
);
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_empty_mode_only",
/*width*/ 26,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 100,
Some(CollaborationModeIndicator::Plan),
);
},
);
// Textarea has content, agent running: queue hint is shown.
snapshot_composer_state_with_width(
"footer_collapse_queue_full",
/*width*/ 120,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 98, /*indicator*/ None,
);
composer.set_task_running(/*running*/ true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_short_with_context",
/*width*/ 50,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 98, /*indicator*/ None,
);
composer.set_task_running(/*running*/ true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_message_without_context",
/*width*/ 40,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 98, /*indicator*/ None,
);
composer.set_task_running(/*running*/ true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_short_without_context",
/*width*/ 30,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 98, /*indicator*/ None,
);
composer.set_task_running(/*running*/ true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_queue_mode_only",
/*width*/ 20,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer, /*context_percent*/ 98, /*indicator*/ None,
);
composer.set_task_running(/*running*/ true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
// Textarea has content, plan mode active, agent running: queue hint + mode.
snapshot_composer_state_with_width(
"footer_collapse_plan_queue_full",
/*width*/ 120,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running(/*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",
/*width*/ 50,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running(/*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",
/*width*/ 40,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running(/*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",
/*width*/ 30,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running(/*running*/ true);
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
},
);
snapshot_composer_state_with_width(
"footer_collapse_plan_queue_mode_only",
/*width*/ 20,
/*enhanced_keys_supported*/ true,
|composer| {
setup_collab_footer(
composer,
/*context_percent*/ 98,
Some(CollaborationModeIndicator::Plan),
);
composer.set_task_running(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_collaboration_modes_enabled(/*enabled*/ true);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ true,
);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['d']);
composer
.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), /*has_focus*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_steer_enabled(/*enabled*/ true);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_steer_enabled(/*enabled*/ true);
composer.set_task_running(/*running*/ true);
composer.set_vim_enabled(/*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(/*should_queue*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_steer_enabled(/*enabled*/ true);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_vim_enabled(/*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::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let path = PathBuf::from("example.png");
composer.attach_image(path.clone());
let placeholder = local_image_label_text(/*label_number*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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]);
}
/// Behavior: `?` toggles the shortcut overlay only when the composer is otherwise empty. After
/// any typing has occurred, `?` should be inserted as a literal character.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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);
// Toggle back to prompt mode so subsequent typing captures characters.
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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);
}
/// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut
/// overlay; it should be treated as part of the pasted content.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Force an active paste burst so this test doesn't depend on tight timing.
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_connectors_enabled(/*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,
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_connectors_enabled(/*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,
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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()));
}
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_connectors_enabled(/*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::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,
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",
/*enhanced_keys_supported*/ 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 mention_popup_type_prefixes_snapshot() {
snapshot_composer_state_with_width(
"mention_popup_type_prefixes",
/*width*/ 72,
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_connectors_enabled(/*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::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,
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_connectors_enabled(/*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,
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay);
composer.set_task_running(/*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![
// Valid @ tokens
("@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",
),
// Unicode examples
("@İ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"),
// Invalid cases (should return None)
("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![
// Different cursor positions within a token
("@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"),
// Multiple tokens - cursor determines which token
("@file1 @file2", 0, Some("file1".to_string()), "First token"),
(
"@file1 @file2",
8,
Some("file2".to_string()),
"Second token",
),
// Edge cases
("@", 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![
// Space boundaries
(
"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",
),
// Full-width space boundaries
(
"test @İstanbul",
8,
Some("İstanbul".to_string()),
"@ token after full-width space",
),
(
"@ЙЦУ @诶",
10,
Some("".to_string()),
"Full-width space between Unicode tokens",
),
// Tab and newline boundaries
(
"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 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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"),
}
}
/// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII
/// char arrives next, the pending ASCII char should still be preserved and the overall input
/// should submit normally (i.e. we should not misclassify this as a paste burst).
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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"),
}
}
/// Behavior: a single non-ASCII char should be inserted immediately (IME-friendly) and should
/// not create any paste-burst state.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.text(), "");
assert!(!composer.is_in_paste_burst());
}
/// Behavior: while we're capturing a paste-like burst, Enter should be treated as a newline
/// within the burst (not as "submit"), and the whole payload should flush as one paste.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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");
}
/// Behavior: a paste-like burst may include a full-width/ideographic space (U+3000). It should
/// still be captured as a single paste payload and preserve the exact Unicode content.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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");
}
/// Behavior: a large multi-line payload containing both non-ASCII and ASCII (e.g. "UTF-8",
/// "Unicode") should be captured as a single paste-like burst, and Enter key events should
/// become `\n` within the buffered content.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Force an active burst so the test doesn't depend on timing heuristics.
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);
}
/// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a
/// newline into the buffered payload and flush as a single paste later.
#[test]
fn ascii_burst_treats_enter_as_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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
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(/*should_queue*/ 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");
}
/// Behavior: even if Enter suppression would normally be active for a burst, Enter should
/// still dispatch a built-in slash command when the first line begins with `/`.
#[test]
fn slash_context_enter_ignores_paste_burst_enter_suppression() {
use crate::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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)));
}
/// Behavior: if a burst is buffering text and the user presses a non-char key, flush the
/// buffered burst *before* applying that key so the buffer cannot get stuck.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Force an active burst so we can deterministically buffer characters without relying on
// timing.
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());
}
/// Behavior: enabling `disable_paste_burst` flushes any held first character (flicker
/// suppression) and then inserts subsequent chars immediately without creating burst state.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// First ASCII char is normally held briefly. Flip the config mid-stream and ensure the
// held char is not dropped.
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(/*disabled*/ 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());
}
/// Behavior: a small explicit paste inserts text directly (no placeholder), and the submitted
/// text matches what is visible in the textarea.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw);
assert_eq!(composer.draft.textarea.text(), "hello");
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, "hello"),
_ => 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Ensure composer is empty and press Enter.
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:?}"),
}
}
/// Behavior: a large explicit paste inserts a placeholder into the textarea, stores the full
/// content in `pending_pastes`, and expands the placeholder to the full content on submit.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*width*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*width*/ 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");
}
/// Behavior: editing that removes a paste placeholder should also clear the associated
/// `pending_pastes` entry so it cannot be submitted accidentally.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.handle_paste(large);
assert_eq!(composer.draft.pending_pastes.len(), 1);
// Any edit that removes the placeholder should clear pending_paste
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 {
// Create a fresh composer for each test case
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender.clone(),
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
if let Some(text) = input {
composer.handle_paste(text);
} else if name == "multiple_pastes" {
// First large paste
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3));
// Second large paste
composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7));
// Small paste
composer.handle_paste(" another short paste".to_string());
} else if name == "backspace_after_pastes" {
// Three large 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));
// Move cursor to end and press backspace
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",
/*enhanced_keys_supported*/ false,
|composer| {
composer.attach_image(PathBuf::from("/tmp/image1.png"));
},
);
snapshot_composer_state(
"image_placeholder_multiple",
/*enhanced_keys_supported*/ 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",
/*enhanced_keys_supported*/ 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",
/*enhanced_keys_supported*/ 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(/*pos*/ 0);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
},
);
snapshot_composer_state(
"remote_image_rows_after_delete_first",
/*enhanced_keys_supported*/ 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(/*pos*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Type "/mo" humanlike so paste-burst doesnt interfere.
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}"));
// Visual snapshot should show the slash popup with /model as the first entry.
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Type "/res" humanlike so paste-burst doesnt interfere.
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");
// Snapshot should show /resume as the first entry for /res.
insta::assert_snapshot!("slash_popup_res", 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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 service_tier_slash_command_dispatches_from_catalog_name() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_service_tier_commands_enabled(/*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()
}
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Type the slash command.
type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']);
// Press Enter to dispatch the selected command.
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// When a slash command is dispatched, the composer should return a
// Command result (not submit literal text) and clear its textarea.
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::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_steer_enabled(true);
composer.draft.textarea.insert_str("restore me");
composer.draft.textarea.set_cursor(/*pos*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.draft.textarea.insert_str("restore me");
composer.draft.textarea.set_cursor(/*pos*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*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(/*width*/ 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 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*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::key_hint;
use crate::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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::key_hint;
use crate::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*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::key_hint;
use crate::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Type a prefix and complete with Tab, which inserts a trailing space
// and moves the cursor beyond the '/name' token (hides the popup).
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 ");
// Press Enter: should dispatch the command, not submit literal text.
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::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_collaboration_modes_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_collaboration_modes_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*pos*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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::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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_collaboration_modes_enabled(/*enabled*/ true);
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
let placeholder = local_image_label_text(/*label_number*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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: codex_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"),
}
}
/// Behavior: multiple paste operations can coexist; placeholders should be expanded to their
/// original content on submission.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Define test cases: (paste content, is_large)
let test_cases = [
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
(" and ".to_string(), false),
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
];
// Expected states after each paste
let mut expected_text = String::new();
let mut expected_pending_count = 0;
// Apply all pastes and build expected state
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();
// Verify all intermediate states were correct
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
),
]
);
// Submit and verify final expansion
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Define test cases: (content, is_large)
let test_cases = [
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
(" and ".to_string(), false),
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
];
// Apply all pastes
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();
// Delete placeholders one by one and collect states
let mut deletion_states = vec![];
// First deletion
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(),
));
// Second deletion
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(),
));
// Verify all states
assert_eq!(
deletion_states,
vec![
(" and [Pasted Content 1006 chars]".to_string(), 1),
(" and ".to_string(), 0),
]
);
}
/// Behavior: if multiple large pastes share the same placeholder label (same char count),
/// deleting one placeholder removes only its corresponding `pending_pastes` entry.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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);
}
/// Behavior: large-paste placeholder numbering continues when another placeholder of the
/// same length still exists, so a new paste gets a new unique placeholder label.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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);
}
/// Behavior: if all placeholders of a given length are removed, numbering resets to the
/// base placeholder on the next paste.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Define test cases: (cursor_position_from_end, expected_pending_count)
let test_cases = [
5, // Delete from middle - should clear tracking
0, // Delete from end - should clear tracking
];
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), // After deleting from middle
(false, 0), // After deleting from end
]
);
}
// --- Image attachment tests ---
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let mention_bindings = vec![MentionBinding {
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*record_history*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*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::key_hint;
use crate::keymap::RuntimeKeymap;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer
.draft
.textarea
.set_text_clearing_elements("one\ntwo");
composer.draft.textarea.set_cursor(/*pos*/ 0);
composer.set_vim_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("hello".to_string(), Vec::new(), Vec::new());
composer.set_vim_enabled(/*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::key_hint;
use crate::keymap::RuntimeKeymap;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*pos*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let placeholder = local_image_label_text(/*label_number*/ 1);
let text = format!("{placeholder} restored");
let text_elements = vec![TextElement::new(
(0..placeholder.len()).into(),
/*placeholder*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*pos*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let path = PathBuf::from("/tmp/image3.png");
composer.attach_image(path.clone());
let placeholder = composer.attachments.local_images[0].placeholder.clone();
// Case 1: backspace at end
composer
.draft
.textarea
.move_cursor_to_end_of_line(/*move_down_at_eol*/ 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());
// Re-add and ensure backspace at element start does not delete the placeholder.
composer.attach_image(path);
let placeholder2 = composer.attachments.local_images[0].placeholder.clone();
// Move cursor to roughly middle of placeholder
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Insert an image placeholder at the start
let path = PathBuf::from("/tmp/image_multibyte.png");
composer.attach_image(path);
// Add multibyte text after the placeholder
composer.draft.textarea.insert_str("日本語");
// Cursor is at end; pressing backspace should delete the last character
// without panicking and leave the placeholder intact.
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let path1 = PathBuf::from("/tmp/image_dup1.png");
let path2 = PathBuf::from("/tmp/image_dup2.png");
composer.attach_image(path1);
// separate placeholders with a space for clarity
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);
// Backspace should delete the first placeholder and its mapping.
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let path1 = PathBuf::from("/tmp/image_first.png");
let path2 = PathBuf::from("/tmp/image_second.png");
let placeholder1 = local_image_label_text(/*label_number*/ 1);
let placeholder2 = local_image_label_text(/*label_number*/ 2);
// Placeholders can be reordered in the text buffer; deleting image #1 should renumber
// image #2 wherever it appears, not just after the cursor.
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let path1 = PathBuf::from("/tmp/image_first.png");
let path2 = PathBuf::from("/tmp/image_second.png");
// Insert two adjacent atomic elements.
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);
// Delete the first element using normal textarea editing (forward Delete at cursor start).
composer.draft.textarea.set_cursor(/*pos*/ 0);
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
// Remaining image should be renumbered and the textarea element updated.
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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:?}"),
}
}
/// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst
/// follows, it should eventually flush as normal typed input (not as a paste).
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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());
}
/// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If
/// the payload is small, it should insert directly (no placeholder).
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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"
);
}
/// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If
/// the payload is large, it should insert a placeholder and defer the full text until submit.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder
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;
}
// Nothing should appear until we stop and flush
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'));
}
/// Behavior: human-like typing (with delays between chars) should not be classified as a paste
/// burst. Characters should appear immediately and should not trigger a paste placeholder.
#[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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Simulate history-like content: "/ test"
composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new());
// After set_text_content -> sync_popups is called; popup should NOT be Command.
assert!(
matches!(composer.popups.active, ActivePopup::None),
"expected no slash popup for '/ test'"
);
// Up should be handled by history navigation path, not slash popup handler.
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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tokio::sync::mpsc::unbounded_channel;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
// Case 1: bare "/"
composer.set_text_content("/".to_string(), Vec::new(), Vec::new());
assert!(
matches!(composer.popups.active, ActivePopup::Command(_)),
"bare '/' should activate slash popup"
);
// Case 2: valid prefix "/re" (matches /review, /resume, etc.)
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"
);
// Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback)
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"
);
// Case 4: invalid prefix "/zzz" still allowed to open popup if it
// matches no built-in command; our current logic will not open popup.
// Verify that explicitly.
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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_collaboration_modes_enabled(/*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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let placeholder = local_image_label_text(/*label_number*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let placeholder = local_image_label_text(/*label_number*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*label_number*/ 2);
composer.apply_external_edit(format!("Keep {placeholder2}"));
let placeholder1 = local_image_label_text(/*label_number*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let placeholder = local_image_label_text(/*label_number*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*record_history*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
let (submitted_text, submitted_elements) = composer
.prepare_submission_text(/*record_history*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ 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(/*pos*/ 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(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("hello".to_string(), Vec::new(), Vec::new());
composer.set_input_enabled(
/*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);
}
}