Files
codex/codex-rs/tui/src/bottom_pane/chat_composer.rs
T
Joe Savona cfc11eb483 Allow extension status content to use multiple footer rows (#60032)
## Why

Extension status content was limited to one row, clipping multiline text and content that wraps at narrow terminal widths.

## What changed

Measure extension status content at the available width and reserve up to four rows in both shared and separate status layouts. Account for mode indicators and warnings, and remeasure when content or available width changes.

Extra status rows yield to the draft and other composer surfaces when vertical space is limited. An unfocused composer keeps a single dim status row.

## Testing

Add regression coverage for resizing, wrapping beside mode indicators and warnings, hyperlink preservation, limited vertical space, cursor placement, footer overrides, focus changes, theme updates, and reasoning effort changes.

GitOrigin-RevId: 6a637b5cf6f34684639a92d920da4795c90abb6f
2026-11-07 02:01:00 +00:00

12853 lines
496 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.
//! Rich registers retain original payloads across draft clears and composer replacement;
//! only the live register transfers, not undo or repeat history. Copies receive fresh IDs and labels
//! before ordinary attachment pruning, including after each semantic repeat action.
//!
//! It edits [`TextArea`] and attachments, routes popup keys, makes completed slash commands atomic,
//! and handles Enter/newlines. It shows Luna Reserve's yellow arrow and detects unbracketed paste
//! bursts, especially on Windows. Paste timing uses Tokio's clock so asynchronous flush deadlines
//! and input classification share a clock, including in paused-time tests. Copy shortcuts and right
//! clicks preserve selected draft text.
//! Local recall retains full draft metadata for composer submissions and accepted command-line
//! initial prompts; transcript replay seeds earlier prompts separately.
//! When enabled, fullscreen right-click paste requires an editable composer without a selection,
//! search, or blocking view. The app reads clipboard text asynchronously and delivers a normal
//! paste only while the same thread, draft, and cursor remain eligible. Intervening input or focus
//! loss cancels the pending paste; a late clipboard result cannot overwrite newer input.
//! The live voice strip renders after effort ignition, followed by the Astra sparkle when eligible.
//! Owned transcripts keep persistent status below the composer and hints on a separate final row.
//! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row
//! so input and persistent status stay anchored when help opens or closes.
//! Escape dismisses visible shortcut help before editing, transcript backtracking, or interruption.
//! Fullscreen limits the composer height; wheel browsing preserves the caret until editor input.
//! Transcript interactions borrow the hint footer through `ComposerRenderOptions`;
//! its resolved presentation drives height, painting, and the focused cursor together.
//!
//! Preset submenu hints borrow slots and reflow with the composer.
//!
//! Shell mode starts when input inserts a leading `!`, never when deletion exposes one.
//! Literal leading bangs remain ordinary messages on submission and during same-draft restoration.
//! Vim Replace preserves the overwritten leading grapheme until Backspace removes an absorbed `!`.
//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab
//! submit ordinary text without enabling shell mode.
//!
//! # Mention Menus
//!
//! By default, `@` lists plugins, filesystem entries, and skills. Skills are hidden when their
//! owning plugin is listed. `$` lists individual skills and apps, but not plugins.
//! Disabling `mentions_v2` restores file-only `@` search and adds plugins back to `$`.
//! Confirmed `$` picker selections inform future ordering within each match tier.
//!
//! # Key Event Routing
//!
//! Plain Left opens agents from an empty, active composer connected to a local daemon or remote App Server.
//! Explicit editor remaps take precedence.
//! 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.
//! Fresh Vim drafts start in Insert; Normal `/` and `?` search the composer.
//! On an empty draft, a standalone Normal `/` enters Insert and opens command completion.
//! Backspace on an empty Vim search query cancels search and any pending operator.
//!
//! # Completion and Popup Dismissal
//!
//! All completion suggestions render above the composer and preserve its footer.
//! Owned transcript frames overlay suggestions with blank rows above and below, without reserving
//! layout space. Those rows show scroll arrows when suggestions extend beyond the visible menu.
//! Warning and transcript views hide suggestions without losing the draft, query, or selection.
//! Measurement, painting, and cursor placement share that layout, including clipped views.
//! Unified mention tabs retain their position across filters; Left/Right also works at a bare `@`.
//!
//! Popup targeting resolves an editable token range around the cursor and treats atomic text
//! elements as hard boundaries. When that range begins immediately after an atomic element, the
//! insertion path first adds a horizontal separator and shifts the range so the completion cannot
//! merge with the atomic element. After replacing the range, completion leaves the cursor after one
//! horizontal separator. It advances across an existing separator when no suffix would be joined;
//! otherwise it inserts a space, preserving the existing separator before a non-whitespace suffix.
//! It also inserts a space rather than crossing a line break.
//!
//! `Esc` records the active token as dismissed. A completed value that begins with `@` or `$` is
//! also re-dismissed before popup synchronization, because separator affinity can still identify
//! the completed token to the left of the cursor. Synchronization keeps the popup hidden only while
//! the query, complete token text, and ordinal among matching occurrences remain the same.
//! Whitespace and atomic-element edges delimit occurrences, while nested sigils do not. This
//! preserves dismissal across offset-only edits without suppressing a later identical token.
//!
//! Slash-command dismissal is tracked separately. Pressing `Esc` while the command popup is active
//! records the first-line `/name` token and keeps the popup closed while that token is unchanged.
//! Editing the command token clears the dismissal and allows the popup to reopen.
//!
//! # History Navigation (↑/↓)
//!
//! The Up/Down history path is managed by [`ChatComposerHistory`]. It merges:
//!
//! - Persistent cross-session history (text-only storage; task links recover element ranges and
//! mention bindings, but attachments cannot be restored).
//! - Local in-session history (full text + text elements + local/remote image attachments).
//!
//! Plain-text history recall strips images and their placeholders.
//! When recalling a persistent entry, encoded task links restore atomic elements and bindings.
//! Recall moves the cursor to the end. Question editors copy primary history on recall/search;
//! draft capture cancels previews, and restoration resets traversal.
//! Ctrl+R searches history in the footer and previews matches in the composer.
//! Typing and pasting edit the active search query, including large pastes and image paths.
//! Background recovery updates the saved prompt without changing the query or visible preview.
//! Esc or Ctrl+C restores that prompt, including recovered answers and interrupted input.
//! Enter accepts a matching preview and discards the saved prompt; without a match, search stays open.
//! Vim undo/redo snapshots complete drafts and groups direct edits with active Vim transactions.
//! An active edit keeps one separately capped snapshot; canceling does not evict committed history.
//! Canceled history previews restore history and active commands; accepting another prompt resets them.
//! Normal-mode Ctrl+R redoes an edit, or does nothing when redo is empty. Insert-mode Ctrl+R
//! keeps prompt-history search; explicitly configured keybindings retain precedence.
//! Vim queries stay draft-local.
//!
//! 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.
//!
//! # Question Draft Recovery
//!
//! Live terminal turns recover typed, unsubmitted question answers into the main composer.
//! Each recovered answer follows its original question as a Markdown blockquote, with blank lines
//! separating questions, answers, and existing text. The append flushes buffered input and dismisses
//! unused sparkle eligibility. The separator and recovered answer form one Vim edit; large answers
//! use atomic paste placeholders backed by their original text.
//! Recovery escapes a shell or slash command prefix before appending answers, keeping the
//! combined draft editable and its later submission literal. Shell mode's separate `!` becomes
//! text in the composer; slash-prefixed paths remain unchanged because they submit as prompts.
//!
//! # Startup Draft Handoff
//!
//! Startup uses a provisional plain-text composer: editing remains available while its owner
//! records one submit intent. Popups, attachments, and direct dispatch stay disabled. After the
//! protected startup gates finish, `prepare_startup_submission` applies normal submission parsing
//! once, including expansion of paste placeholders. [`ComposerDraftSnapshot`] transfers its
//! text, cursor, pending paste placeholders, local history, and recent activity to the fully
//! initialized composer.
//! `ChatWidget` merges the draft with any existing initial prompt and attachments, rebasing cursor
//! and placeholder positions while preserving both composers' contents. The draft remains deferred
//! until protected views close, input is enabled, and required sandbox setup completes.
//!
//! # Submission and Prompt Expansion
//!
//! Multiline pastes continue blockquote prefixes.
//! `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.
//! Submit and queue bindings act only on fresh key presses; repeated events are consumed so a held
//! key cannot dispatch a preserved draft after another input layer closes.
//! Vim Replace shares Insert's composer actions; only textarea editing differs.
//! Literal completion/paste edits discard stale Replace recovery; subsequent typing records anew.
//! Token markers added during Replace are removed when restoring overwritten characters.
//! `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 elements when `trim_submission` is enabled (the default).
//! Otherwise, preserves both.
//! - Treats a leading `!` revealed only by paste expansion as literal model input, not shell input.
//! - 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. Replacing the chat widget carries that buffer into
//! the fresh composer so Vim yanks survive `/new`, `/cd`, and thread switches. Rich objects retain
//! their original image paths, mention targets, and collapsed-paste contents. Pasted objects receive
//! fresh identities and labels, including in Replace mode, where Backspace removes them atomically.
//!
//! 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.
//! Commands requiring dispatch validation retain their draft and defer busy-state checks to
//! `ChatWidget`, which has the thread state needed to accept or reject the command.
//!
//! # Parent-Owned Thread Mode
//!
//! Parent-owned subagent threads keep the draft editable while blocking agent-directed submission.
//! On the `Enter` and `Tab` submission paths, normal prompts, disallowed slash commands, and `!`
//! shell commands return `ParentOwnedInputBlocked` without clearing the draft. Bare local and
//! navigation slash commands remain available so users can leave or manage the view.
//! Exports and local display commands (`/title` and `/statusline`) also accept arguments.
//!
//! During reconnection, `handle_restricted_key` edits the draft directly without popup dispatch,
//! composer shortcuts, or submission; `?` becomes literal input. Enter and Tab leave the draft
//! intact until reconnection succeeds, except for `/warnings`, `/config`, `/quit`, and `/exit`.
//! When connected but the thread is unavailable, configured
//! submit keys may dispatch explicitly allowed recovery and local commands; other drafts stay put.
//! Empty-prompt agents navigation remains available with its usual editor and draft guards.
//! Collapsed pastes expand into editable text so the full draft can be copied before quitting.
//!
//! # Reasoning Effort Animations
//!
//! The composer observes the effective reasoning tier whenever model-dependent surfaces refresh.
//! The first observation, session configuration, and restored threads establish a baseline;
//! genuine changes to Max/Ultra can then queue composer and status-line transitions when motion
//! and color support allow them.
//! Repeated selections do not restart either effect, dropping below Max clears them, and
//! restoration clears any transition queued while replaying the saved session. Rendering advances
//! active transitions through the frame requester until they finish.
//!
//! # Astra Sparkle Eligibility
//!
//! A confirmed new task can offer one flourish when it starts with Astra or the user switches to
//! Astra through `/model` before entering ordinary prompt content. The draft tracks whether it is
//! untouched, contains a recognized slash command, or is permanently dismissed. An eligible slash
//! command keeps the opportunity while being typed, dispatched, or erased; a fresh Astra response
//! can arm the effect while the command is still pending. Stars stay hidden until the composer is
//! empty. Commands that insert draft content, ordinary typed, pasted, or restored drafts,
//! attachments, and real work dismiss the opportunity even if that content is later cleared.
//! Ordinary disconnected edits also dismiss it immediately, even when erased before rendering;
//! a recognized command typed and erased offline still preserves the opportunity.
//! Opening history search, typing or pasting its query, and canceling it with `Esc` or `Ctrl+C`
//! temporarily hide an active field without restarting its deadline. Any buffered keys are
//! integrated into the original draft without being classified again. A preview does not count
//! as a draft until the user accepts a nonempty match.
//! Toggling shortcut help on an empty composer also hides an active field until help closes,
//! without restarting its deadline, and leaves an unused opportunity intact. A literal `?` is
//! ordinary prompt content.
//! Startup draft snapshots carry this distinction so erased ordinary input does not become
//! untouched on handoff.
//!
//! Sparkle key and paste handling runs only for input routed to the composer; ordinary characters
//! are observed before the paste-burst buffer can hide the first edit. An edit that flushes and
//! erases a held slash also clears the tracked command. An empty main-composer `Esc`
//! can stop the active flourish. Once shown, it fades out by 15 seconds; command entry, popups, and
//! focus loss hide the field without restarting that deadline. Rendering runs after the textarea,
//! placeholder, effort ignition, and voice strip, and draws only in eligible blank cells without
//! overwriting the placeholder or normal cursor. Hidden frames do not schedule animation redraws;
//! motion settings, the starfield preference, and true-color support also gate the effect.
//!
//! # 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.
//!
//! On supported terminals the local draft references also have a strip of thumbnails.
//! It shows the first attachments that fit and a count of those hidden. Removing a
//! reference removes its preview through the same attachment bookkeeping used for submission.
//!
//! # 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
//!
//! Some terminals, especially Windows, deliver pastes as rapid `KeyCode::Char`/`Enter`/`Tab` events.
//! [`PasteBurst`](super::paste_burst::PasteBurst) buffers text-producing keys (plain, Shift, or
//! Windows AltGr) and integrates buffered text through the composer, avoiding transient shortcut UI.
//! It briefly holds the first ASCII char to detect a burst. Non-ASCII chars appear immediately
//! for IME responsiveness, but still participate in burst detection.
//! Parent views must keep flushing editors that lose focus while input is buffered; a hidden
//! editor's pending burst can otherwise keep the shared draw loop waiting indefinitely.
//!
//! Submission flushes expired characters and buffers before classifying Enter, independent of
//! UI flush ticks. `disable_paste_burst` bypasses detection; setting it flushes and clears in-flight state.
//! Mouse edits flush pending typing; selection and copy behavior lives in [`mouse`]. Confirmed
//! explicit copies clear the selection while preserving the draft and cursor. Footer text uses a separate
//! read-only selection owner over its final rendered cells.
//!
//! See `codex-rs/tui/src/bottom_pane/paste_burst.rs` for the detailed state machine.
//!
//! # 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.
//! - Unmodified Tab joins detected bursts, including short Unicode prefixes, before popup dispatch.
//! Expired bursts are flushed first so manual Tab keeps its normal shortcut behavior.
//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called
//! from UI ticks to integrate a pending burst via [`ChatComposer::apply_paste`] or insert a
//! held single character. Explicit paste events (terminal or clipboard) use
//! [`ChatComposer::handle_paste`], which first commits buffered typing before classifying the
//! pasted text. This preserves the order when a paste arrives before the next burst-flush tick;
//! buffered keys have already been classified and go directly through `apply_paste`.
//! Image detection probes only regular files. Pasted device and FIFO paths stay literal.
//! - Startup recovery previews held keys with the live Vim Replace policy without consuming them.
//! Buffered pastes remain literal in recovery so large-paste and image classification stays with
//! the live paste path.
//!
//! # 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::ShortcutHint;
use crate::key_hint::has_ctrl_or_alt;
use codex_message_history::HistoryBatchCursor;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use codex_protocol::openai_models::ReasoningEffort;
use super::chat_composer_history::ChatComposerHistory;
use super::chat_composer_history::HistoryEntry;
use super::chat_composer_history::HistoryEntryResponse;
use super::chat_composer_history::HistorySearchResult;
use super::command_popup::CommandItem;
use super::effort_ignition::EffortIgnition;
use super::effort_ignition::EffortTier;
use super::effort_ignition::IGNITION_FRAME_TICK;
use super::effort_ignition::IgnitionStyle;
use super::effort_status_line::EFFORT_STATUS_LINE_FRAME_TICK;
use super::effort_status_line::EffortStatusLineTransition;
use super::file_search_popup::FileSearchPopup;
use super::footer::CollaborationModeIndicator;
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::mention_usage::MentionUsage;
use super::mentions_v2::MentionV2Popup;
use super::mentions_v2::MentionV2Selection;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use super::prompt_args::parse_slash_name;
use super::skill_popup::MentionItem;
use super::skill_popup::SkillPopup;
use super::slash_commands::ServiceTierCommand;
use super::slash_commands::SlashCommandItem;
use super::voice_strip::VoiceStrip;
use crate::bottom_pane::footer_content::FooterContent;
use crate::history_cell::sanitize_user_text;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::EditorKeymap;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::user_bindings;
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 crate::ui_consts::FOOTER_INDENT_COLS;
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 agents_navigation;
mod attachment_state;
mod completion_target;
mod composer_layout;
mod draft_state;
mod footer_state;
mod history_search;
mod inline_input;
mod mouse;
pub(crate) use mouse::ClipboardPasteTarget;
mod paste_input;
mod popup_state;
mod reconnect;
pub(crate) use reconnect::RestrictedInputMode;
mod slash_input;
mod sparkle;
mod status_surface;
mod vim_history;
mod vim_register;
mod vim_search;
mod warning_notice;
use self::attachment_state::AttachmentState;
use self::draft_state::ComposerMentionBinding;
use self::draft_state::DraftState;
pub(crate) use self::footer_state::CommandPopupPlacement;
use self::footer_state::ComposerLayout;
pub(crate) use self::footer_state::ComposerRenderOptions;
use self::footer_state::FooterState;
pub(crate) use self::footer_state::TranscriptFooter;
use self::history_search::HistorySearchSession;
use self::popup_state::ActivePopup;
use self::popup_state::DismissedToken;
use self::popup_state::PopupState;
use self::slash_input::SlashInput;
use self::slash_input::SlashValidation;
use self::slash_input::SubmissionValidation;
use self::vim_history::VimHistory;
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;
#[cfg(test)]
use codex_app_server_protocol::SkillInterface;
use codex_app_server_protocol::SkillMetadata;
use codex_connectors::AppInfo;
use codex_file_search::FileMatch;
#[cfg(test)]
use codex_plugin::AppConnectorId;
use codex_plugin::PluginCapabilitySummary;
use std::cell::OnceCell;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::ops::Range;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
pub(crate) use vim_register::KillBufferSnapshot;
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,
pending_pastes: Vec<(String, String)>,
},
/// 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>),
/// Agent-directed input was attempted while viewing a parent-owned spawned child thread.
ParentOwnedInputBlocked,
None,
}
fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool {
if matches!(
command,
SlashCommand::Export | SlashCommand::Title | SlashCommand::Statusline
) {
return true;
}
args.is_empty()
&& matches!(
command,
SlashCommand::Feedback
| SlashCommand::New
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::App
| SlashCommand::Side
| SlashCommand::Btw
| SlashCommand::Agents
| SlashCommand::MultiAgents
| SlashCommand::Vim
| SlashCommand::Keymap
| SlashCommand::Config
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Logout
| SlashCommand::Copy
| SlashCommand::Raw
| SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Import
| SlashCommand::Hooks
| SlashCommand::Status
| SlashCommand::Warnings
| SlashCommand::Daemon
| SlashCommand::Usage
| SlashCommand::Ide
| SlashCommand::DebugConfig
| SlashCommand::Theme
| SlashCommand::Pets
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Rollout
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueuedInputAction {
Plain,
/// Preserve model-input provenance when paste expansion reveals a leading shell sigil.
Literal,
ParseSlash,
RunShell,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PendingPasteHandling {
Expand,
Preserve,
}
/// Shared composer behavior; defaults match the main chat input.
#[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 a leading `!` switches the editor into shell mode.
pub(crate) shell_commands_enabled: bool,
/// Whether pasting a file path can attach local images.
pub(crate) image_paste_enabled: bool,
pub(crate) blockquote_paste_enabled: bool,
/// Strip leading and trailing whitespace from submissions.
pub(crate) trim_submission: bool,
/// Embedded editors reset Vim only when their owner accepts the answer.
pub(crate) reset_vim_on_submission: bool,
}
impl Default for ChatComposerConfig {
fn default() -> Self {
Self {
popups_enabled: true,
slash_commands_enabled: true,
shell_commands_enabled: true,
image_paste_enabled: true,
blockquote_paste_enabled: true,
trim_submission: true,
reset_vim_on_submission: true,
}
}
}
impl ChatComposerConfig {
/// Text answers support Markdown, without popups, commands, or image attachments.
pub(crate) const fn plain_text() -> Self {
Self {
popups_enabled: false,
slash_commands_enabled: false,
shell_commands_enabled: false,
image_paste_enabled: false,
blockquote_paste_enabled: true,
trim_submission: true,
reset_vim_on_submission: true,
}
}
}
pub(crate) struct ChatComposer {
draft: DraftState,
popups: PopupState,
pub(super) mention_usage: MentionUsage,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
pub(super) command_center_enabled: bool,
// Keep the footer payload out of the composer's stack frame during session navigation.
footer: Box<FooterState>,
has_focus: bool,
frame_requester: Option<FrameRequester>,
sparkle: sparkle::Sparkle,
effort_tier: Option<EffortTier>,
effort_animation_style: Option<IgnitionStyle>,
effort_ignition: Option<EffortIgnition>,
voice_strip: Option<VoiceStrip>,
effort_status_line_transition: Option<EffortStatusLineTransition>,
effort_observed: bool,
luna_reserve_active: bool,
attachments: AttachmentState,
placeholder_text: String,
blocks_direct_input: bool,
is_task_running: bool,
queue_submissions: 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>,
skills: Option<Vec<SkillMetadata>>,
plugins: Option<Vec<PluginCapabilitySummary>>,
task_mentions: Option<Vec<crate::task_mentions::TaskMention>>,
connectors_snapshot: Option<ConnectorsSnapshot>,
collaboration_modes_enabled: bool,
config: ChatComposerConfig,
connectors_enabled: bool,
plugins_command_enabled: bool,
token_activity_command_enabled: bool,
service_tier_commands_enabled: bool,
service_tier_commands: Vec<ServiceTierCommand>,
mentions_v2_enabled: bool,
goal_command_enabled: bool,
voice_command_enabled: bool,
worktrees_enabled: bool,
windows_degraded_sandbox_active: bool,
side_conversation_active: bool,
history_search: Option<HistorySearchSession>,
vim_history: VimHistory,
vim_register: vim_register::VimRegister,
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: Arc<EditorKeymap>,
vim_normal_keymap: VimNormalKeymap,
}
/// A resolved legacy `$` target plus any catalog built while disambiguating shell syntax.
struct MentionCompletionTarget {
range: Range<usize>,
query: String,
prebuilt_mentions: Option<Vec<MentionItem>>,
}
#[derive(Clone, Copy)]
pub(crate) enum ShellPrefixHandling {
Interpret,
Literal,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct ComposerDraft {
is_bash_mode: bool,
pub(super) text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>,
mention_bindings: Vec<MentionBinding>,
mention_ranges: Vec<Range<usize>>,
pending_pastes: Vec<(String, String)>,
cursor: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ComposerDraftSnapshot {
pub(crate) text: String,
pub(crate) shell_mode: Option<bool>,
pub(crate) cursor: usize,
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)>,
pub(crate) startup_local_history: Vec<HistoryEntry>,
pub(crate) last_composer_activity_at: Option<Instant>,
pub(crate) sparkle_draft: sparkle::SparkleDraft,
}
impl ChatComposer {
fn slash_input(&self) -> SlashInput<'_> {
SlashInput::new(
self.slash_commands_enabled(),
self.draft.is_bash_mode,
self.builtin_command_flags(),
&self.service_tier_commands,
)
}
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(),
mention_usage: MentionUsage::default(),
app_event_tx,
history: ChatComposerHistory::new(),
command_center_enabled: false,
footer: Box::new(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,
flash: None,
context_window_percent: None,
context_window_used_tokens: None,
context_window_pending: false,
collaboration_mode_indicator: None,
goal_status_indicator: None,
ide_context_active: false,
status_line_value: None,
measured_status_line: Default::default(),
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
external_editor_key: default_keymap
.primary_hint(KeymapContext::Global, "open_external_editor"),
warning_notice_area: std::cell::Cell::default(),
selection_revision: std::cell::Cell::default(),
show_warnings_key: default_keymap
.primary_hint(KeymapContext::Global, "open_warnings"),
show_transcript_key: default_keymap
.primary_hint(KeymapContext::Global, "open_transcript"),
find_transcript_key: default_keymap
.primary_hint(KeymapContext::Global, "find_transcript"),
focus_activity_key: default_keymap
.primary_hint(KeymapContext::Global, "focus_activity"),
insert_newline_key: footer_insert_newline_key(
&default_keymap.editor.insert_newline,
use_shift_enter_hint,
)
.map(ShortcutHint::from),
queue_key: default_keymap.primary_hint(KeymapContext::Composer, "queue"),
toggle_shortcuts_key: default_keymap
.primary_hint(KeymapContext::Composer, "toggle_shortcuts"),
history_search_key: default_keymap
.primary_hint(KeymapContext::Composer, "history_search_previous"),
reasoning_down_key: default_keymap
.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"),
reasoning_up_key: default_keymap
.primary_hint(KeymapContext::Chat, "increase_reasoning_effort"),
toggle_voice_key: default_keymap.primary_hint(KeymapContext::Chat, "toggle_voice"),
}),
has_focus: has_input_focus,
frame_requester: None,
sparkle: sparkle::Sparkle::default(),
effort_tier: None,
effort_animation_style: None,
effort_ignition: None,
voice_strip: None,
effort_status_line_transition: None,
effort_observed: false,
luna_reserve_active: false,
attachments: AttachmentState::default(),
placeholder_text,
blocks_direct_input: false,
is_task_running: false,
queue_submissions: false,
pending_slash_command_history: None,
skills: None,
plugins: None,
task_mentions: None,
connectors_snapshot: None,
collaboration_modes_enabled: false,
config,
connectors_enabled: false,
plugins_command_enabled: false,
token_activity_command_enabled: false,
service_tier_commands_enabled: false,
service_tier_commands: Vec::new(),
mentions_v2_enabled: false,
goal_command_enabled: false,
voice_command_enabled: false,
worktrees_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
history_search: None,
vim_history: VimHistory::default(),
vim_register: vim_register::VimRegister::default(),
submit_keys: vec![key_hint::plain(KeyCode::Enter)],
queue_keys: vec![key_hint::plain(KeyCode::Tab)],
toggle_shortcuts_keys: vec![
key_hint::plain(KeyCode::Char('?')),
key_hint::shift(KeyCode::Char('?')),
],
history_search_previous_keys: default_keymap.composer.history_search_previous.clone(),
history_search_next_keys: default_keymap.composer.history_search_next.clone(),
editor_keymap: default_editor_keymap,
vim_normal_keymap: default_vim_normal_keymap,
};
this.draft.textarea.set_keymap_bindings(&default_keymap);
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
this
}
pub(crate) fn set_frame_requester(&mut self, frame_requester: FrameRequester) {
self.frame_requester = Some(frame_requester);
}
/// Records the effective reasoning tier, captures the outgoing status
/// line, and queues the one-shot effects for a genuine Max/Ultra change
/// after the initial baseline.
pub(crate) fn set_active_reasoning_effort(
&mut self,
effort: Option<&ReasoningEffort>,
animations_enabled: bool,
) -> bool {
let tier = EffortTier::from_effort(effort);
let is_baseline = !self.effort_observed;
self.effort_observed = true;
if self.effort_tier == tier {
return false;
}
self.effort_tier = tier;
self.effort_ignition = None;
self.effort_status_line_transition = None;
if let Some(tier) = tier
&& !is_baseline
&& animations_enabled
{
let style = IgnitionStyle::random(self.effort_animation_style);
self.effort_ignition = Some(EffortIgnition::new(tier, style));
self.effort_animation_style = Some(style);
if self.footer.status_line_enabled
&& let Some(FooterContent::HyperlinkLine(previous)) =
passive_footer_status_line(&self.footer_props())
{
self.effort_status_line_transition =
Some(EffortStatusLineTransition::new(tier, previous));
}
if let Some(frame_requester) = &self.frame_requester {
frame_requester.schedule_frame();
}
}
true
}
/// Establishes the current tier without retaining or starting a one-shot effect.
pub(crate) fn set_active_reasoning_effort_baseline(
&mut self,
effort: Option<&ReasoningEffort>,
) {
self.effort_tier = EffortTier::from_effort(effort);
self.effort_observed = true;
self.effort_ignition = None;
self.effort_status_line_transition = None;
}
pub fn set_skill_mentions(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.skills = skills;
self.refresh_mentions_v2_popup_candidates();
self.sync_popups();
}
pub fn set_plugin_mentions(&mut self, plugins: Option<Vec<PluginCapabilitySummary>>) {
self.plugins = plugins;
self.refresh_mentions_v2_popup_candidates();
self.sync_popups();
}
pub(crate) fn set_task_mentions_enabled(&mut self, enabled: bool) {
self.task_mentions = enabled.then(Vec::new);
self.refresh_mentions_v2_popup_candidates();
if enabled
&& matches!(self.popups.active, ActivePopup::MentionV2(_))
&& let Some(query) = self.current_mentions_v2_token()
&& !query.is_empty()
{
self.app_event_tx.send(AppEvent::StartFileSearch(query));
}
}
pub(crate) fn task_mentions_enabled(&self) -> bool {
self.task_mentions.is_some()
}
pub(crate) fn on_task_search_result(
&mut self,
query: &str,
mut matches: Vec<crate::task_mentions::TaskMention>,
) {
if self.current_mentions_v2_token().as_deref() != Some(query) {
return;
}
let mentioned_ids = self
.current_mention_elements()
.into_iter()
.filter_map(|(id, _, _)| self.draft.mention_bindings.get(&id))
.filter_map(|binding| crate::task_mentions::valid_thread_path(&binding.path))
.collect::<std::collections::HashSet<_>>();
matches.retain(|task| {
mentioned_ids.len() < crate::task_mentions::MAX_REFERENCED_TASKS
&& !mentioned_ids.contains(task.thread_id.as_str())
});
let Some(task_mentions) = &mut self.task_mentions else {
return;
};
*task_mentions = matches;
self.refresh_mentions_v2_popup_candidates();
}
/// Refreshes an open mention catalog when skill or plugin metadata changes.
fn refresh_mentions_v2_popup_candidates(&mut self) {
let ActivePopup::MentionV2(popup) = &mut self.popups.active else {
return;
};
popup.set_candidates(super::mentions_v2::build_search_catalog(
self.skills.as_deref(),
self.plugins.as_deref(),
self.task_mentions.as_deref().unwrap_or_default(),
));
}
pub fn set_plugins_command_enabled(&mut self, enabled: bool) {
self.update_slash_command_catalog(|composer| composer.plugins_command_enabled = enabled);
}
pub fn set_token_activity_command_enabled(&mut self, enabled: bool) {
self.update_slash_command_catalog(|composer| {
composer.token_activity_command_enabled = enabled
});
}
pub fn set_mentions_v2_enabled(&mut self, enabled: bool) {
self.mentions_v2_enabled = enabled;
self.history.set_at_mention_restore_enabled(enabled);
self.sync_popups();
}
/// 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, sigil, mention) in elements {
if let Some(binding) = self.draft.mention_bindings.remove(&id)
&& binding.sigil == sigil
&& binding.mention == mention
{
ordered.push(MentionBinding {
sigil: binding.sigil,
mention: binding.mention,
path: binding.path,
});
}
}
self.draft.mention_bindings.clear();
ordered
}
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
self.update_slash_command_catalog(|composer| {
composer.collaboration_modes_enabled = enabled
});
}
pub fn set_connectors_enabled(&mut self, enabled: bool) {
self.update_slash_command_catalog(|composer| composer.connectors_enabled = enabled);
}
pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) {
self.update_slash_command_catalog(|composer| {
composer.service_tier_commands_enabled = enabled
});
}
pub fn set_service_tier_commands(&mut self, commands: Vec<ServiceTierCommand>) {
self.update_slash_command_catalog(|composer| composer.service_tier_commands = commands);
}
pub fn set_goal_command_enabled(&mut self, enabled: bool) {
self.update_slash_command_catalog(|composer| composer.goal_command_enabled = enabled);
}
pub fn set_voice_command_enabled(&mut self, enabled: bool) {
self.update_slash_command_catalog(|composer| composer.voice_command_enabled = enabled);
}
fn update_slash_command_catalog(&mut self, update: impl FnOnce(&mut Self)) {
update(self);
self.sync_popups();
}
/// Refresh the event destination when a retained editor survives reconnection.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn set_app_event_sender(&mut self, app_event_tx: AppEventSender) {
self.app_event_tx = app_event_tx;
}
/// 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 =
keymap.primary_hint(KeymapContext::Global, "open_external_editor");
self.footer.show_warnings_key = keymap.primary_hint(KeymapContext::Global, "open_warnings");
self.footer.show_transcript_key =
keymap.primary_hint(KeymapContext::Global, "open_transcript");
self.footer.find_transcript_key =
keymap.primary_hint(KeymapContext::Global, "find_transcript");
self.footer.focus_activity_key =
keymap.primary_hint(KeymapContext::Global, "focus_activity");
self.footer.insert_newline_key =
match keymap.primary_hint(KeymapContext::Editor, "insert_newline") {
hint @ Some(ShortcutHint::Chord { .. }) => hint,
_ => footer_insert_newline_key(
&keymap.editor.insert_newline,
self.footer.use_shift_enter_hint,
)
.map(ShortcutHint::from),
};
self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue");
self.footer.toggle_shortcuts_key =
keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts");
self.footer.history_search_key =
keymap.primary_hint(KeymapContext::Composer, "history_search_previous");
self.footer.reasoning_down_key =
keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort");
self.footer.reasoning_up_key =
keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort");
self.footer.toggle_voice_key = keymap.primary_hint(KeymapContext::Chat, "toggle_voice");
}
/// Return the contexts whose handlers can consume the next composer key.
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if self.draft.textarea.vim_query().is_some() {
return KeymapContextSet::new(KeymapContext::Editor);
}
if self.history_search.is_some() {
return KeymapContextSet::new(KeymapContext::Composer);
}
if !self.draft.input_enabled || self.popups.active() {
return KeymapContextSet::default();
}
let contexts = self.draft.textarea.keymap_contexts();
contexts.with(KeymapContext::Composer)
}
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_side_conversation_active(&mut self, active: bool) {
self.update_slash_command_catalog(|composer| composer.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.update_slash_command_catalog(|composer| {
composer.windows_degraded_sandbox_active = enabled
});
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_options(area, ComposerRenderOptions::default())
}
/// 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,
}
}
pub(crate) fn on_history_batch_response(
&mut self,
log_id: u64,
cursor: HistoryBatchCursor,
entries: Vec<crate::app_event::HistoryBatchEntryResponse>,
next_older_cursor: Option<HistoryBatchCursor>,
) -> bool {
let result = self.history.on_batch_response(
log_id,
cursor,
entries,
next_older_cursor,
&self.app_event_tx,
);
self.apply_history_batch_result(result)
}
/// Applies a failed batch lookup without conflating it with history exhaustion.
///
/// The history state machine either schedules a bounded retry, preserves an existing match, or
/// returns the search UI to its idle draft when no match has been selected yet.
pub(crate) fn on_history_batch_error(
&mut self,
log_id: u64,
cursor: HistoryBatchCursor,
) -> bool {
let result = self
.history
.on_batch_error(log_id, cursor, &self.app_event_tx);
self.apply_history_batch_result(result)
}
fn apply_history_batch_result(&mut self, result: Option<HistorySearchResult>) -> bool {
let Some(result) = result else {
return false;
};
self.apply_history_search_result(result);
true
}
pub(crate) fn record_local_user_message_history(&mut self, entry: HistoryEntry) {
self.history.record_local_submission(entry);
}
pub(crate) fn record_replayed_user_message_history(&mut self, entry: HistoryEntry) {
self.history.record_replayed_submission(entry);
}
pub fn handle_paste_image_path(&mut self, pasted: &str) -> bool {
let Some(path_buf) = normalize_pasted_path(pasted) else {
return false;
};
// Opening a serial device or FIFO can block the input loop.
if !path_buf.is_file() {
return false;
}
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::info!("OK: {pasted}");
tracing::debug!("image dimensions={}x{}", width, height);
let format = pasted_image_format(&path_buf);
tracing::debug!("attached image format={}", format.label());
self.attach_image(path_buf);
true
}
Err(err) => {
tracing::trace!("ERR: {err}");
false
}
}
}
/// 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.note_sparkle_replaced_text(&text);
self.vim_history = VimHistory::default();
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;
// Import literally so placeholders remain atomic and Replace recovery starts empty.
self.draft.textarea_state.get_mut().follow_cursor();
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_at(idx, &text[idx..pos]);
}
self.draft.textarea.insert_element(ph);
*count -= 1;
idx = pos + ph.len();
}
if idx < text.len() {
self.draft.textarea.insert_str_at(idx, &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 flushes buffered typing and clears 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) {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.apply_paste(pasted);
}
self.draft.textarea.enable_vim_search();
self.draft.textarea.set_vim_enabled(enabled);
self.vim_history = VimHistory::default();
self.draft.paste_burst.clear_after_explicit_paste();
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
/// Enable Vim while keeping already-active text entry in insert mode.
pub(crate) fn enable_vim_in_insert_mode(&mut self) {
self.set_vim_enabled(/*enabled*/ true);
self.resume_text_entry();
}
/// Resume text entry after a parent view takes focus, preserving Vim undo history.
pub(crate) fn resume_text_entry(&mut self) {
self.draft.textarea.enter_vim_insert_mode();
}
pub(crate) fn take_kill_buffer_snapshot(&mut self) -> KillBufferSnapshot {
vim_register::take_snapshot(self)
}
pub(crate) fn restore_kill_buffer_snapshot(&mut self, snapshot: KillBufferSnapshot) {
vim_register::restore_snapshot(self, snapshot);
}
/// Restore draft history transferred from the startup composer.
pub(crate) fn restore_startup_local_history(
&mut self,
startup_local_history: Vec<HistoryEntry>,
) {
for entry in startup_local_history {
self.history.record_local_submission(entry);
}
}
/// 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.
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)
}
pub(crate) fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
self.draft.textarea.vim_mode_indicator_span()
}
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 = if self.footer.context_window_pending {
Line::default()
} else {
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();
}
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
if !self.sparkle.history_preview && !urls.is_empty() {
self.dismiss_sparkle();
}
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
}
/// Replace the entire composer content with `text` and reset cursor.
///
/// This is the "fresh draft" path: it discards active history search and
/// clears pending paste payloads and mention link targets. Callers restoring
/// a previously submitted draft that must keep sigiled mention target
/// 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>,
) {
if self.history_search.take().is_some() {
self.history.reset_navigation();
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
self.set_text_content_with_mention_bindings(
text,
text_elements,
local_image_paths,
Vec::new(),
);
}
/// Restore draft content; clear pending input and undo history. The cursor starts at zero.
pub(crate) fn set_text_content_with_mention_bindings(
&mut self,
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
mention_bindings: Vec<MentionBinding>,
) {
self.set_text_content_with_shell_prefix(
text,
text_elements,
local_image_paths,
mention_bindings,
ShellPrefixHandling::Interpret,
);
}
pub(super) fn set_text_content_with_shell_prefix(
&mut self,
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
mention_bindings: Vec<MentionBinding>,
shell_prefix: ShellPrefixHandling,
) {
if !self.sparkle.history_preview {
if local_image_paths.is_empty() {
self.note_sparkle_replaced_text(&text);
} else {
self.dismiss_sparkle();
}
}
// Clear any existing content, placeholders, and attachments first.
self.footer.flash = None;
self.vim_history = VimHistory::default();
self.draft.textarea_state.get_mut().follow_cursor();
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) = match shell_prefix {
ShellPrefixHandling::Interpret => self.imported_text_for_textarea(text, text_elements),
ShellPrefixHandling::Literal => (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();
}
pub(crate) fn current_cursor(&self) -> usize {
self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 }
}
#[cfg(test)]
pub(crate) fn cursor(&self) -> usize {
self.current_cursor()
}
fn history_navigation_cursor(&self) -> usize {
if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 {
0
} else if self.draft.textarea.is_vim_normal_mode()
&& !self.draft.textarea.text().is_empty()
&& self.draft.textarea.cursor() == self.draft.textarea.vim_normal_end_cursor()
{
self.current_text().len()
} else {
self.current_cursor()
}
}
pub(crate) 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()))
}
pub(super) fn snapshot_draft(&self) -> ComposerDraft {
let (mention_bindings, mention_ranges) = self.snapshot_mention_bindings_with_ranges();
ComposerDraft {
is_bash_mode: self.draft.is_bash_mode,
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,
mention_ranges,
pending_pastes: self.draft.pending_pastes.clone(),
cursor: self.current_cursor(),
}
}
pub(super) fn restore_draft(&mut self, draft: ComposerDraft) {
let ComposerDraft {
is_bash_mode,
text,
text_elements,
local_image_paths,
remote_image_urls,
mention_bindings,
mention_ranges,
pending_pastes,
cursor,
} = draft;
self.set_remote_image_urls(remote_image_urls);
self.set_text_content_with_shell_prefix(
text,
text_elements,
local_image_paths,
Vec::new(),
if is_bash_mode {
ShellPrefixHandling::Interpret
} else {
ShellPrefixHandling::Literal
},
);
for (binding, range) in mention_bindings.into_iter().zip(mention_ranges) {
if let Some(id) = self.draft.textarea.element_id_for_exact_range(range) {
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
sigil: binding.sigil,
mention: binding.mention,
path: binding.path,
},
);
}
}
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;
}
pub(crate) fn set_parent_owned_thread(&mut self) {
self.blocks_direct_input = true;
self.placeholder_text = "Viewing sub-agent — direct input is disabled".to_string();
}
/// 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 self.config.shell_commands_enabled
&& 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)
}
}
/// Flush buffered typing before clearing so cancellation preserves the complete draft in history.
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.apply_paste(pasted);
}
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()
}
}
/// Recall content at its history boundary, preserving pending pastes and omitting images
/// in plain-text editors.
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);
if !self.config.image_paste_enabled {
for image in &self.attachments.local_images {
if let Some(element) = self.draft.textarea.text_elements().into_iter().find(|e| {
e.placeholder(self.draft.textarea.text()) == Some(image.placeholder.as_str())
}) {
let range = element.byte_range;
self.draft
.textarea
.replace_range(range.start..range.end, "");
}
}
self.attachments = AttachmentState::default();
}
self.history.record_recalled_text(self.current_text());
self.move_cursor_to_history_entry_end();
}
pub(crate) fn text_elements(&self) -> Vec<TextElement> {
self.current_text_elements()
}
pub(crate) fn local_image_paths(&self) -> Vec<PathBuf> {
self.attachments.local_image_paths()
}
pub(crate) fn status_line_content(&self) -> Option<&FooterContent> {
self.footer.status_line_value.as_ref()
}
pub(crate) fn local_images(&self) -> &[LocalImageAttachment] {
&self.attachments.local_images
}
#[cfg(test)]
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.dismiss_sparkle();
let started_vim_edit = self.begin_direct_vim_edit();
let elements_before = self.draft.textarea.element_payloads();
self.attachments
.attach_image(&mut self.draft.textarea, path);
self.reconcile_deleted_elements(elements_before);
if started_vim_edit {
self.finish_vim_edit();
}
}
#[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> {
std::mem::take(&mut self.attachments.local_images)
}
/// 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, integrate it via [`Self::apply_paste`]. Its key events have already
/// been classified; [`Self::handle_paste`] classifies explicit terminal pastes instead.
/// - 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(tokio::time::Instant::now().into_std())
}
/// 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);
}
_ => {}
}
}
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)
}
}
/// 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_none()
&& !self.popups.active()
&& self.config.slash_commands_enabled
&& self.is_empty()
&& self.draft.textarea.wants_vim_search_key(key_event)
&& !self.draft.textarea.is_vim_operator_pending()
&& key_event.code == KeyCode::Char('/')
&& key_event.modifiers.is_empty()
{
self.draft.textarea.enter_vim_insert_mode();
}
self.draft.textarea_state.get_mut().follow_cursor();
let before = self.before_sparkle_key(key_event);
let result = self.handle_key_event_inner(key_event);
self.after_sparkle_key(before, &result.0);
result
}
fn handle_key_event_inner(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.history_search.is_none()
&& !self.popups.active()
&& self.draft.textarea.wants_vim_search_key(key_event)
{
return self.handle_input_basic(key_event);
}
if self.history_search.is_some() {
return self.handle_history_search_key(key_event);
}
if self.handle_vim_history_key(key_event) {
return (InputResult::None, true);
}
if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) {
return self.begin_history_search();
}
if self.handle_paste_tab(key_event, tokio::time::Instant::now().into_std()) {
return (InputResult::None, true);
}
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
}
/// Whether a popup or query owns input.
pub(crate) fn popup_active(&self) -> bool {
self.query_active() || self.popups.active()
}
pub(crate) fn query_active(&self) -> bool {
self.history_search.is_some() || self.draft.textarea.vim_query().is_some()
}
#[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.apply_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.retract_paste_burst(grab.start_byte)
{
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
self.draft.paste_burst.clear_after_explicit_paste();
}
// 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.apply_paste(pasted);
}
let elements_before = self.draft.textarea.element_payloads();
self.draft.textarea.input(input);
self.reconcile_deleted_elements(elements_before);
(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_empty_prompt_shortcut(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running);
if next_mode != self.footer.mode {
self.footer.mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
let ActivePopup::File(popup) = &mut self.popups.active else {
unreachable!();
};
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some((range, query)) = completion_target::current_prefixed_token_range(
&self.draft.textarea,
'@',
/*allow_empty*/ false,
) {
self.popups.dismissed_file_token =
Some(DismissedToken::new(&self.draft.textarea, range, query));
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let Some(sel) = popup.selected_match() else {
self.popups.active = ActivePopup::None;
return if key_event.code == KeyCode::Enter {
self.handle_key_event_without_popup(key_event)
} else {
(InputResult::None, true)
};
};
let sel_path = sel.to_string_lossy().to_string();
if let Some((token_range, _)) =
self.current_editable_at_token_range_with_options(/*allow_empty*/ false)
{
self.insert_selected_file_path(token_range, &sel_path);
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
}
/// Handle key events when the legacy skill mention popup is visible.
fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_empty_prompt_shortcut(&key_event) {
return (InputResult::None, true);
}
self.footer.mode = reset_mode_after_activity(self.footer.mode);
let ActivePopup::Skill(popup) = &mut self.popups.active else {
unreachable!();
};
let mut selected_mention: Option<(String, Option<String>)> = None;
let mut close_popup = false;
let result = match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some(target) = self.current_mention_target() {
self.popups.dismissed_mention_token = Some(DismissedToken::new(
&self.draft.textarea,
target.range,
target.query,
));
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
if let Some(mention) = popup.selected_mention() {
selected_mention = Some((mention.insert_text.clone(), mention.path.clone()));
}
close_popup = true;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
};
if close_popup {
if let Some((insert_text, path)) = selected_mention
&& let Some(target) = self.current_mention_target()
{
self.insert_selected_mention(target.range, &insert_text, path.as_deref());
if let Some(path) = path {
self.mention_usage.record(&path);
}
}
self.popups.active = ActivePopup::None;
}
result
}
fn handle_key_event_with_mentions_v2_popup(
&mut self,
key_event: KeyEvent,
) -> (InputResult, bool) {
if self.handle_empty_prompt_shortcut(&key_event) {
return (InputResult::None, true);
}
self.footer.mode = reset_mode_after_activity(self.footer.mode);
let can_switch_search_mode = self.current_mentions_v2_token_range().is_some();
let ActivePopup::MentionV2(popup) = &mut self.popups.active else {
unreachable!();
};
let mut selected: Option<MentionV2Selection> = None;
let mut close_popup = false;
let mut submit_without_popup = false;
let result = match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
} => {
if can_switch_search_mode {
popup.previous_search_mode();
(InputResult::None, true)
} else {
self.handle_input_basic(key_event)
}
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} => {
if can_switch_search_mode {
popup.next_search_mode();
(InputResult::None, true)
} else {
self.handle_input_basic(key_event)
}
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some((range, query)) = self.current_mentions_v2_token_range() {
self.popups.dismissed_mention_token =
Some(DismissedToken::new(&self.draft.textarea, range, query));
}
self.popups.active = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
selected = popup.selected();
close_popup = true;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
selected = popup.selected();
close_popup = true;
submit_without_popup = selected.is_none();
(InputResult::None, true)
}
input => self.handle_input_basic(input),
};
if close_popup {
let token_range = self
.current_editable_at_token_range_with_options(/*allow_empty*/ true)
.map(|(range, _)| range);
if let (Some(selected), Some(token_range)) = (selected, token_range) {
match selected {
MentionV2Selection::File(path) => {
self.insert_selected_file_path(
token_range,
path.to_string_lossy().as_ref(),
);
}
MentionV2Selection::Tool { insert_text, path } => {
self.insert_selected_mention(token_range, &insert_text, path.as_deref());
}
}
}
self.popups.active = ActivePopup::None;
if submit_without_popup {
return self.handle_key_event_without_popup(key_event);
}
}
result
}
fn is_image_path(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".png")
|| lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".gif")
|| lower.ends_with(".webp")
}
/// Leaves the cursor after one horizontal separator following a completion.
///
/// Another separator is preserved before any non-whitespace suffix so subsequent typing does
/// not merge into it. Line breaks are never reused as separators; a space is inserted before
/// them so subsequent typing stays on the completed token's line.
fn advance_past_completion_separator(&mut self) {
let cursor = self.draft.textarea.cursor();
let existing_separator_len = self.draft.textarea.text()[cursor..]
.chars()
.next()
.filter(|c| {
c.is_whitespace()
&& !matches!(
*c,
'\n' | '\r'
| '\u{000B}'
| '\u{000C}'
| '\u{0085}'
| '\u{2028}'
| '\u{2029}'
)
})
.map(char::len_utf8);
if let Some(separator_len) = existing_separator_len {
let after_separator = cursor + separator_len;
let separator_precedes_suffix = self.draft.textarea.text()[after_separator..]
.chars()
.next()
.is_some_and(|c| !c.is_whitespace());
if separator_precedes_suffix {
self.draft.textarea.insert_str_at(cursor, " ");
} else {
self.draft.textarea.set_cursor(after_separator);
}
} else {
self.draft.textarea.insert_str_at(cursor, " ");
}
}
/// Dismisses popup synchronization only for the exact token occurrence just inserted.
///
/// Matching both range and text prevents an identical token later in the draft from inheriting
/// the completed token's dismissal state.
fn dismiss_completed_prefixed_token(
&mut self,
prefix: char,
inserted_range: Range<usize>,
inserted_text: &str,
) {
let Some(completed_token) = inserted_text.strip_prefix(prefix) else {
return;
};
// Completion leaves the cursor on separator whitespace, where normal token affinity would
// otherwise immediately reopen the popup for sigil-prefixed inserted text.
let Some((current_range, current_token)) = completion_target::current_prefixed_token_range(
&self.draft.textarea,
prefix,
/*allow_empty*/ true,
) else {
return;
};
if current_range != inserted_range || current_token != completed_token {
return;
}
if prefix == '@' && !self.mentions_v2_enabled {
self.popups.dismissed_file_token = Some(DismissedToken::new(
&self.draft.textarea,
current_range,
current_token,
));
} else {
self.popups.dismissed_mention_token = Some(DismissedToken::new(
&self.draft.textarea,
current_range,
current_token,
));
}
}
fn insert_selected_file_path(&mut self, token_range: Range<usize>, selected_path: &str) {
let started_vim_edit = self.begin_direct_vim_edit();
let token_range = self.separate_completion_from_adjacent_element(token_range);
if Self::is_image_path(selected_path) {
let path_buf = PathBuf::from(selected_path);
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::debug!("selected image dimensions={}x{}", width, height);
let start_idx = token_range.start;
self.draft.textarea.replace_range(token_range, "");
self.draft.textarea.set_cursor(start_idx);
self.attach_image(path_buf);
self.advance_past_completion_separator();
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
self.insert_selected_path(token_range, selected_path);
}
}
} else {
self.insert_selected_path(token_range, selected_path);
}
if started_vim_edit {
self.finish_vim_edit();
}
}
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
}
fn current_prefixed_token(
textarea: &TextArea,
prefix: char,
allow_empty: bool,
) -> Option<String> {
completion_target::current_prefixed_token_range(textarea, prefix, allow_empty)
.map(|(_, token)| token)
}
/// 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)
}
/// Returns the active prefixed token only when its sigil and name remain editable plaintext.
///
/// Atomic elements and tokens whose mention prefix is already atomic are excluded so bound
/// mentions are not offered for completion again.
fn current_editable_prefixed_token_range(
&self,
prefix: char,
allow_empty: bool,
) -> Option<(Range<usize>, String)> {
let (range, token) = completion_target::current_prefixed_token_range(
&self.draft.textarea,
prefix,
allow_empty,
)?;
completion_target::prefixed_token_range_is_editable(
&self.draft.textarea,
prefix,
&range,
&token,
)
.then_some((range, token))
}
fn current_editable_at_token_range_with_options(
&self,
allow_empty: bool,
) -> Option<(Range<usize>, String)> {
self.current_editable_prefixed_token_range('@', allow_empty)
}
fn current_mentions_v2_token_range(&self) -> Option<(Range<usize>, String)> {
if !self.mentions_v2_enabled {
return None;
}
self.current_editable_at_token_range_with_options(/*allow_empty*/ true)
}
fn current_mentions_v2_token(&self) -> Option<String> {
self.current_mentions_v2_token_range()
.map(|(_, token)| token)
}
/// Resolves the active legacy `$` target without eagerly cloning the mention catalog.
///
/// Definite shell syntax is rejected. Ambiguous shell-like queries are accepted only when a
/// bindable mention matches. The query-independent catalog built during that check is carried
/// forward only when the selected target is also ambiguous.
fn current_mention_target(&self) -> Option<MentionCompletionTarget> {
if !self.mentions_enabled() {
return None;
}
let mentions: OnceCell<Vec<MentionItem>> = OnceCell::new();
let (range, query) = {
let dollar_query_is_completable =
|query: &str| match completion_target::dollar_query_kind(query) {
completion_target::DollarQueryKind::Completable => true,
completion_target::DollarQueryKind::AmbiguousShellParameter => mentions
.get_or_init(|| {
self.mention_items()
.into_iter()
.filter(|mention| {
mention.path.is_some()
&& Self::mention_token_from_insert_text(
&mention.insert_text,
)
.is_some()
})
.collect()
})
.iter()
.any(|mention| mention.fuzzy_match_query(query).is_some()),
completion_target::DollarQueryKind::ShellVariable
| completion_target::DollarQueryKind::DefiniteShellParameter
| completion_target::DollarQueryKind::Invalid => false,
};
let (range, query) =
completion_target::current_prefixed_token_range_with_dollar_predicate(
&self.draft.textarea,
'$',
/*allow_empty*/ true,
dollar_query_is_completable,
)?;
if !completion_target::prefixed_token_range_is_editable(
&self.draft.textarea,
'$',
&range,
&query,
) || !dollar_query_is_completable(&query)
{
return None;
}
(range, query)
};
let prebuilt_mentions = if matches!(
completion_target::dollar_query_kind(&query),
completion_target::DollarQueryKind::AmbiguousShellParameter
) {
mentions.into_inner()
} else {
None
};
Some(MentionCompletionTarget {
range,
query,
prebuilt_mentions,
})
}
/// Replace the active `@token` (the one under the cursor) with `path`.
fn insert_selected_path(&mut self, token_range: Range<usize>, path: &str) {
// 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.
let start_idx = token_range.start;
self.draft.textarea.replace_range(token_range, &inserted);
let inserted_range = start_idx..start_idx.saturating_add(inserted.len());
self.draft.textarea.set_cursor(inserted_range.end);
self.advance_past_completion_separator();
self.dismiss_completed_prefixed_token('@', inserted_range, &inserted);
}
fn insert_selected_mention(
&mut self,
token_range: Range<usize>,
insert_text: &str,
path: Option<&str>,
) {
let token_range = self.separate_completion_from_adjacent_element(token_range);
// Remove the active token and insert the selected mention as an atomic element.
let start_idx = token_range.start;
self.draft.textarea.replace_range(token_range, "");
self.draft.textarea.set_cursor(start_idx);
let id = self.draft.textarea.insert_element(insert_text);
let inserted_range = start_idx..start_idx.saturating_add(insert_text.len());
let mention = if path
.and_then(crate::task_mentions::valid_thread_path)
.is_some()
{
insert_text
.strip_prefix('@')
.filter(|name| !name.is_empty())
.map(|name| ('@', name.to_string()))
} else {
Self::mention_token_from_insert_text(insert_text)
};
if let (Some(path), Some((sigil, mention))) = (path, mention) {
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
sigil,
mention,
path: path.to_string(),
},
);
}
if path
.and_then(crate::task_mentions::valid_thread_path)
.is_some()
&& let Some(task_mentions) = &mut self.task_mentions
{
task_mentions.clear();
}
self.advance_past_completion_separator();
if let Some(sigil) = insert_text.chars().next()
&& matches!(sigil, '$' | '@')
{
self.dismiss_completed_prefixed_token(sigil, inserted_range, insert_text);
}
}
/// Inserts a leading separator when a completion starts directly after an atomic element.
///
/// The returned range is shifted to keep replacing the same editable token after insertion.
fn separate_completion_from_adjacent_element(
&mut self,
mut token_range: Range<usize>,
) -> Range<usize> {
let starts_after_element = self
.draft
.textarea
.text_element_ranges()
.any(|range| range.end == token_range.start);
if starts_after_element {
self.draft
.textarea
.replace_range(token_range.start..token_range.start, " ");
token_range.start += 1;
token_range.end += 1;
}
token_range
}
fn mention_token_from_insert_text(insert_text: &str) -> Option<(char, String)> {
let sigil = insert_text.chars().next()?;
if !matches!(sigil, '$' | '@') {
return None;
}
let name = &insert_text[sigil.len_utf8()..];
if name.is_empty() {
return None;
}
if name
.as_bytes()
.iter()
.all(|byte| is_mention_name_char(*byte))
{
Some((sigil, name.to_string()))
} else {
None
}
}
fn current_mention_elements(&self) -> Vec<(u64, char, String)> {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
if let Some(binding) = self.draft.mention_bindings.get(&snapshot.id)
&& crate::task_mentions::valid_thread_path(&binding.path).is_some()
&& snapshot.text == format!("{}{}", binding.sigil, binding.mention)
{
return Some((snapshot.id, binding.sigil, binding.mention.clone()));
}
Self::mention_token_from_insert_text(snapshot.text.as_str())
.map(|(sigil, mention)| (snapshot.id, sigil, mention))
})
.collect()
}
fn snapshot_mention_bindings(&self) -> Vec<MentionBinding> {
self.snapshot_mention_bindings_with_ranges().0
}
fn snapshot_mention_bindings_with_ranges(&self) -> (Vec<MentionBinding>, Vec<Range<usize>>) {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|element| {
let binding = self.draft.mention_bindings.get(&element.id)?;
if element.text != format!("{}{}", binding.sigil, binding.mention)
|| (crate::task_mentions::valid_thread_path(&binding.path).is_none()
&& Self::mention_token_from_insert_text(&element.text).is_none())
{
return None;
}
Some((
MentionBinding {
sigil: binding.sigil,
mention: binding.mention.clone(),
path: binding.path.clone(),
},
element.range,
))
})
.unzip()
}
fn bind_mentions_from_snapshot(&mut self, mention_bindings: Vec<MentionBinding>) {
self.draft.mention_bindings.clear();
if mention_bindings.is_empty() {
return;
}
let text = self.draft.textarea.text().to_string();
let mut scan_from = 0usize;
for binding in mention_bindings {
let token = format!("{}{}", binding.sigil, binding.mention);
let range = if crate::task_mentions::valid_thread_path(&binding.path).is_some() {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.find(|element| element.range.start >= scan_from && element.text == token)
.map(|element| element.range)
} else {
find_next_mention_token_range(text.as_str(), token.as_str(), scan_from)
};
let Some(range) = range else {
continue;
};
let id = if let Some(id) = self.draft.textarea.add_element_range(range.clone()) {
Some(id)
} else {
self.draft
.textarea
.element_id_for_exact_range(range.clone())
};
if let Some(id) = id {
self.draft.mention_bindings.insert(
id,
ComposerMentionBinding {
sigil: binding.sigil,
mention: binding.mention,
path: binding.path,
},
);
scan_from = range.end;
}
}
}
fn plugin_at_mention_highlights(&self) -> Vec<(Range<usize>, Style)> {
self.draft
.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
let binding = self.draft.mention_bindings.get(&snapshot.id)?;
if !binding.path.starts_with("plugin://") || !snapshot.text.starts_with('@') {
return None;
}
Some((snapshot.range, Style::default().fg(Color::Magenta)))
})
.collect()
}
/// 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,
PendingPasteHandling::Expand,
)
}
fn prepare_submission_text_with_options(
&mut self,
record_history: bool,
slash_validation: SlashValidation,
pending_paste_handling: PendingPasteHandling,
) -> Option<(String, Vec<TextElement>)> {
let mut text = self.current_text();
let original_input = text.clone();
let original_text_elements = self.current_text_elements();
let original_mention_bindings = self.snapshot_mention_bindings();
let original_local_image_paths = self.attachments.local_image_paths();
let original_pending_pastes = self.draft.pending_pastes.clone();
let mut text_elements = original_text_elements.clone();
let input_starts_with_space = original_input.starts_with(' ');
self.draft.recent_submission_mention_bindings.clear();
self.draft.textarea.set_text_clearing_elements("");
self.draft.is_bash_mode = false;
if pending_paste_handling == PendingPasteHandling::Expand
&& !self.draft.pending_pastes.is_empty()
{
// 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;
}
if self.config.trim_submission {
let expanded_input = text.clone();
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
}
if slash_validation == SlashValidation::Immediate
&& let SubmissionValidation::UnknownCommand(name) = self
.slash_input()
.validate_submission(&text, input_starts_with_space)
{
let message = format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(message, /*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());
// Embedded composers may cover the transcript that receives the full error.
if self.footer.hint_override.is_some() {
self.show_footer_flash(
Line::from(
format!("Message too long; limit {MAX_USER_INPUT_TEXT_CHARS} characters")
.red(),
),
Duration::from_secs(5),
);
}
return None;
}
self.attachments
.prune_local_images_for_submission(&text, &text_elements);
if text.trim().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()) {
let preserve_literal_paste = self.slash_commands_enabled()
&& text.starts_with('!')
&& !original_input.trim_start().starts_with('!');
let (history_text, history_text_elements) = if preserve_literal_paste {
let history_text = original_input.trim().to_string();
let history_text_elements = Self::trim_text_elements(
&original_input,
&history_text,
original_text_elements,
);
(history_text, history_text_elements)
} else {
(text.clone(), text_elements.clone())
};
self.history.record_local_submission(HistoryEntry {
text: history_text,
text_elements: history_text_elements,
local_image_paths: self.attachments.local_image_paths(),
remote_image_urls: self.attachments.remote_image_urls(),
mention_bindings: original_mention_bindings,
pending_pastes: if pending_paste_handling == PendingPasteHandling::Preserve
|| preserve_literal_paste
{
original_pending_pastes.clone()
} else {
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, tokio::time::Instant::now().into_std());
self.reset_vim_mode_after_successful_dispatch(&result.0);
result
}
/// Prepare a startup-confirmed draft through the normal queue parser, independent of keymaps.
pub(super) fn prepare_startup_submission(&mut self) -> InputResult {
// Startup's plain-text editor cannot show shell mode; require confirmation here.
if self.is_bang_shell_command() {
return InputResult::None;
}
self.handle_submission(/*should_queue*/ true).0
}
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.config.reset_vim_on_submission
{
self.reset_vim_mode();
}
}
fn handle_submission_with_time(
&mut self,
should_queue: bool,
now: Instant,
) -> (InputResult, bool) {
self.handle_paste_burst_flush(now);
if !should_queue && self.handle_paste_enter(now) {
return (InputResult::None, true);
}
if let Some(result) = self.handle_parent_owned_submission() {
return result;
}
if should_queue {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.apply_paste(pasted);
}
let visible_shell_command = self.is_bang_shell_command();
let original_input = self.current_text();
let original_text_elements = self.current_text_elements();
let original_pending_pastes = self.draft.pending_pastes.clone();
let raw_text = self.draft.textarea.text();
let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text);
let preserve_pending_pastes = defer_slash_validation
&& !self.draft.pending_pastes.is_empty()
&& parse_slash_name(raw_text)
.is_some_and(|(name, _, _)| name == SlashCommand::Goal.command());
let pending_pastes = if preserve_pending_pastes {
self.draft.pending_pastes.clone()
} else {
Vec::new()
};
if let Some((text, text_elements)) = self.prepare_submission_text_with_options(
/*record_history*/ true,
if defer_slash_validation {
SlashValidation::Deferred
} else {
SlashValidation::Immediate
},
if preserve_pending_pastes {
PendingPasteHandling::Preserve
} else {
PendingPasteHandling::Expand
},
) {
let action = if text.starts_with('!') && !visible_shell_command {
QueuedInputAction::Literal
} else {
slash_input::queued_input_action(&text, defer_slash_validation)
};
let (text, text_elements, pending_pastes) = if action == QueuedInputAction::Literal
{
let text = original_input.trim().to_string();
let text_elements =
Self::trim_text_elements(&original_input, &text, original_text_elements);
(text, text_elements, original_pending_pastes)
} else {
(text, text_elements, pending_pastes)
};
return (
InputResult::Queued {
text,
text_elements,
action,
pending_pastes,
},
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);
}
let visible_shell_command = self.is_bang_shell_command();
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 self.slash_commands_enabled() && text.starts_with('!') && !visible_shell_command {
let text = original_input.trim().to_string();
let text_elements =
Self::trim_text_elements(&original_input, &text, original_text_elements);
(
InputResult::Queued {
text,
text_elements,
action: QueuedInputAction::Literal,
pending_pastes: original_pending_pastes,
},
true,
)
} else {
// Do not clear local attachments here; ChatWidget drains them via
// take_recent_submission_images().
(
InputResult::Submitted {
text,
text_elements,
},
true,
)
}
} else {
// Restore suppressed input, preserving validation feedback.
let flash = self.footer.flash.take();
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;
self.footer.flash = flash;
(InputResult::None, true)
}
}
fn handle_parent_owned_submission(&mut self) -> Option<(InputResult, bool)> {
if !self.blocks_direct_input {
return None;
}
let text = self.current_text();
let allowed_slash_command = parse_slash_name(&text).is_some_and(|(name, args, _)| {
matches!(
self.slash_input().command(name),
Some(SlashCommandItem::Builtin(command))
if name == command.command()
&& parent_owned_command_is_allowed(command, args)
)
});
if text.starts_with('/') && allowed_slash_command {
return None;
}
Some((InputResult::ParentOwnedInputBlocked, true))
}
/// 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> {
let command = self
.slash_input()
.bare_command(self.draft.textarea.text())?;
if self.reject_slash_command_if_unavailable(&command) {
self.stage_slash_command_history(&command);
self.record_pending_slash_command_history();
return Some(InputResult::None);
}
self.stage_slash_command_history(&command);
if !matches!(command, SlashCommandItem::Builtin(cmd) if cmd.requires_dispatch_validation())
{
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> {
let text = self.draft.textarea.text().to_string();
let inline_command = self.slash_input().inline_command(&text)?;
let command = inline_command.command;
if self.reject_slash_command_if_unavailable(&command) {
self.stage_slash_command_history(&command);
self.record_pending_slash_command_history();
return Some(InputResult::None);
}
self.stage_slash_command_history(&command);
let mut args_elements = slash_input::args_elements(
inline_command.rest,
inline_command.rest_offset,
&self.draft.textarea.text_elements(),
);
let trimmed_rest = inline_command.rest.trim();
args_elements = Self::trim_text_elements(inline_command.rest, trimmed_rest, args_elements);
let SlashCommandItem::Builtin(cmd) = command else {
return None;
};
Some(InputResult::CommandWithArgs(
cmd,
trimmed_rest.to_string(),
args_elements,
))
}
/// 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) = slash_input::prepared_args(&prepared_text)?;
let mut args_elements =
slash_input::args_elements(prepared_rest, prepared_rest_offset, &prepared_elements);
let trimmed_rest = prepared_rest.trim();
args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements);
self.draft.textarea.enter_vim_insert_mode();
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()
|| matches!(command, SlashCommandItem::Builtin(cmd) if cmd.requires_dispatch_validation())
{
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
}
/// 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(),
});
}
fn handle_remote_image_selection_key(
&mut self,
key_event: &KeyEvent,
) -> Option<(InputResult, bool)> {
if self.draft.textarea.mouse_selection_range().is_some() {
return None;
}
let removes_remote_image = matches!(key_event.code, KeyCode::Delete | KeyCode::Backspace)
&& self.attachments.selected_remote_image_index.is_some();
let started_vim_edit = removes_remote_image && self.begin_direct_vim_edit();
let result = self
.attachments
.handle_remote_image_selection_key(key_event, &mut self.draft.textarea);
if started_vim_edit {
self.finish_vim_edit();
}
result
}
/// 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_empty_prompt_shortcut(&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.apply_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.config.shell_commands_enabled
&& 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 key_event.kind == KeyEventKind::Repeat
&& (self.queue_keys.is_pressed(key_event) || self.submit_keys.is_pressed(key_event))
{
return (InputResult::None, true);
}
if self.queue_keys.is_pressed(key_event)
&& (self.is_task_running || self.queue_submissions || !self.is_bang_shell_command())
{
return self.handle_submission(self.is_task_running || self.queue_submissions);
}
if self.submit_keys.is_pressed(key_event) {
return self.handle_submission(self.queue_submissions);
}
if let KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} = key_event
&& self.is_empty()
{
return (InputResult::None, false);
}
let (history_up_pressed, history_down_pressed) = if self.draft.textarea.is_vim_normal_mode()
{
if self.draft.textarea.is_vim_operator_pending() {
(false, false)
} else {
(
self.vim_normal_keymap.move_up.is_pressed(key_event),
self.vim_normal_keymap.move_down.is_pressed(key_event),
)
}
} else {
(
self.editor_keymap.move_up.is_pressed(key_event),
self.editor_keymap.move_down.is_pressed(key_event),
)
};
if history_up_pressed || history_down_pressed {
if self.draft.textarea.mouse_selection_range().is_none()
&& 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.config.shell_commands_enabled && self.draft.is_bash_mode
}
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()]))
}
/// 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 text-producing character input.
/// - For non-text 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, tokio::time::Instant::now().into_std())
}
fn handle_input_basic_with_time(
&mut self,
input: KeyEvent,
now: Instant,
) -> (InputResult, bool) {
if !self.draft.textarea.is_vim_normal_mode() {
self.begin_vim_edit(input);
}
// 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_control_char_if_active('\n', now)
{
return (InputResult::None, true);
}
let has_non_text_modifier = has_ctrl_or_alt(input.modifiers)
|| input
.modifiers
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META);
// Intercept text-producing Char inputs to optionally accumulate into a burst buffer.
//
// This preserves plain, Shift, and Windows AltGr input while keeping shortcut modifiers
// out of the burst detector and flushing any in-flight text before a non-text key.
if let KeyEvent {
code: KeyCode::Char(ch),
..
} = input
{
if !has_non_text_modifier
&& !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.retract_paste_burst(grab.start_byte)
{
self.draft.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
self.draft.paste_burst.clear_after_explicit_paste();
}
// 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.apply_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.apply_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
&& self.draft.textarea.vim_query().is_none()
&& self.draft.textarea.mouse_selection_range().is_none()
&& self.draft.textarea.is_delete_backward_key(input)
&& self.draft.textarea.cursor() == 0
{
let started_vim_edit = self.begin_direct_vim_edit();
if self.draft.textarea.is_vim_replace_mode() {
self.draft.textarea.input(input);
}
self.draft.is_bash_mode = false;
if started_vim_edit {
self.finish_vim_edit();
}
return (InputResult::None, true);
}
let prefix_insertion_revision = self.draft.textarea.prefix_insertion_revision();
self.begin_vim_edit(input);
if self.draft.textarea.is_vim_normal_mode()
&& !self.draft.textarea.is_vim_operator_pending()
&& self.vim_normal_keymap.repeat_last_change.is_pressed(input)
{
self.draft.textarea.set_cursor(self.draft.textarea.cursor());
if let Some(edits) = self.draft.textarea.begin_vim_repeat() {
for edit in edits {
let elements = self.draft.textarea.element_payloads();
let prefix_insertion_revision = self.draft.textarea.prefix_insertion_revision();
if !self.draft.textarea.apply_vim_edit(&edit) {
break;
}
vim_register::sync(self);
if self.draft.textarea.prefix_insertion_revision() != prefix_insertion_revision
{
self.sync_bash_mode_from_text();
}
self.reconcile_deleted_elements(elements);
}
self.draft.textarea.finish_vim_repeat();
}
} else {
self.draft.textarea.input(input);
vim_register::sync(self);
}
if self.draft.textarea.prefix_insertion_revision() != prefix_insertion_revision {
self.sync_bash_mode_from_text();
}
if let Some(elements_before) = elements_before {
self.reconcile_deleted_elements(elements_before);
}
self.finish_vim_edit();
// Update the paste-burst heuristic for text, shortcut, and non-char events.
match input.code {
KeyCode::Char(_) => {
if has_non_text_modifier {
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.config.shell_commands_enabled
&& !self.draft.is_bash_mode
&& self.draft.textarea.text().starts_with('!')
{
self.draft.textarea.absorb_prompt_prefix(/*prefix_len*/ 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 empty-prompt agents navigation and shortcut-overlay toggling or dismissal.
///
/// 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_empty_prompt_shortcut(&mut self, key_event: &KeyEvent) -> bool {
if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(*key_event) {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
return true;
}
if key_event.kind != KeyEventKind::Press {
return false;
}
if self.handle_agents_navigation(*key_event) {
return true;
}
let Some(next) = self.empty_prompt_shortcut_toggle(key_event) else {
return false;
};
self.footer.mode = next;
true
}
fn empty_prompt_shortcut_toggle(&self, key_event: &KeyEvent) -> Option<FooterMode> {
if key_event.kind == KeyEventKind::Press
&& self.footer.hint_override.is_none()
&& self.toggle_shortcuts_keys.is_pressed(*key_event)
&& self.is_empty()
&& !self.is_in_paste_burst()
{
let next = toggle_shortcut_mode(
self.footer.mode,
self.quit_shortcut_hint_visible(),
self.is_empty(),
);
(next != self.footer.mode).then_some(next)
} else {
None
}
}
pub(crate) fn sync_popups(&mut self) {
self.sync_slash_command_elements();
if self.history_search.is_some() || self.draft.textarea.vim_query().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.draft.textarea.mouse_selection_range().is_some() {
self.popups.active = ActivePopup::None;
return;
}
let mut mentions_v2_token = self.current_mentions_v2_token_range();
let mut file_token = if self.mentions_v2_enabled {
None
} else {
self.current_editable_at_token_range_with_options(/*allow_empty*/ false)
};
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 mut mention_target = self.current_mention_target();
let at_token_start = mentions_v2_token
.as_ref()
.or(file_token.as_ref())
.map(|(range, _)| range.start);
let mention_token_start = mention_target.as_ref().map(|target| target.range.start);
if let (Some(at_token_start), Some(mention_token_start)) =
(at_token_start, mention_token_start)
{
if at_token_start > mention_token_start {
mention_target = None;
} else if mention_token_start > at_token_start {
mentions_v2_token = None;
file_token = None;
}
}
let allow_command_popup = self.slash_commands_enabled()
&& !self.draft.is_bash_mode
&& file_token.is_none()
&& mentions_v2_token.is_none()
&& mention_target.is_none();
self.sync_command_popup(allow_command_popup);
if matches!(self.popups.active, ActivePopup::Command(_)) {
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
}
self.popups.dismissed_file_token = None;
self.popups.dismissed_mention_token = None;
return;
}
if let Some((range, token)) = mentions_v2_token {
self.sync_mentions_v2_popup(range, token);
return;
}
if let Some(target) = mention_target {
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
}
self.sync_mention_popup(target);
return;
}
self.popups.dismissed_mention_token = None;
if let Some((range, token)) = file_token {
self.sync_file_search_popup(range, token);
return;
}
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
}
self.popups.dismissed_file_token = None;
if matches!(
self.popups.active,
ActivePopup::File(_) | ActivePopup::Skill(_) | ActivePopup::MentionV2(_)
) {
self.popups.active = ActivePopup::None;
}
}
/// 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) {
let text = self.draft.textarea.text();
let first_line_end = text.find('\n').unwrap_or(text.len());
let first_line = &text[..first_line_end];
// Keep an explicitly dismissed popup closed until the command token changes.
let command_token = slash_input::command_popup_filter_text(first_line, /*cursor*/ 0);
if let Some(command_token) = command_token.as_deref()
&& self.popups.dismissed_command_token.as_deref() == Some(command_token)
{
return;
}
self.popups.dismissed_command_token = None;
if !allow {
if matches!(self.popups.active, ActivePopup::Command(_)) {
self.popups.active = ActivePopup::None;
}
return;
}
// Determine whether the caret is inside the initial '/name' token on the first line.
let cursor = self.draft.textarea.cursor();
let caret_on_first_line = cursor <= first_line_end;
let is_editing_slash_command_name = caret_on_first_line
&& self
.slash_input()
.is_editing_command_name(first_line, cursor);
let command_filter_text = caret_on_first_line
.then(|| slash_input::command_popup_filter_text(first_line, cursor))
.flatten();
// If 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 {
if let Some(command_filter_text) = command_filter_text.as_deref() {
popup.on_composer_text_change(command_filter_text.to_string());
}
} else {
self.popups.active = ActivePopup::None;
}
}
_ => {
if is_editing_slash_command_name
&& let Some(command_filter_text) = command_filter_text.as_deref()
{
let command_popup = self.slash_input().command_popup(command_filter_text);
self.popups.active = ActivePopup::Command(command_popup);
}
}
}
}
/// Synchronize the legacy file-search popup with the current `@` token.
fn sync_file_search_popup(&mut self, range: Range<usize>, query: String) {
if self
.popups
.dismissed_file_token
.as_ref()
.is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query))
{
return;
}
if query.is_empty() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
} else {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
}
match &mut self.popups.active {
ActivePopup::File(popup) => {
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
}
_ => {
let mut popup = FileSearchPopup::new();
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
self.popups.active = ActivePopup::File(popup);
}
}
if query.is_empty() {
self.popups.current_file_query = None;
} else {
self.popups.current_file_query = Some(query);
}
self.popups.dismissed_file_token = None;
}
fn sync_mention_popup(&mut self, target: MentionCompletionTarget) {
let MentionCompletionTarget {
range,
query,
prebuilt_mentions,
} = target;
if self
.popups
.dismissed_mention_token
.as_ref()
.is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query))
{
return;
}
let mentions = prebuilt_mentions.unwrap_or_else(|| self.mention_items());
if mentions.is_empty() {
self.popups.active = ActivePopup::None;
return;
}
match &mut self.popups.active {
ActivePopup::Skill(popup) => {
popup.set_query(&query);
popup.set_mentions(mentions);
}
_ => {
let mut popup =
SkillPopup::new(mentions).with_frequencies(self.mention_usage.frequencies());
popup.set_query(&query);
self.popups.active = ActivePopup::Skill(popup);
}
}
}
fn sync_mentions_v2_popup(&mut self, range: Range<usize>, query: String) {
if self
.popups
.dismissed_mention_token
.as_ref()
.is_some_and(|dismissed| dismissed.matches(&self.draft.textarea, &range, &query))
{
return;
}
if query.is_empty() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.popups.current_file_query = None;
} else {
let new_popup = !matches!(self.popups.active, ActivePopup::MentionV2(_));
if new_popup {
// A fresh popup has no cached matches, and the app-owned file-search manager can
// retain an identical query. Reset it before issuing the query so results arrive.
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
}
if new_popup || self.popups.current_file_query.as_deref() != Some(query.as_str()) {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
self.popups.current_file_query = Some(query.clone());
}
}
match &mut self.popups.active {
ActivePopup::MentionV2(popup) => {
popup.set_query(&query);
}
_ => {
let candidates = super::mentions_v2::build_search_catalog(
self.skills.as_deref(),
self.plugins.as_deref(),
self.task_mentions.as_deref().unwrap_or_default(),
);
self.popups.active =
ActivePopup::MentionV2(MentionV2Popup::new(candidates, &query));
}
}
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.as_str().to_owned()),
category_tag: Some("[Skill]".to_string()),
sort_rank: 1,
});
}
}
if !self.mentions_v2_enabled
&& 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(crate) fn show_shutdown_in_progress(&mut self) {
self.set_input_enabled(/*enabled*/ false, Some("Shutting down...".to_string()));
self.footer.quit_shortcut_expires_at = None;
self.footer.mode = FooterMode::ComposerEmpty;
self.footer.hint_override = Some(Vec::new());
self.footer.flash = None;
}
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
}
pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) {
self.queue_submissions = queue_submissions;
}
pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) {
if self.footer.context_window_percent == percent
&& self.footer.context_window_used_tokens == used_tokens
{
return;
}
self.footer.context_window_percent = percent;
self.footer.context_window_used_tokens = used_tokens;
}
pub(crate) fn set_context_window_pending(&mut self, pending: bool) {
self.footer.context_window_pending = pending;
}
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);
}
}
/// 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 bindings = user_bindings(bindings);
let shift_enter = key_hint::shift(KeyCode::Enter);
if enhanced_keys_supported && bindings.contains(&shift_enter) {
return Some(shift_enter);
}
let plain_enter = key_hint::plain(KeyCode::Enter);
bindings
.iter()
.copied()
.find(|binding| *binding != plain_enter)
.or_else(|| bindings.first().copied())
}
fn skill_description(skill: &SkillMetadata) -> Option<String> {
let description = skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
.or(skill.short_description.as_deref())
.unwrap_or(&skill.description);
let trimmed = description.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn ends_plaintext_at_mention(bytes: &[u8], index: usize) -> bool {
bytes.get(index).is_none_or(|byte| {
byte.is_ascii_whitespace()
|| *byte == b'.'
&& bytes.get(index + 1).is_none_or(|next| {
next.is_ascii_whitespace()
|| !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-'
})
|| !matches!(*byte, b'.' | b'/' | b'\\')
&& !byte.is_ascii_alphanumeric()
&& *byte != b'_'
&& *byte != b'-'
})
}
fn ends_plaintext_at_dollar_mention(bytes: &[u8], index: usize) -> bool {
bytes
.get(index)
.is_none_or(|byte| !is_mention_name_char(*byte))
}
fn starts_plaintext_at_mention(text: &str, index: usize) -> bool {
if index == 0 {
return true;
}
text.get(..index)
.and_then(|prefix| prefix.chars().next_back())
.is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch))
}
fn is_mention_name_char_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}
fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option<Range<usize>> {
if token.is_empty() || from >= text.len() {
return None;
}
let bytes = text.as_bytes();
let token_bytes = token.as_bytes();
let sigil = *token_bytes.first()?;
let mut index = from;
while index < bytes.len() {
if bytes[index] != sigil {
index += 1;
continue;
}
let end = index.saturating_add(token_bytes.len());
if end > bytes.len() {
return None;
}
if &bytes[index..end] != token_bytes {
index += 1;
continue;
}
// Fix for restored `@` mentions: rebinding must not attach to embedded substrings such
// as email addresses, while preserving the existing `$` mention matching behavior.
let starts_plaintext_mention = if sigil == b'@' {
starts_plaintext_at_mention(text, index)
} else {
true
};
// Fix for restored `@` mentions: mirror history encoding's trailing boundary so path-like
// text such as `@sample/pkg` is not rebound as the plain `@sample` mention.
let ends_plaintext_mention = if sigil == b'@' {
ends_plaintext_at_mention(bytes, end)
} else {
ends_plaintext_at_dollar_mention(bytes, end)
};
if starts_plaintext_mention && ends_plaintext_mention {
return Some(index..end);
}
index = end;
}
None
}
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_options(area, ComposerRenderOptions::default())
}
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_options(width, ComposerRenderOptions::default())
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_with_mask(area, buf, /*mask_char*/ None);
}
}
impl ChatComposer {
pub(crate) fn set_luna_reserve_active(&mut self, active: bool) -> bool {
if self.luna_reserve_active == active {
return false;
}
self.luna_reserve_active = active;
true
}
}
impl ChatComposer {
pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option<char>) {
self.render_with_options(area, buf, mask_char, ComposerRenderOptions::default());
}
pub(crate) fn render_with_options(
&self,
area: Rect,
buf: &mut Buffer,
mask_char: Option<char>,
options: ComposerRenderOptions<'_>,
) {
let ComposerLayout {
status,
composer: composer_rect,
remote_images: remote_images_rect,
thumbnails,
textarea: textarea_rect,
popup: popup_rect,
footer: footer_rect,
} = self.layout_with_options(area, options);
self.render_status_surface(status, buf, options);
if self.popups.active.is_above_composer()
&& options.command_popup_placement != CommandPopupPlacement::Hidden
{
let popup_rect = if options.command_popup_placement == CommandPopupPlacement::Overlay {
let rows_above_composer = composer_rect
.y
.saturating_sub(options.overlay_top.max(buf.area.y));
let height = self
.popups
.active
.required_height(composer_rect.width, /*footer_total_height*/ 0)
.min(rows_above_composer);
// Command and unified mention menus can render in a single row.
let height = if matches!(
self.popups.active,
ActivePopup::Command(_) | ActivePopup::MentionV2(_)
) || height > 2
{
height
} else {
0
};
Rect::new(
composer_rect.x,
composer_rect.y - height,
composer_rect.width,
height,
)
} else {
popup_rect
};
// Overlay rows must erase both glyphs and styles from the transcript underneath.
ratatui::widgets::Clear.render(popup_rect, buf);
match &self.popups.active {
ActivePopup::Command(popup) => popup.render_ref(popup_rect, buf),
ActivePopup::MentionV2(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::None => {
unreachable!("only suggestion menus use above-composer placement")
}
}
}
let transcript_hint_area = inset_footer_hint_area(footer_rect);
let warning_notice = self
.warning_notice_layout(transcript_hint_area, options)
.filter(|_| !options.unfocused);
let warning_area = warning_notice.as_ref().map(|(area, _)| *area);
match options.footer {
_ if options.unfocused => {}
Some(footer) => {
self.render_transcript_footer(transcript_hint_area, buf, footer, warning_area);
}
None if self.shortcuts_above_composer(options) => {
let props = self.hint_footer_props(options);
super::shortcut_overlay::render(&props, inset_footer_hint_area(popup_rect), buf);
super::shortcut_overlay::close_hint(&props, transcript_hint_area.width)
.render(transcript_hint_area, buf);
}
None => {
let hint_rect = warning_notice::before_warning(footer_rect, warning_area);
if let Some(input) = self.draft.textarea.vim_query() {
input.render(inset_footer_hint_area(hint_rect), buf);
} else if let Some(line) = self.history_search_footer_line() {
render_footer_line(hint_rect, buf, line);
} else if self.shows_shared_status(options) {
self.render_status_surface(hint_rect, buf, options);
} else {
let footer_props = self.hint_footer_props(options);
let show_cycle_hint = !self.has_separate_status(options)
&& !footer_props.is_task_running
&& self.footer.collaboration_mode_indicator.is_some();
let show_shortcuts_hint =
footer_props.mode == FooterMode::ComposerEmpty && !self.is_in_paste_burst();
let show_queue_hint = footer_props.mode == FooterMode::ComposerHasDraft
&& footer_props.is_task_running;
let status_line_active = uses_passive_footer_status_layout(&footer_props);
let left_mode_indicator =
if status_line_active || self.has_separate_status(options) {
None
} else {
self.footer.collaboration_mode_indicator
};
let active_footer_hint_override = self.footer.hint_override.as_ref();
let 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 {
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 self.has_separate_status(options) {
None
} 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);
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 => {
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() {
Widget::render(&flash.line, 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 {
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);
}
}
}
}
self.footer.warning_notice_area.set(warning_area);
if let Some((warning_area, line)) = warning_notice {
line.render(warning_area, buf);
}
if let Some(selection) = options.rendered_selection
&& !self.popup_active()
&& options.footer.is_none_or(|footer| !footer.is_interactive)
{
let revision = (options.warning_count, self.footer_props().is_task_running);
let changed = self.footer.selection_revision.replace(revision) != revision;
let mut selection = selection.borrow_mut();
selection.register(status.intersection(buf.area), changed);
selection.register(footer_rect.intersection(buf.area), changed);
}
let style = user_message_style();
Block::default().style(style).render(composer_rect, buf);
if let Some(previews) = options.image_previews {
previews.render(
thumbnails,
buf,
&self.attachments.local_images,
&self.draft.textarea,
);
}
if !remote_images_rect.is_empty() {
let first = self
.attachments
.selected_remote_image_index
.unwrap_or_default()
.saturating_sub(usize::from(remote_images_rect.height) - 1);
Paragraph::new(
self.attachments
.remote_image_lines()
.into_iter()
.skip(first)
.collect::<Vec<_>>(),
)
.style(style)
.render(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 if self.luna_reserve_active {
// Reserve keeps one arrow at every reasoning effort; only its foreground changes.
"›"
.fg(crate::terminal_palette::best_color((246, 197, 67)))
.bold()
} else if let Some(tier) = self.effort_tier {
let charge = self
.effort_ignition
.as_ref()
.map(EffortIgnition::charge_alpha)
.unwrap_or(1.0);
tier.prompt(charge)
} 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();
if options.max_height.is_none() {
state.follow_cursor();
}
let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode;
if self.draft.input_enabled {
if let Some(mask_char) = mask_char {
self.draft
.textarea
.render_ref_masked(textarea_rect, buf, &mut state, mask_char);
} else {
let mut highlights = self.plugin_at_mention_highlights();
let search_highlight_style =
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
highlights.extend(
self.history_search_highlight_ranges()
.into_iter()
.chain(self.draft.textarea.vim_search_highlights())
.map(|range| (range, search_highlight_style)),
);
if highlights.is_empty() {
StatefulWidgetRef::render_ref(
&(&self.draft.textarea),
textarea_rect,
buf,
&mut state,
);
} else {
self.draft.textarea.render_ref_styled_with_highlights(
textarea_rect,
buf,
&mut state,
Style::default(),
&highlights,
);
}
}
}
if !options.unfocused && (!self.draft.input_enabled || textarea_is_empty) {
let text = if self.draft.input_enabled {
self.placeholder_text.as_str().to_string()
} else {
self.draft
.input_disabled_placeholder
.as_deref()
.unwrap_or("Input disabled.")
.to_string()
};
if !textarea_rect.is_empty() {
let placeholder = Span::from(text).dim();
Line::from(vec![placeholder]).render(textarea_rect.inner(Margin::new(0, 0)), buf);
}
}
// The reserved right margin indicates hidden rows without changing wrapping.
if options.max_height.is_some()
&& self.draft.input_enabled
&& !textarea_rect.is_empty()
&& textarea_rect.right() < composer_rect.right()
{
if state.scroll > 0 {
buf.set_span(textarea_rect.right(), textarea_rect.y, &"↑".dim(), 1);
}
if state.scroll.saturating_add(textarea_rect.height)
< self.draft.textarea.desired_height(textarea_rect.width)
{
buf.set_span(
textarea_rect.right(),
textarea_rect.bottom() - 1,
&"↓".dim(),
1,
);
}
}
if matches!(self.popups.active, ActivePopup::None)
&& let Some(ignition) = &self.effort_ignition
&& !ignition.is_finished()
{
let protected_top = if remote_images_rect.is_empty() {
textarea_rect.y
} else {
remote_images_rect.y
};
let protected = Rect::new(
composer_rect.x,
protected_top,
composer_rect.width,
textarea_rect.bottom().saturating_sub(protected_top),
);
if ignition.render(composer_rect, protected, buf)
&& let Some(frame_requester) = &self.frame_requester
{
frame_requester.schedule_frame_in(IGNITION_FRAME_TICK);
}
}
drop(state);
self.render_voice_strip(composer_rect, buf);
self.render_sparkle(
composer_rect,
textarea_rect,
self.cursor_pos_with_options(area, options),
buf,
);
if options.unfocused {
buf.set_style(composer_rect, crate::style::quiet_workspace_style());
} else if options.footer.is_some_and(|footer| footer.is_interactive) {
buf.set_style(composer_rect, Style::default().dim());
}
}
}
#[cfg(test)]
#[path = "chat_composer/agents_navigation_tests.rs"]
mod agents_navigation_tests;
#[cfg(test)]
#[path = "chat_composer_effort_tests.rs"]
mod effort_tests;
#[cfg(test)]
#[path = "chat_composer_enter_tests.rs"]
mod enter_tests;
#[cfg(test)]
#[path = "chat_composer/embedded_input_tests.rs"]
mod embedded_input_tests;
#[cfg(test)]
#[path = "chat_composer/paste_tests.rs"]
mod paste_tests;
#[cfg(test)]
#[path = "chat_composer/snapshot_tests.rs"]
mod snapshot_tests;
#[cfg(test)]
#[path = "chat_composer/mentions_layout_tests.rs"]
mod mentions_layout_tests;
#[cfg(test)]
mod tests {
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::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
pub(super) fn new_test_composer() -> (ChatComposer, UnboundedReceiver<AppEvent>) {
let (tx, rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
(
ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
),
rx,
)
}
#[test]
fn parent_owned_thread_placeholder_snapshot() {
snapshot_composer_state(
"parent_owned_thread_placeholder",
/*enhanced_keys_supported*/ false,
ChatComposer::set_parent_owned_thread,
);
}
#[test]
fn light_terminal_palette_renders_light_composer_snapshot() {
let colors = crate::terminal_probe::DefaultColors {
fg: (0x55, 0x57, 0x53),
bg: (0xff, 0xff, 0xff),
};
crate::terminal_palette::with_test_default_colors(colors, || {
let (composer, _rx) = new_test_composer();
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 6,
);
let mut buffer = Buffer::empty(area);
composer.render(area, &mut buffer);
assert_eq!(
buffer[(0, 1)].bg,
crate::terminal_palette::rgb_color((244, 244, 244))
);
insta::assert_snapshot!("light_terminal_palette_composer", format!("{buffer:?}"));
});
}
#[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:?}",
);
}
#[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:?}",
);
}
pub(super) 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, width);
let height = footer_lines + 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_shortcut_overlay_queue_submissions",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_queue_submissions(/*queue_submissions*/ 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_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_history_search_unavailable",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());
composer.begin_history_search();
for ch in ['g', 'i', 't'] {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
composer.apply_history_search_result(HistorySearchResult::Pending);
composer.apply_history_search_result(HistorySearchResult::Unavailable);
},
);
snapshot_composer_state(
"footer_mode_shell_command_absorbs_bang",
/*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").into(),
));
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").into(),
));
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").into(),
));
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
let area = Rect::new(0, 0, 100, 9);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let prompt_cell = &buf[(0, 1)];
assert_eq!(prompt_cell.symbol(), "!");
assert_eq!(prompt_cell.style().fg, Some(Color::LightRed));
let footer_y = area.height - 1;
let footer_text = (0..area.width)
.map(|x| buf[(x, footer_y)].symbol().chars().next().unwrap_or(' '))
.collect::<String>();
let shell_label_x = footer_text
.find("Shell mode")
.expect("expected shell mode footer label");
assert_eq!(
buf[(shell_label_x as u16, footer_y)].style().fg,
Some(Color::LightRed)
);
}
fn plugin_mention_foreground_color(composer: &ChatComposer) -> Option<Color> {
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let textarea_row = 1;
let row_text = (0..area.width)
.map(|x| {
buf[(x, textarea_row)]
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect::<String>();
let mention_x = row_text
.find("@sample")
.expect("expected plugin mention in composer row");
buf[(mention_x as u16, textarea_row)].style().fg
}
#[test]
fn plugin_at_mentions_use_plugin_accent_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
assert_eq!(
plugin_mention_foreground_color(&composer),
Some(Color::Magenta)
);
}
#[test]
fn plugin_at_mentions_render_with_plugin_accent_snapshot() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let textarea_row = 1;
let mut text = String::new();
let mut magenta = String::new();
for x in 0..area.width {
let cell = &buf[(x, textarea_row)];
text.push(cell.symbol().chars().next().unwrap_or(' '));
magenta.push(if cell.style().fg == Some(Color::Magenta) {
'^'
} else {
' '
});
}
while text.ends_with(' ') {
text.pop();
}
while magenta.ends_with(' ') {
magenta.pop();
}
insta::assert_snapshot!(
"plugin_at_mentions_render_with_plugin_accent",
format!("text: {text}\nmagenta: {magenta}")
);
}
#[test]
fn recalled_plugin_at_mentions_keep_plugin_accent_style() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ true,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content_with_mention_bindings(
"@sample plugin".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
composer.set_text_content(String::new(), Vec::new(), Vec::new());
let (_, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(needs_redraw);
assert_eq!(
plugin_mention_foreground_color(&composer),
Some(Color::Magenta)
);
}
#[test]
fn status_line_hyperlink_marks_pr_number_cells() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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(
crate::bottom_pane::status_line_from_segments(
[(
crate::bottom_pane::status_line_setup::StatusLineItem::PullRequestNumber,
"PR #20252".to_string(),
)],
/*use_theme_colors*/ false,
/*thread_id*/ None,
Some(url),
)
.map(Into::into),
);
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().count());
}
#[test]
fn deleting_prefix_keeps_bang_literal_on_submission() {
for disable_paste_burst in [false, true] {
for should_queue in [false, true] {
let (mut composer, _rx) = new_test_composer();
composer.set_disable_paste_burst(disable_paste_burst);
composer.set_task_running(should_queue);
type_chars_humanlike(&mut composer, &"Yes! I agree.".chars().collect::<Vec<_>>());
composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
for _ in 0..3 {
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
}
assert!(!composer.draft.is_bash_mode);
assert_eq!(composer.current_text(), "! I agree.");
// Moving and inserting elsewhere must not reinterpret the exposed bang either.
composer.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
type_chars_humanlike(&mut composer, &['!']);
assert!(!composer.draft.is_bash_mode);
assert_eq!(
composer
.handle_key_event(KeyEvent::new(
if should_queue {
KeyCode::Tab
} else {
KeyCode::Enter
},
KeyModifiers::NONE,
))
.0,
InputResult::Queued {
text: "! I agree.!".to_owned(),
text_elements: Vec::new(),
action: QueuedInputAction::Literal,
pending_pastes: Vec::new(),
}
);
}
}
}
#[test]
fn deleting_prefix_keeps_bang_editable_snapshot() {
snapshot_composer_state_with_width(
"deleting_prefix_keeps_bang_literal",
/*width*/ 60,
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_disable_paste_burst(/*disabled*/ true);
composer.handle_paste("Yes! I agree.".to_owned());
composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
for _ in 0..3 {
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
}
},
);
let (mut composer, _rx) = new_test_composer();
composer.handle_paste("Yes! I agree.".to_owned());
composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
for _ in 0..4 {
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
}
assert_eq!(composer.current_text(), " I agree.");
assert!(!composer.draft.is_bash_mode);
}
#[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);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE));
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Replace".cyan())
);
snapshot_composer_state(
"vim_replace_mode",
/*enhanced_keys_supported*/ true,
|composer| {
composer.set_vim_enabled(/*enabled*/ true);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE));
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1));
},
);
composer.insert_str("ok");
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
assert_eq!(composer.current_text(), "");
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
}
#[test]
fn slash_opens_command_popup_in_vim_insert_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);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
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_dispatches_after_vim_insert_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 ['i', '/', '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: Insert".green())
);
assert!(matches!(result, InputResult::Command(SlashCommand::Diff)));
}
#[test]
fn inline_slash_command_dispatch_resets_vim_mode_to_insert() {
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: Insert".green())
);
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"),
}
composer
.prepare_inline_args_submission(/*record_history*/ false)
.unwrap();
for code in [KeyCode::Char('x'), KeyCode::Esc, KeyCode::Char('.')] {
composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
assert_eq!(composer.current_text(), "xx");
}
#[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_insert_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: Insert".green())
);
assert!(composer.is_empty());
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, "h"),
_ => panic!("expected Submitted"),
}
}
#[test]
fn vim_mode_resets_to_insert_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: Insert".green())
);
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,);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('R'), 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,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected mention popup to open after connectors update");
};
let mention = popup
.selected_mention()
.expect("expected connector mention to be selected");
assert_eq!(mention.insert_text, "$notion".to_string());
assert_eq!(mention.path, Some("app://connector_1".to_string()));
}
#[test]
fn set_connector_mentions_skips_disabled_connectors() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
assert!(
matches!(composer.popups.active, ActivePopup::None),
"disabled connectors should not appear in the mention popup"
);
}
#[test]
fn set_plugin_mentions_refreshes_open_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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(),
plugin_namespace: None,
description: None,
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
}]));
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected mention popup to open after plugin update");
};
let mention = popup
.selected_mention()
.expect("expected plugin mention to be selected");
assert_eq!(mention.insert_text, "$sample".to_string());
assert_eq!(mention.path, Some("plugin://sample@test".to_string()));
}
fn test_skill_metadata(name: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
description: "Example skill used in tests.".to_string(),
short_description: None,
interface: None,
dependencies: None,
path: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs().into(),
scope: crate::test_support::skill_scope_user(),
enabled: true,
plugin_id: None,
}
}
fn test_skill_binding(name: &str) -> MentionBinding {
MentionBinding {
sigil: '$',
mention: name.to_string(),
path: test_path_buf(&format!("/tmp/{name}/SKILL.md"))
.abs()
.display()
.to_string(),
}
}
fn test_plugin_binding(name: &str) -> MentionBinding {
MentionBinding {
sigil: '@',
mention: name.to_string(),
path: format!("plugin://{name}@test"),
}
}
pub(super) fn test_plugin_summary(name: &str, description: &str) -> PluginCapabilitySummary {
PluginCapabilitySummary {
config_name: format!("{name}@test"),
display_name: name.to_string(),
plugin_namespace: None,
description: Some(description.to_string()),
has_skills: false,
mcp_server_names: vec![name.to_string()],
app_connector_ids: Vec::new(),
}
}
fn configure_partially_bound_skill_mentions(composer: &mut ChatComposer) {
composer.set_skill_mentions(Some(vec![test_skill_metadata("unbound-skill")]));
composer.set_text_content_with_mention_bindings(
"$unbound-skill $bound-skill continue".to_string(),
Vec::new(),
Vec::new(),
vec![test_skill_binding("bound-skill")],
);
composer.draft.textarea.set_cursor("$unbound-skill ".len());
composer.sync_popups();
}
fn configure_bound_skill_left_of_unbound_skill(composer: &mut ChatComposer) {
composer.set_skill_mentions(Some(vec![test_skill_metadata("unbound-skill")]));
composer.set_text_content_with_mention_bindings(
"$bound-skill$unbound-skill".to_string(),
Vec::new(),
Vec::new(),
vec![test_skill_binding("bound-skill")],
);
composer.draft.textarea.set_cursor("$bound-skill".len());
composer.sync_popups();
}
fn configure_skill_target_between_bound_mentions(composer: &mut ChatComposer) {
composer.set_skill_mentions(Some(vec![test_skill_metadata("other")]));
composer.set_text_content_with_mention_bindings(
"$bound1 $oth $bound2".to_string(),
Vec::new(),
Vec::new(),
vec![test_skill_binding("bound1"), test_skill_binding("bound2")],
);
composer
.draft
.textarea
.replace_range("$bound1".len().."$bound1 ".len(), "");
composer
.draft
.textarea
.replace_range("$bound1$oth".len().."$bound1$oth ".len(), "");
composer.draft.textarea.set_cursor("$bound1$o".len());
composer.sync_popups();
}
fn configure_skill_mention_with_trailing_space(composer: &mut ChatComposer) {
composer.set_skill_mentions(Some(vec![test_skill_metadata("figma")]));
let text = "$figma ";
composer.set_text_content(text.to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor(text.len());
composer.sync_popups();
}
fn configure_bound_plugin_left_of_unbound_plugin(composer: &mut ChatComposer) {
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_plugin_mentions(Some(vec![test_plugin_summary(
"other",
"Plugin used to test adjacent mention targeting.",
)]));
composer.set_text_content_with_mention_bindings(
"@sample@other".to_string(),
Vec::new(),
Vec::new(),
vec![test_plugin_binding("sample")],
);
composer.draft.textarea.set_cursor("@sample".len());
composer.sync_popups();
}
fn configure_unbound_plugin_left_of_bound_plugin(composer: &mut ChatComposer) {
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_plugin_mentions(Some(vec![test_plugin_summary(
"left",
"Plugin used to test bound mention fallback.",
)]));
composer.set_text_content_with_mention_bindings(
"@left @bound".to_string(),
Vec::new(),
Vec::new(),
vec![test_plugin_binding("bound")],
);
composer.draft.textarea.set_cursor("@left ".len());
composer.sync_popups();
}
fn configure_plugin_target_between_bound_mentions(composer: &mut ChatComposer) {
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_plugin_mentions(Some(vec![test_plugin_summary(
"other",
"Plugin used to test middle mention targeting.",
)]));
composer.set_text_content_with_mention_bindings(
"@bound1 @oth @bound2".to_string(),
Vec::new(),
Vec::new(),
vec![test_plugin_binding("bound1"), test_plugin_binding("bound2")],
);
composer
.draft
.textarea
.replace_range("@bound1".len().."@bound1 ".len(), "");
composer
.draft
.textarea
.replace_range("@bound1@oth".len().."@bound1@oth ".len(), "");
composer.draft.textarea.set_cursor("@bound1@o".len());
composer.sync_popups();
}
#[test]
fn skill_popup_targets_unbound_mention_left_of_bound_mention() {
let (mut composer, _rx) = new_test_composer();
configure_partially_bound_skill_mentions(&mut composer);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
composer.current_text(),
"$unbound-skill $bound-skill continue"
);
assert_eq!(
composer.mention_bindings(),
vec![
test_skill_binding("unbound-skill"),
test_skill_binding("bound-skill"),
]
);
composer.insert_str("foo");
assert_eq!(
composer.current_text(),
"$unbound-skill foo $bound-skill continue"
);
}
#[test]
fn skill_popup_targets_unbound_mention_left_of_bound_mention_snapshot() {
snapshot_composer_state(
"skill_popup_targets_unbound_mention_left_of_bound_mention",
/*enhanced_keys_supported*/ false,
configure_partially_bound_skill_mentions,
);
}
#[test]
fn skill_popup_prefers_confirmed_selections_within_match_tier() {
snapshot_composer_state(
"skill_popup_prefers_confirmed_selections_within_match_tier",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![
test_skill_metadata("statsig"),
test_skill_metadata("status"),
test_skill_metadata("hello-statsig"),
]));
for name in ["hello-statsig", "hello-statsig", "status"] {
let text = format!("${name}");
composer.set_text_content(text.clone(), Vec::new(), Vec::new());
composer.set_current_cursor(text.len());
composer.sync_popups();
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
composer.set_text_content("$stat".to_owned(), Vec::new(), Vec::new());
composer.set_current_cursor("$stat".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected skill popup");
};
assert_eq!(
popup
.selected_mention()
.map(|item| item.insert_text.as_str()),
Some("$status")
);
},
);
}
#[test]
fn skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention_snapshot() {
snapshot_composer_state(
"skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention",
/*enhanced_keys_supported*/ false,
configure_bound_skill_left_of_unbound_skill,
);
}
#[test]
fn skill_popup_falls_back_from_bound_skill_with_path_suffix_snapshot() {
snapshot_composer_state(
"skill_popup_falls_back_from_bound_skill_with_path_suffix",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("left")]));
composer.set_text_content_with_mention_bindings(
"$left $bound/path".to_string(),
Vec::new(),
Vec::new(),
vec![test_skill_binding("bound")],
);
composer.draft.textarea.set_cursor("$left ".len());
composer.sync_popups();
},
);
}
#[test]
fn skill_popup_closes_after_trailing_space_snapshot() {
snapshot_composer_state(
"skill_popup_closes_after_trailing_space",
/*enhanced_keys_supported*/ false,
configure_skill_mention_with_trailing_space,
);
}
#[test]
fn unified_mention_popup_falls_back_from_bound_plugin_on_right_snapshot() {
snapshot_composer_state(
"unified_mention_popup_falls_back_from_bound_plugin_on_right",
/*enhanced_keys_supported*/ false,
configure_unbound_plugin_left_of_bound_plugin,
);
}
#[test]
fn adjacent_plugin_completion_inserts_separator_snapshot() {
snapshot_composer_state(
"adjacent_plugin_completion_inserts_separator",
/*enhanced_keys_supported*/ false,
|composer| {
configure_bound_plugin_left_of_unbound_plugin(composer);
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
},
);
}
#[test]
fn skill_completion_replaces_only_middle_adjacent_target() {
let (mut composer, _rx) = new_test_composer();
configure_skill_target_between_bound_mentions(&mut composer);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "$bound1 $other $bound2");
assert_eq!(
composer.mention_bindings(),
vec![
test_skill_binding("bound1"),
test_skill_binding("other"),
test_skill_binding("bound2"),
]
);
}
#[test]
fn unified_completion_replaces_only_middle_adjacent_target() {
let (mut composer, _rx) = new_test_composer();
configure_plugin_target_between_bound_mentions(&mut composer);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "@bound1 @other @bound2");
assert_eq!(
composer.mention_bindings(),
vec![
test_plugin_binding("bound1"),
test_plugin_binding("other"),
test_plugin_binding("bound2"),
]
);
}
#[test]
fn skill_popup_falls_back_from_shell_variable_to_skill_on_right() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")]));
composer.set_text_content("$HOME $rustdoc".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$HOME ".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected skill popup to fall back to the right token");
};
assert_eq!(
popup
.selected_mention()
.expect("expected rustdoc selection")
.insert_text,
"$rustdoc"
);
}
#[test]
fn file_popup_ignores_shell_positional_parameter_snapshot() {
snapshot_composer_state(
"file_popup_ignores_shell_positional_parameter",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("available")]));
composer.set_text_content("@src $1_suffix".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
},
);
}
#[test]
fn file_popup_ignores_bare_shell_parameter_with_matching_skill_snapshot() {
snapshot_composer_state(
"file_popup_ignores_bare_shell_parameter_with_matching_skill",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("_tool")]));
composer.set_text_content("@src $_".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
},
);
}
#[test]
fn file_popup_ignores_definite_shell_parameters_with_matching_skills() {
for (query, skill_name) in [("12", "12factor"), ("-", "-tool")] {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata(skill_name)]));
composer.set_text_content(format!("@src ${query}"), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
}
}
#[test]
fn skill_popup_accepts_loaded_hyphen_leading_skill_name() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("-tool")]));
composer.set_text_content("$-t".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$-t".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected skill popup for -tool");
};
assert_eq!(
popup
.selected_mention()
.expect("expected -tool selection")
.insert_text,
"$-tool"
);
}
#[test]
fn file_popup_ignores_unbindable_qualified_skill_snapshot() {
snapshot_composer_state(
"file_popup_ignores_unbindable_qualified_skill",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("1plugin:deploy")]));
composer.set_text_content("@src $1plugin:d".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
},
);
}
#[test]
fn skill_popup_preserves_normal_target_after_ambiguous_probe_snapshot() {
snapshot_composer_state(
"skill_popup_preserves_normal_target_after_ambiguous_probe",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("plugin:deploy")]));
composer.set_text_content("$1x $plugin:d".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$1x ".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected the right qualified skill popup");
};
assert_eq!(
popup
.selected_mention()
.expect("expected qualified skill selection")
.insert_text,
"$plugin:deploy"
);
},
);
}
#[test]
fn mention_target_prebuilds_catalog_only_for_ambiguous_query() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("1password")]));
composer.set_text_content("$normal".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$normal".len());
let normal_target = composer
.current_mention_target()
.expect("expected normal mention target");
assert!(normal_target.prebuilt_mentions.is_none());
composer.set_text_content("$1p".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$1p".len());
let ambiguous_target = composer
.current_mention_target()
.expect("expected ambiguous mention target");
assert_eq!(
ambiguous_target.prebuilt_mentions.map(|mentions| {
mentions
.into_iter()
.map(|mention| mention.display_name)
.collect::<Vec<_>>()
}),
Some(vec!["1password".to_string()])
);
}
#[test]
fn skill_popup_accepts_digit_leading_skill_snapshot() {
snapshot_composer_state(
"skill_popup_accepts_digit_leading_skill",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("1password")]));
composer.set_text_content("$1p".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$1p".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::Skill(_)));
},
);
}
#[test]
fn skill_popup_does_not_fuzzy_match_shell_variable_snapshot() {
snapshot_composer_state(
"skill_popup_does_not_fuzzy_match_shell_variable",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("home")]));
composer.set_text_content("$HOME".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$HOME".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::None));
},
);
}
#[test]
fn skill_popup_preserves_loaded_shell_like_skill_at_separator() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![
test_skill_metadata("1password"),
test_skill_metadata("rustdoc"),
]));
composer.set_text_content("$1p $rustdoc".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$1p ".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected the left shell-like skill popup");
};
assert_eq!(
popup
.selected_mention()
.expect("expected 1password selection")
.insert_text,
"$1password"
);
}
#[test]
fn skill_popup_accepts_lowercase_skill_named_like_env_var() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("home")]));
composer.set_text_content("$home".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$home".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected skill popup for lowercase home skill");
};
assert_eq!(
popup
.selected_mention()
.expect("expected home skill selection")
.insert_text,
"$home"
);
}
#[test]
fn legacy_file_popup_falls_back_when_right_skill_is_unavailable() {
let (mut composer, _rx) = new_test_composer();
composer.set_text_content("@src $missing".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
}
#[test]
fn unified_popup_falls_back_when_right_skill_is_unavailable() {
let (mut composer, _rx) = new_test_composer();
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_text_content("@src $missing".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@src ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
}
#[test]
fn skill_popup_falls_back_when_right_at_mention_is_bound() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("old")]));
composer.set_text_content_with_mention_bindings(
"$old @bound".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "bound".to_string(),
path: "plugin://bound@test".to_string(),
}],
);
composer.draft.textarea.set_cursor("$old ".len());
composer.sync_popups();
let ActivePopup::Skill(popup) = &composer.popups.active else {
panic!("expected the left skill popup");
};
assert_eq!(
popup
.selected_mention()
.expect("expected old skill selection")
.insert_text,
"$old"
);
}
#[test]
fn legacy_popup_prefers_right_at_token_over_left_skill() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("old")]));
composer.set_text_content("$old @new".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$old ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
}
#[test]
fn unified_popup_prefers_right_skill_over_left_at_token() {
let (mut composer, _rx) = new_test_composer();
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_skill_mentions(Some(vec![test_skill_metadata("new")]));
composer.set_text_content("@old $new".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@old ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::Skill(_)));
}
#[test]
fn skill_popup_closes_at_plain_text_after_whitespace() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("old")]));
composer.set_text_content("$old word".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$old ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn skill_popup_closes_between_spaces_before_plain_text_snapshot() {
snapshot_composer_state(
"skill_popup_closes_between_spaces_before_plain_text",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_skill_mentions(Some(vec![test_skill_metadata("old")]));
composer.set_text_content("$old word".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("$old ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::None));
},
);
}
fn assert_typed_skill_prefix_starts_new_mention(inserted: &str) {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("rustdoc")]));
composer.set_text_content("$simplify-code".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor(/*pos*/ 0);
composer.insert_str(inserted);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(composer.current_text(), "$rustdoc $simplify-code");
assert_eq!(
composer.mention_bindings(),
vec![test_skill_binding("rustdoc")]
);
}
#[test]
fn typing_empty_skill_prefix_before_existing_skill_starts_new_mention() {
assert_typed_skill_prefix_starts_new_mention("$");
}
#[test]
fn typing_partial_skill_prefix_before_existing_skill_starts_new_mention() {
assert_typed_skill_prefix_starts_new_mention("$r");
}
#[test]
fn set_skill_mentions_refreshes_open_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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,
path: skill_path.clone().into(),
scope: crate::test_support::skill_scope_user(),
enabled: true,
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_keep_direct_skills_and_apps_with_unified_mentions() {
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,
icon_small_url: None,
icon_large_url: None,
brand_color: None,
default_prompt: None,
}),
dependencies: None,
path: skill_path.clone().into(),
scope: crate::test_support::skill_scope_repo(),
enabled: true,
plugin_id: Some("google-calendar@debug".to_string()),
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),
display_name: "Google Calendar".to_string(),
plugin_namespace: None,
description: Some(
"Connect Google Calendar for scheduling, availability, and event management."
.to_string(),
),
has_skills: true,
mcp_server_names: vec!["google-calendar".to_string()],
app_connector_ids: vec![AppConnectorId("google_calendar".to_string())],
}]));
composer.set_connector_mentions(Some(ConnectorsSnapshot {
connectors: vec![AppInfo {
id: "google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Look up events and availability".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: vec!["Google Calendar".to_string()],
}],
}));
let mentions = composer.mention_items();
assert_eq!(mentions.len(), 3);
assert_eq!(mentions[0].category_tag, Some("[Skill]".to_string()));
assert_eq!(mentions[0].path, Some(skill_path.display().to_string()));
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
assert_eq!(mentions[1].category_tag, Some("[Plugin]".to_string()));
assert_eq!(
mentions[1].path,
Some("plugin://google-calendar@debug".to_string())
);
assert_eq!(mentions[2].category_tag, Some("[App]".to_string()));
assert_eq!(mentions[2].path, Some("app://google_calendar".to_string()));
composer.set_mentions_v2_enabled(/*enabled*/ true);
assert_eq!(
composer
.mention_items()
.into_iter()
.map(|mention| (mention.insert_text, mention.path))
.collect::<Vec<_>>(),
vec![
(
"$google-calendar:availability".to_string(),
Some(skill_path.display().to_string()),
),
(
"$google-calendar".to_string(),
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(),
plugin_namespace: None,
description: Some(
"Plugin that includes the Figma MCP server and Skills for common workflows"
.to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: vec![AppConnectorId("calendar".to_string())],
}]));
},
);
}
#[test]
fn default_unified_mention_popup_snapshot() {
snapshot_composer_state(
"default_unified_mention_popup",
/*enhanced_keys_supported*/ false,
|composer| {
let features = codex_features::Features::with_defaults();
composer
.set_mentions_v2_enabled(features.enabled(codex_features::Feature::MentionsV2));
composer.set_skill_mentions(Some(vec![
SkillMetadata {
name: "sample-tools:search".to_string(),
plugin_id: Some("sample@test".to_string()),
..test_skill_metadata("sample-search")
},
SkillMetadata {
name: "sample:summarize".to_string(),
plugin_id: Some("sample@test".to_string()),
..test_skill_metadata("sample-summarize")
},
SkillMetadata {
name: "sample:notes".to_string(),
..test_skill_metadata("sample-notes")
},
SkillMetadata {
name: "sample:other".to_string(),
plugin_id: Some("sample@other-marketplace".to_string()),
..test_skill_metadata("sample-other")
},
]));
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(),
plugin_namespace: Some("sample".to_string()),
description: Some("Plugin with skills and an MCP server".to_string()),
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
}]));
},
);
}
#[test]
fn task_mention_popup_and_selected_composer_snapshots() {
for selected in [false, true] {
snapshot_composer_state(
if selected {
"task_mention_selected_composer"
} else {
"task_mention_popup"
},
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_task_mentions_enabled(/*enabled*/ true);
composer.set_text_content("@migr".to_string(), Vec::new(), Vec::new());
composer.on_task_search_result(
"migr",
vec![
crate::task_mentions::TaskMention {
thread_id: "task-123".to_string(),
title: "Review database migration".to_string(),
cwd: "/workspace/project".to_string(),
snippet: "database migration".to_string(),
},
crate::task_mentions::TaskMention {
thread_id: "task-456".to_string(),
title: "migration".to_string(),
cwd: "/another/project".to_string(),
snippet: "migration".to_string(),
},
],
);
if selected {
composer
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(composer.mention_bindings().as_slice(),
[MentionBinding { mention, path, .. }]
if mention == "Review database migration"
&& path == "thread://task-123"));
assert!(composer.task_mentions.as_ref().is_some_and(Vec::is_empty));
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
assert_eq!(buf[(2, 1)].style().fg, Some(Color::Cyan));
}
},
);
}
}
#[test]
fn task_mention_titles_are_normalized_and_bounded_before_insertion() {
let (mut composer, _rx) = new_test_composer();
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_task_mentions_enabled(/*enabled*/ true);
composer.set_text_content("@task".to_string(), Vec::new(), Vec::new());
let task = crate::task_mentions::TaskMention {
thread_id: "task-123".to_string(),
title: format!("task\n\t{}é{}", "a".repeat(154), "é".repeat(200)),
cwd: "/workspace/project".to_string(),
snippet: String::new(),
};
composer.on_task_search_result("task", vec![task.clone()]);
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
composer.mention_bindings(),
vec![MentionBinding {
sigil: '@',
mention: format!("task {}é", "a".repeat(154)),
path: "thread://task-123".to_string(),
}]
);
composer.draft.textarea.set_text_clearing_elements("@task");
composer.sync_popups();
composer.on_task_search_result("task", vec![task]);
assert!(matches!(&composer.popups.active,
ActivePopup::MentionV2(popup) if popup.selected().is_some()));
let history = HistoryEntry::new(
"[@sample](plugin://sample@test) @sample [@sample](thread://task-123)x".to_string(),
);
composer.set_text_content_with_mention_bindings(
history.text,
history.text_elements,
Vec::new(),
history.mention_bindings,
);
assert_eq!(
composer.mention_bindings(),
["plugin://sample@test", "thread://task-123"]
.into_iter()
.map(|path| MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: path.to_string(),
})
.collect::<Vec<_>>()
);
assert_eq!(
composer.draft.textarea.text_element_snapshots()[1]
.range
.start,
"@sample @sample ".len()
);
}
#[test]
fn bare_unified_mention_resets_an_inherited_file_search() {
let (mut composer, mut rx) = new_test_composer();
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.insert_str("@");
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::StartFileSearch(query)) if query.is_empty()
));
composer.insert_str("s");
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::StartFileSearch(query)) if query == "s"
));
}
#[test]
fn reopening_identical_unified_mention_restarts_file_search() {
let (mut composer, mut rx) = new_test_composer();
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_text_content("@foo @foo".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@f".len());
composer.sync_popups();
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::StartFileSearch(query)) if query.is_empty()
));
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::StartFileSearch(query)) if query == "foo"
));
composer.on_file_search_result("foo".to_string(), Vec::new());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.popups.active, ActivePopup::None));
composer.draft.textarea.set_cursor("@foo @foo".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
let queries = std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::StartFileSearch(query) => Some(query),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(queries, vec![String::new(), "foo".to_string()]);
}
#[test]
fn restored_unified_mention_restarts_file_search() {
let (mut composer, mut rx) = new_test_composer();
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_text_content("@foo".to_string(), Vec::new(), Vec::new());
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
let queries = std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::StartFileSearch(query) => Some(query),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(queries, vec![String::new(), "foo".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_mentions_v2_enabled(/*enabled*/ true);
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,
icon_small_url: None,
icon_large_url: None,
brand_color: None,
default_prompt: None,
}),
dependencies: None,
path: test_path_buf("/tmp/repo/google-calendar/SKILL.md")
.abs()
.into(),
scope: crate::test_support::skill_scope_repo(),
enabled: true,
plugin_id: None,
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),
display_name: "Google Calendar".to_string(),
plugin_namespace: None,
description: Some(
"Connect Google Calendar for scheduling, availability, and event management."
.to_string(),
),
has_skills: false,
mcp_server_names: vec!["google-calendar".to_string()],
app_connector_ids: Vec::new(),
}]));
composer.set_connector_mentions(Some(ConnectorsSnapshot {
connectors: vec![AppInfo {
id: "google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Look up events and availability".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}],
}));
},
);
}
#[test]
fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn shortcut_overlay_persists_while_task_running() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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 set_text_content_rebinds_at_sigiled_mentions() {
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 {
sigil: '@',
mention: "figma".to_string(),
path: "/tmp/user/figma/SKILL.md".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"@figma please".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_matching_sigil_only() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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 {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"@figma then $figma".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let bound_tokens = composer
.current_mention_elements()
.into_iter()
.map(|(_, sigil, mention)| (sigil, mention))
.collect::<Vec<_>>();
assert_eq!(bound_tokens, vec![('$', "figma".to_string())]);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_both_sigil_forms() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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 {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@test".to_string(),
},
MentionBinding {
sigil: '$',
mention: "figma".to_string(),
path: "app://figma".to_string(),
},
];
composer.set_text_content_with_mention_bindings(
"@figma then $figma".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let bound_tokens = composer
.current_mention_elements()
.into_iter()
.map(|(_, sigil, mention)| (sigil, mention))
.collect::<Vec<_>>();
assert_eq!(
bound_tokens,
vec![('@', "figma".to_string()), ('$', "figma".to_string())]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_at_mentions_after_email_substrings() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let text = "foo@sample.com then @sample".to_string();
let mention_start = text.rfind("@sample").expect("expected bound mention token");
let mention_range = mention_start..mention_start + "@sample".len();
let mention_bindings = vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}];
composer.set_text_content_with_mention_bindings(
text,
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
// Fix coverage: only the plaintext `@sample` should be atomic; the email substring stays editable.
assert_eq!(
composer
.draft
.textarea
.text_element_snapshots()
.into_iter()
.map(|snapshot| snapshot.range)
.collect::<Vec<_>>(),
vec![mention_range]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn set_text_content_rebinds_at_mentions_after_punctuation() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
let text = "Please ask (@sample)".to_string();
let mention_start = text.find("@sample").expect("expected bound mention token");
let mention_range = mention_start..mention_start + "@sample".len();
let mention_bindings = vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}];
composer.set_text_content_with_mention_bindings(
text,
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
assert_eq!(
composer
.draft
.textarea
.text_element_snapshots()
.into_iter()
.map(|snapshot| snapshot.range)
.collect::<Vec<_>>(),
vec![mention_range]
);
assert_eq!(composer.mention_bindings(), mention_bindings);
}
#[test]
fn bound_at_mentions_do_not_block_arrow_navigation() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content_with_mention_bindings(
"go @figma now".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "figma".to_string(),
path: "plugin://figma@debug".to_string(),
}],
);
composer.draft.textarea.set_cursor("go".len());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go".len() + 1);
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go @figma".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go ".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.draft.textarea.cursor(), "go".len());
assert!(matches!(composer.popups.active, ActivePopup::None));
}
#[test]
fn restored_bound_at_mentions_do_not_open_mention_popup() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
for (text, move_cursor_to_end) in [
("@sample".to_string(), false),
("Please ask @sample.".to_string(), true),
] {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
plugin_namespace: None,
description: None,
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
}]));
composer.set_text_content_with_mention_bindings(
text.clone(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
if move_cursor_to_end {
composer.move_cursor_to_end();
}
assert!(matches!(composer.popups.active, ActivePopup::None));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted {
text: submitted, ..
} => assert_eq!(submitted, text),
_ => panic!("expected restored bound mention to submit"),
}
}
}
#[test]
fn enter_submits_when_file_popup_has_no_selection() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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"),
}
}
#[test]
fn enter_submits_when_unified_mention_popup_has_no_selection() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_mentions_v2_enabled(/*enabled*/ true);
let input = "npx -y @kaeawc/auto-mobile@latest";
composer.draft.textarea.insert_str(input);
composer.draft.textarea.set_cursor(input.len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, input),
_ => panic!("expected Submitted"),
}
}
/// 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_even_when_parent_owned() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
for parent_owned in [false, true] {
let (mut composer, _rx) = new_test_composer();
if parent_owned {
composer.set_parent_owned_thread();
}
let mut now = Instant::now();
let step = Duration::from_millis(1);
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
now,
);
now += step;
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
now,
);
now += step;
let (result, _) =
composer.handle_submission_with_time(/*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: startup-pending submissions are queued immediately, so Enter should flush any
/// buffered burst text into that queued message instead of turning into a draft newline.
#[test]
fn queued_submission_flushes_ascii_burst_instead_of_inserting_newline() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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);
for ch in ['h', 'i'] {
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
now,
);
now += step;
}
assert!(composer.is_in_paste_burst());
let (result, _) = composer.handle_submission_with_time(/*should_queue*/ true, now);
assert_eq!(
result,
InputResult::Queued {
text: "hi".to_string(),
text_elements: Vec::new(),
action: QueuedInputAction::Plain,
pending_pastes: Vec::new(),
}
);
assert!(composer.draft.textarea.text().is_empty());
assert!(!composer.is_in_paste_burst());
}
/// 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 sanitized = "_count_rows\tindent\ntwo";
let needs_redraw =
composer.handle_paste("_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}".to_string());
assert!(needs_redraw);
assert_eq!(composer.draft.textarea.text(), sanitized);
assert!(composer.draft.pending_pastes.is_empty());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, sanitized),
_ => panic!("expected Submitted"),
}
}
#[test]
fn empty_enter_returns_none() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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| {
let (mut previous, _rx) = new_test_composer();
previous.attach_image(PathBuf::from("/tmp/image1.png"));
previous.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
composer.restore_kill_buffer_snapshot(previous.take_kill_buffer_snapshot());
composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
},
);
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_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_clipping_snapshot() {
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 doesn’t interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let mut terminal =
Terminal::new(TestBackend::new(/*width*/ 60, /*height*/ 3)).expect("create terminal");
terminal
.draw(|f| {
composer.render(f.area(), f.buffer_mut());
assert_eq!(composer.cursor_pos(f.area()), Some((5, 1)));
})
.expect("draw composer");
// The picker sits above the draft; clipping must keep the draft and cursor visible.
insta::assert_snapshot!("slash_popup_mo_clipped", terminal.backend());
}
#[test]
fn slash_popup_selects_first_matching_command() {
use super::super::command_popup::CommandItem;
for (input, expected) in [
("/mo", "model"),
("/res", "resume"),
("/ar", "archive"),
("/pet", "pets"),
("/bt", "btw"),
("/si", "side"),
] {
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, &input.chars().collect::<Vec<_>>());
match &composer.popups.active {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), expected),
Some(CommandItem::ServiceTier(command)) => {
panic!("expected {expected} command, got service tier {command:?}")
}
None => panic!("no selected command for '{input}'"),
},
_ => panic!("slash popup not active after typing '{input}'"),
}
}
}
#[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(),
})
);
}
#[test]
fn dynamic_slash_command_becomes_editable_and_is_not_exported() {
let (mut composer, _rx) = new_test_composer();
composer.set_voice_command_enabled(/*enabled*/ true);
type_chars_humanlike(&mut composer, &['/', 'v', 'o', 'i', 'c', 'e', ' ']);
assert_eq!(composer.draft.textarea.text_elements().len(), 1);
composer.set_voice_command_enabled(/*enabled*/ false);
assert_eq!(composer.current_text(), "/voice ");
assert!(composer.draft.textarea.text_elements().is_empty());
assert!(composer.text_elements().is_empty());
}
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
pub(super) 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::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/init'"),
}
assert!(
composer.draft.textarea.is_empty(),
"composer should be cleared"
);
}
#[test]
fn kill_buffer_preserves_image_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 ");
let image_path = PathBuf::from("/tmp/killed-image.png");
composer.attach_image(image_path.clone());
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('u'), 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 other_image_path = PathBuf::from("/tmp/other-image.png");
composer.attach_image(other_image_path.clone());
composer.handle_paste(" ".into());
let (_result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
result,
InputResult::Submitted {
text: "[Image #1] restore me [Image #2]".into(),
text_elements: vec![
TextElement::new((0..10).into(), Some("[Image #1]".into())),
TextElement::new((22..32).into(), Some("[Image #2]".into())),
],
}
);
assert_eq!(
composer.take_recent_submission_images(),
vec![other_image_path, image_path]
);
}
#[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("/compact");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!("/compact", 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 enter_queues_when_queue_submissions_is_enabled() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_queue_submissions(/*queue_submissions*/ true);
composer
.draft
.textarea
.set_text_clearing_elements("queued before session");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
result,
InputResult::Queued {
text: "queued before session".to_string(),
text_elements: Vec::new(),
action: QueuedInputAction::Plain,
pending_pastes: Vec::new(),
}
);
}
#[test]
fn tab_queues_slash_led_prompts_while_task_running_without_validation() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
fn assert_queued_slash(input: &str) {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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_bang_input(
input: &str,
expected_text: &str,
expected_action: QueuedInputAction,
) {
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.handle_paste(input.to_owned());
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, expected_action);
}
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_bang_input("!echo hi", "!echo hi", QueuedInputAction::RunShell);
assert_queued_bang_input("!", "!", QueuedInputAction::RunShell);
assert_queued_bang_input(" !echo hi", "!echo hi", QueuedInputAction::Literal);
}
#[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::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/diff'"),
}
assert!(composer.draft.textarea.is_empty());
}
#[test]
fn slash_command_elementizes_on_space() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/mention'"),
}
assert!(
composer.draft.textarea.is_empty(),
"composer should be cleared"
);
composer.insert_str("@");
assert_eq!(composer.draft.textarea.text(), "@");
}
#[test]
fn slash_plan_args_preserve_text_elements() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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"),
}
}
fn complete_file(
composer: &mut ChatComposer,
text: &str,
cursor: usize,
query: &str,
selected_path: PathBuf,
) {
composer.set_text_content(text.to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor(cursor);
composer.sync_popups();
let root = selected_path
.parent()
.filter(|_| selected_path.is_absolute())
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("/tmp"));
composer.on_file_search_result(
query.to_string(),
vec![FileMatch {
score: 1,
path: selected_path,
match_type: codex_file_search::MatchType::File,
root,
indices: None,
}],
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
#[test]
fn legacy_file_completion_ignores_shell_variable_to_the_right() {
let (mut composer, _rx) = new_test_composer();
composer.set_skill_mentions(Some(vec![test_skill_metadata("available")]));
complete_file(
&mut composer,
"@src $HOME",
/*cursor*/ "@src ".len(),
"src",
PathBuf::from("src/main.rs"),
);
assert_eq!(composer.current_text(), "src/main.rs $HOME");
}
#[test]
fn unified_popup_preserves_empty_at_before_existing_text() {
let (mut composer, _rx) = new_test_composer();
composer.set_mentions_v2_enabled(/*enabled*/ true);
composer.set_text_content("@ word".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_)));
}
#[test]
fn adjacent_file_completion_inserts_leading_separator() {
let (mut composer, _rx) = new_test_composer();
configure_bound_plugin_left_of_unbound_plugin(&mut composer);
composer.insert_selected_file_path("@sample".len().."@sample@other".len(), "src/main.rs");
assert_eq!(composer.current_text(), "@sample src/main.rs ");
}
#[test]
fn adjacent_image_completion_inserts_leading_separator() {
let tmp = tempdir().expect("create TempDir");
let image_path = tmp.path().join("image.png");
let image: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255]));
image.save(&image_path).expect("write temp png");
let (mut composer, _rx) = new_test_composer();
configure_bound_plugin_left_of_unbound_plugin(&mut composer);
composer.set_vim_enabled(/*enabled*/ true);
let before = composer.draft_snapshot();
composer.insert_selected_file_path(
"@sample".len().."@sample@other".len(),
image_path.to_str().expect("UTF-8 path"),
);
assert_eq!(composer.current_text(), "@sample [Image #1] ");
assert_eq!(composer.local_image_paths(), vec![image_path]);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
assert_eq!(composer.draft_snapshot(), before);
}
#[test]
fn file_completion_preserves_separator_before_existing_suffix() {
let (mut composer, _rx) = new_test_composer();
complete_file(
&mut composer,
"@ma next",
/*cursor*/ "@ma".len(),
"ma",
PathBuf::from("src/main.rs"),
);
composer.insert_str("foo");
assert_eq!(composer.current_text(), "src/main.rs foo next");
}
#[test]
fn file_completion_preserves_separator_before_sigiled_suffix() {
for suffix in ["@next", "$next"] {
let (mut composer, _rx) = new_test_composer();
composer.set_text_content(format!("@ma {suffix}"), Vec::new(), Vec::new());
composer.insert_selected_path(0.."@ma".len(), "src/main.rs");
composer.insert_str("foo");
assert_eq!(composer.current_text(), format!("src/main.rs foo {suffix}"));
}
}
#[test]
fn file_completion_inserts_separator_before_line_break() {
let (mut composer, _rx) = new_test_composer();
complete_file(
&mut composer,
"@ma\nnext",
/*cursor*/ "@ma".len(),
"ma",
PathBuf::from("src/main.rs"),
);
composer.insert_str("foo");
assert_eq!(composer.current_text(), "src/main.rs foo\nnext");
}
#[test]
fn file_completion_for_sigil_path_does_not_reopen_popup() {
let (mut composer, _rx) = new_test_composer();
complete_file(
&mut composer,
"@ma\nnext",
/*cursor*/ "@ma".len(),
"ma",
PathBuf::from("@scope/main.rs"),
);
assert_eq!(composer.current_text(), "@scope/main.rs \nnext");
assert!(matches!(composer.popups.active, ActivePopup::None));
let (result, consumed) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(consumed);
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, "@scope/main.rs \nnext"),
_ => panic!("expected completed path to submit"),
}
}
#[test]
fn file_completion_does_not_dismiss_identical_next_token() {
let (mut composer, _rx) = new_test_composer();
complete_file(
&mut composer,
"@ma @scope/main.rs",
/*cursor*/ "@ma".len(),
"ma",
PathBuf::from("@scope/main.rs"),
);
composer.draft.textarea.set_cursor("@scope/main.rs ".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
}
#[test]
fn dismissed_file_popup_tracks_token_across_leading_whitespace_edits() {
let (mut composer, _rx) = new_test_composer();
composer.set_text_content("@ma".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@ma".len());
composer.sync_popups();
assert!(matches!(composer.popups.active, ActivePopup::File(_)));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.popups.active, ActivePopup::None));
composer.draft.textarea.set_cursor(/*pos*/ 0);
composer.insert_str(" ");
assert!(matches!(composer.popups.active, ActivePopup::None));
for _ in 0..3 {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(matches!(composer.popups.active, ActivePopup::None));
}
}
#[test]
fn dismissed_file_popup_ignores_token_substrings_in_leading_paste() {
let (mut composer, _rx) = new_test_composer();
composer.set_text_content("@ma".to_string(), Vec::new(), Vec::new());
composer.draft.textarea.set_cursor("@ma".len());
composer.sync_popups();
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.popups.active, ActivePopup::None));
composer.draft.textarea.set_cursor(/*pos*/ 0);
composer.handle_paste("email@ma.com ".to_string());
assert_eq!(composer.current_text(), "email@ma.com @ma");
assert!(matches!(composer.popups.active, ActivePopup::None));
}
/// 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 {
sigil: '$',
mention: "figma".to_string(),
path: "/tmp/user/figma/SKILL.md".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"$figma please".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
assert_eq!(
composer.take_recent_submission_mention_bindings(),
mention_bindings
);
assert!(composer.take_mention_bindings().is_empty());
}
#[test]
fn history_navigation_restores_remote_and_local_image_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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_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();
Arc::make_mut(&mut keymap.editor).move_up = vec![key_hint::plain(KeyCode::F(2))];
composer.set_keymap_bindings(&keymap);
assert!(Arc::ptr_eq(&composer.editor_keymap, &keymap.editor));
assert_eq!(Arc::strong_count(&keymap.editor), 3);
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![LocalImageAttachment {
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![LocalImageAttachment {
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:?}"),
}
}
#[test]
fn shortcut_modified_input_does_not_enter_paste_bursts() {
snapshot_composer_state(
"shortcut_modified_space_is_not_rendered",
/*enhanced_keys_supported*/ false,
|composer| {
let now = Instant::now();
for input in [
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::SUPER),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
] {
composer.handle_input_basic_with_time(input, now);
}
assert!(
composer.handle_paste_burst_flush(
now + PasteBurst::recommended_active_flush_delay()
)
);
},
);
for modifiers in [
KeyModifiers::SUPER,
KeyModifiers::SHIFT | KeyModifiers::SUPER,
KeyModifiers::HYPER,
KeyModifiers::META,
] {
let (mut composer, _rx) = new_test_composer();
let now = Instant::now();
composer
.handle_input_basic_with_time(KeyEvent::new(KeyCode::Char(' '), modifiers), now);
assert!(
!composer.is_in_paste_burst(),
"shortcut modifiers {modifiers:?} must not start a paste burst"
);
assert!(
!composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay())
);
assert_eq!(composer.draft.textarea.text(), "");
}
}
#[test]
fn plain_and_shift_spaces_insert_normally() {
for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
let (mut composer, _rx) = new_test_composer();
let now = Instant::now();
composer
.handle_input_basic_with_time(KeyEvent::new(KeyCode::Char(' '), modifiers), now);
assert!(composer.is_in_paste_burst());
assert_eq!(composer.draft.textarea.text(), "");
assert!(composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay()));
assert_eq!(composer.draft.textarea.text(), " ");
assert!(!composer.is_in_paste_burst());
}
snapshot_composer_state(
"plain_space_is_rendered",
/*enhanced_keys_supported*/ false,
|composer| {
let now = Instant::now();
for input in [
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
] {
composer.handle_input_basic_with_time(input, now);
}
assert!(
composer.handle_paste_burst_flush(
now + PasteBurst::recommended_active_flush_delay()
)
);
},
);
}
#[test]
fn non_text_input_flushes_pending_paste_without_inserting_text() {
let (mut composer, _rx) = new_test_composer();
let now = Instant::now();
composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
now,
);
composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
now,
);
assert!(composer.is_in_paste_burst());
assert_eq!(composer.draft.textarea.text(), "");
composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::SUPER),
now,
);
assert_eq!(composer.draft.textarea.text(), "ab");
assert!(!composer.is_in_paste_burst());
assert!(!composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay()));
}
#[test]
fn platform_specific_altgr_input_remains_text() {
for modifiers in [
KeyModifiers::CONTROL | KeyModifiers::ALT,
KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT,
] {
let (mut composer, _rx) = new_test_composer();
let now = Instant::now();
composer
.handle_input_basic_with_time(KeyEvent::new(KeyCode::Char('@'), modifiers), now);
if cfg!(windows) {
assert!(composer.is_in_paste_burst());
assert!(
composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay())
);
assert_eq!(composer.draft.textarea.text(), "@");
} else {
assert!(!composer.is_in_paste_burst());
assert!(
!composer.handle_paste_burst_flush(now + PasteBurst::recommended_flush_delay())
);
assert_eq!(composer.draft.textarea.text(), "");
}
}
}
/// 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(/*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(/*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 mut now = Instant::now();
let step = ChatComposer::recommended_paste_flush_delay();
for _ in 0..count {
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
now,
);
now += step;
let _ = composer.handle_paste_burst_flush(now);
}
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(LocalImageAttachment {
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(LocalImageAttachment {
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(LocalImageAttachment {
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);
}
#[test]
fn shutdown_in_progress_disables_input_and_uses_hint_without_footer() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*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.show_shutdown_in_progress();
assert!(!composer.input_enabled());
assert_eq!(composer.current_text(), "hello");
assert_eq!(composer.custom_footer_height(), Some(0));
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 5,
};
assert_eq!(composer.cursor_pos(area), None);
let mut terminal = Terminal::new(TestBackend::new(40, 5)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap();
insta::assert_snapshot!("shutdown_in_progress", terminal.backend());
}
}