mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
## Why The TUI currently handles keyboard shortcuts as hard-coded event matches spread across app, composer, pager, list, approval, and navigation code. That makes shortcuts hard to customize, makes displayed hints easy to drift from actual behavior, and makes future keymap work riskier because there is no central action inventory. This PR adds the foundation for configurable, action-based keymaps without adding the interactive remapping UI yet. Onboarding intentionally stays on fixed startup shortcuts because users cannot reasonably configure keymaps before completing onboarding. This is PR1 in the keymap stack: - PR1: #18593: configurable keymap foundation - PR2: #18594: `/keymap` picker and guided remapping UI - PR3: #18595: Vim composer mode and the remap option ## Design Notes The new model resolves named actions into concrete runtime bindings once from config, then passes those bindings to the UI surfaces that handle input or render shortcut hints. The main concepts are: - **Context**: a scope where an action is active, such as `global`, `chat`, `composer`, `editor`, `pager`, `list`, or `approval`. - **Action**: a named operation inside a context, such as `global.open_transcript`, `composer.submit`, or `pager.close`. - **Binding**: one or more single-key shortcuts assigned to an action, written as config strings such as `ctrl-t`, `alt-backspace`, or `page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or leader-key flows are not part of this PR. - **Resolution order**: context-specific config wins first, supported global fallbacks come next, and built-in defaults fill in anything unset. - **Explicit unbinding**: an empty array removes an action binding in that scope and does not fall through to a fallback binding. - **Conflict validation**: a resolved keymap rejects duplicate active bindings inside the same scope so one keypress cannot dispatch two actions. ## What Changed - Added `TuiKeymap` config support under `[tui.keymap]`, including typed contexts/actions, key alias normalization, generated schema coverage, and user-facing config errors. - Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`, including fallback precedence, built-in defaults, explicit unbinding, and per-context conflict validation. - Rewired existing TUI handlers to consume resolved keymap actions instead of directly matching hard-coded keys in each component. - Updated key hint rendering and footer/pager/list surfaces so displayed shortcuts follow the resolved keymap. - Kept onboarding shortcuts fixed in `codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through `[tui.keymap]`. ## Validation The branch includes focused coverage for config parsing, key normalization, runtime fallback resolution, explicit unbinding, duplicate-key conflict validation, default keymap consistency, onboarding startup key behavior, and UI hint snapshots affected by resolved key bindings.
9394 lines
362 KiB
Rust
9394 lines
362 KiB
Rust
//! The chat composer is the bottom-pane text input state machine.
|
||
//!
|
||
//! It is responsible for:
|
||
//!
|
||
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
|
||
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
|
||
//! - Promoting typed slash commands into atomic elements when the command name is completed.
|
||
//! - Handling submit vs newline on Enter.
|
||
//! - Turning raw key streams into explicit paste operations on platforms where terminals
|
||
//! don't provide reliable bracketed paste (notably Windows).
|
||
//!
|
||
//! # Key Event Routing
|
||
//!
|
||
//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a
|
||
//! popup-specific handler if a popup is visible and otherwise to
|
||
//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call
|
||
//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor.
|
||
//!
|
||
//! # History Navigation (↑/↓)
|
||
//!
|
||
//! The Up/Down history path is managed by [`ChatComposerHistory`]. It merges:
|
||
//!
|
||
//! - Persistent cross-session history (text-only; no element ranges or attachments).
|
||
//! - Local in-session history (full text + text elements + local/remote image attachments).
|
||
//!
|
||
//! When recalling a local entry, the composer rehydrates text elements and both attachment kinds
|
||
//! (local image paths + remote image URLs).
|
||
//! When recalling a persistent entry, only the text is restored.
|
||
//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like
|
||
//! history traversal semantics instead of dropping to column 0.
|
||
//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the
|
||
//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as
|
||
//! an editable draft and `Esc` restores the draft that was active when search started.
|
||
//!
|
||
//! Slash commands are staged for local history instead of being recorded immediately. Command
|
||
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after
|
||
//! `ChatWidget` dispatches the command.
|
||
//!
|
||
//! # Submission and Prompt Expansion
|
||
//!
|
||
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
|
||
//! running, `Tab` submits just like Enter so input is never dropped.
|
||
//! `Tab` does not submit when entering a `!` shell command.
|
||
//!
|
||
//! On submit/queue paths, the composer:
|
||
//!
|
||
//! - Expands pending paste placeholders so element ranges align with the final text.
|
||
//! - Trims whitespace and rebases text elements accordingly.
|
||
//! - Prunes local attached images so only placeholders that survive expansion are sent.
|
||
//! - Preserves remote image URLs as separate attachments even when text is empty.
|
||
//!
|
||
//! When these paths clear the visible textarea after a successful submit or slash-command
|
||
//! dispatch, they intentionally preserve the textarea kill buffer. That lets users `Ctrl+K` part
|
||
//! of a draft, perform a composer action such as changing reasoning level, and then `Ctrl+Y` the
|
||
//! killed text back into the now-empty draft.
|
||
//!
|
||
//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion
|
||
//! and attachment pruning, and clears pending paste state on success.
|
||
//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so
|
||
//! pasted content and text elements are preserved when extracting args.
|
||
//!
|
||
//! # Remote Image Rows (Up/Down/Delete)
|
||
//!
|
||
//! Remote image URLs are rendered as non-editable `[Image #N]` rows above the textarea (inside the
|
||
//! same composer block). These rows represent image attachments rehydrated from app-server/backtrack
|
||
//! history; TUI users can remove them, but cannot type into that row region.
|
||
//!
|
||
//! Keyboard behavior:
|
||
//!
|
||
//! - `Up` at textarea cursor `0` enters remote-row selection at the last remote image.
|
||
//! - `Up`/`Down` move selection between remote rows.
|
||
//! - `Down` on the last row clears selection and returns control to the textarea.
|
||
//! - `Delete`/`Backspace` remove the selected remote image row.
|
||
//!
|
||
//! Placeholder numbering is unified across remote and local images:
|
||
//!
|
||
//! - Remote rows occupy `[Image #1]..[Image #M]`.
|
||
//! - Local placeholders are offset after that range (`[Image #M+1]..`).
|
||
//! - Deleting a remote row relabels local placeholders to keep numbering contiguous.
|
||
//!
|
||
//! # Non-bracketed Paste Bursts
|
||
//!
|
||
//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of
|
||
//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event.
|
||
//!
|
||
//! To avoid misinterpreting these bursts as real typing (and to prevent transient UI effects like
|
||
//! shortcut overlays toggling on a pasted `?`), we feed "plain" character events into
|
||
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
|
||
//! through [`ChatComposer::handle_paste`].
|
||
//!
|
||
//! The burst detector intentionally treats ASCII and non-ASCII differently:
|
||
//!
|
||
//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the
|
||
//! stream is paste-like.
|
||
//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow
|
||
//! burst detection for actual paste streams.
|
||
//!
|
||
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
|
||
//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the
|
||
//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input.
|
||
//!
|
||
//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`.
|
||
//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`.
|
||
//!
|
||
//! # PasteBurst Integration Points
|
||
//!
|
||
//! The burst detector is consulted in a few specific places:
|
||
//!
|
||
//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char
|
||
//! input to either buffer it or insert normally.
|
||
//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the
|
||
//! first char, while still allowing paste detection via retro-capture.
|
||
//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called
|
||
//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a
|
||
//! normal typed character.
|
||
//!
|
||
//! # Input Disabled Mode
|
||
//!
|
||
//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores
|
||
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
|
||
//! overall state machine, since it affects which transitions are even possible from a given UI
|
||
//! state.
|
||
//!
|
||
use crate::bottom_pane::footer::goal_status_indicator_line;
|
||
use crate::bottom_pane::footer::mode_indicator_line;
|
||
use crate::key_hint;
|
||
use crate::key_hint::KeyBinding;
|
||
use crate::key_hint::has_ctrl_or_alt;
|
||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||
use crate::ui_consts::FOOTER_INDENT_COLS;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use crossterm::event::KeyModifiers;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Constraint;
|
||
use ratatui::layout::Layout;
|
||
use ratatui::layout::Margin;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Modifier;
|
||
use ratatui::style::Style;
|
||
use ratatui::style::Stylize;
|
||
use ratatui::text::Line;
|
||
use ratatui::text::Span;
|
||
use ratatui::widgets::Block;
|
||
use ratatui::widgets::Paragraph;
|
||
use ratatui::widgets::StatefulWidgetRef;
|
||
use ratatui::widgets::WidgetRef;
|
||
|
||
use super::chat_composer_history::ChatComposerHistory;
|
||
use super::chat_composer_history::HistoryEntry;
|
||
use super::chat_composer_history::HistoryEntryResponse;
|
||
use super::command_popup::CommandItem;
|
||
use super::command_popup::CommandPopup;
|
||
use super::command_popup::CommandPopupFlags;
|
||
use super::file_search_popup::FileSearchPopup;
|
||
use super::footer::CollaborationModeIndicator;
|
||
use super::footer::FooterKeyHints;
|
||
use super::footer::FooterMode;
|
||
use super::footer::FooterProps;
|
||
use super::footer::GoalStatusIndicator;
|
||
use super::footer::SummaryLeft;
|
||
use super::footer::can_show_left_with_context;
|
||
use super::footer::context_window_line;
|
||
use super::footer::esc_hint_mode;
|
||
use super::footer::footer_height;
|
||
use super::footer::footer_hint_items_width;
|
||
use super::footer::footer_line_width;
|
||
use super::footer::inset_footer_hint_area;
|
||
use super::footer::max_left_width_for_right;
|
||
use super::footer::passive_footer_status_line;
|
||
use super::footer::render_context_right;
|
||
use super::footer::render_footer_from_props;
|
||
use super::footer::render_footer_hint_items;
|
||
use super::footer::render_footer_line;
|
||
use super::footer::reset_mode_after_activity;
|
||
use super::footer::side_conversation_context_line;
|
||
use super::footer::single_line_footer_layout;
|
||
use super::footer::toggle_shortcut_mode;
|
||
use super::footer::uses_passive_footer_status_layout;
|
||
use super::paste_burst::CharDecision;
|
||
use super::paste_burst::PasteBurst;
|
||
use super::skill_popup::MentionItem;
|
||
use super::skill_popup::SkillPopup;
|
||
use super::slash_commands;
|
||
use super::slash_commands::BuiltinCommandFlags;
|
||
use crate::bottom_pane::paste_burst::FlushResult;
|
||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||
use crate::key_hint::KeyBindingListExt;
|
||
use crate::keymap::EditorKeymap;
|
||
use crate::keymap::RuntimeKeymap;
|
||
use crate::keymap::primary_binding;
|
||
use crate::render::Insets;
|
||
use crate::render::RectExt;
|
||
use crate::render::renderable::Renderable;
|
||
use crate::slash_command::SlashCommand;
|
||
use crate::style::user_message_style;
|
||
use codex_protocol::models::local_image_label_text;
|
||
use codex_protocol::user_input::ByteRange;
|
||
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
||
use codex_protocol::user_input::TextElement;
|
||
|
||
mod history_search;
|
||
|
||
use self::history_search::HistorySearchSession;
|
||
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::bottom_pane::textarea::TextAreaState;
|
||
use crate::clipboard_paste::normalize_pasted_path;
|
||
use crate::clipboard_paste::pasted_image_format;
|
||
use crate::history_cell;
|
||
use crate::skills_helpers::skill_display_name;
|
||
use crate::tui::FrameRequester;
|
||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||
use codex_app_server_protocol::AppInfo;
|
||
#[cfg(test)]
|
||
use codex_core_skills::model::SkillInterface;
|
||
use codex_core_skills::model::SkillMetadata;
|
||
use codex_file_search::FileMatch;
|
||
#[cfg(test)]
|
||
use codex_plugin::AppConnectorId;
|
||
use codex_plugin::PluginCapabilitySummary;
|
||
use std::cell::RefCell;
|
||
use std::collections::HashMap;
|
||
use std::collections::HashSet;
|
||
use std::collections::VecDeque;
|
||
use std::ops::Range;
|
||
use std::path::PathBuf;
|
||
use std::time::Duration;
|
||
use std::time::Instant;
|
||
|
||
#[cfg(test)]
|
||
use ratatui::style::Color;
|
||
|
||
/// If the pasted content exceeds this number of characters, replace it with a
|
||
/// placeholder in the UI.
|
||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||
|
||
fn user_input_too_large_message(actual_chars: usize) -> String {
|
||
format!(
|
||
"Message exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters ({actual_chars} provided)."
|
||
)
|
||
}
|
||
|
||
/// Result returned when the user interacts with the text area.
|
||
#[derive(Debug, PartialEq)]
|
||
pub enum InputResult {
|
||
Submitted {
|
||
text: String,
|
||
text_elements: Vec<TextElement>,
|
||
},
|
||
Queued {
|
||
text: String,
|
||
text_elements: Vec<TextElement>,
|
||
action: QueuedInputAction,
|
||
},
|
||
/// A bare slash command parsed by the composer.
|
||
///
|
||
/// Callers that dispatch this variant are also responsible for resolving any pending local
|
||
/// command-history entry that the composer staged before clearing the visible input.
|
||
Command(SlashCommand),
|
||
/// An inline slash command and its trimmed argument text.
|
||
///
|
||
/// The `TextElement` ranges are rebased into the argument string, while any pending local
|
||
/// command-history entry still represents the original command invocation that should be
|
||
/// committed only if dispatch accepts it.
|
||
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
|
||
None,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum QueuedInputAction {
|
||
Plain,
|
||
ParseSlash,
|
||
RunShell,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct AttachedImage {
|
||
placeholder: String,
|
||
path: PathBuf,
|
||
}
|
||
|
||
/// Feature flags for reusing the chat composer in other bottom-pane surfaces.
|
||
///
|
||
/// The default keeps today's behavior intact. Other call sites can opt out of
|
||
/// specific behaviors by constructing a config with those flags set to `false`.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub(crate) struct ChatComposerConfig {
|
||
/// Whether command/file/skill popups are allowed to appear.
|
||
pub(crate) popups_enabled: bool,
|
||
/// Whether `/...` input is parsed and dispatched as slash commands.
|
||
pub(crate) slash_commands_enabled: bool,
|
||
/// Whether pasting a file path can attach local images.
|
||
pub(crate) image_paste_enabled: bool,
|
||
}
|
||
|
||
impl Default for ChatComposerConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
popups_enabled: true,
|
||
slash_commands_enabled: true,
|
||
image_paste_enabled: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ChatComposerConfig {
|
||
/// A minimal preset for plain-text inputs embedded in other surfaces.
|
||
///
|
||
/// This disables popups, slash commands, and image-path attachment behavior
|
||
/// so the composer behaves like a simple notes field.
|
||
pub(crate) const fn plain_text() -> Self {
|
||
Self {
|
||
popups_enabled: false,
|
||
slash_commands_enabled: false,
|
||
image_paste_enabled: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) struct ChatComposer {
|
||
textarea: TextArea,
|
||
textarea_state: RefCell<TextAreaState>,
|
||
is_bash_mode: bool,
|
||
active_popup: ActivePopup,
|
||
app_event_tx: AppEventSender,
|
||
history: ChatComposerHistory,
|
||
quit_shortcut_expires_at: Option<Instant>,
|
||
quit_shortcut_key: KeyBinding,
|
||
esc_backtrack_hint: bool,
|
||
use_shift_enter_hint: bool,
|
||
dismissed_file_popup_token: Option<String>,
|
||
current_file_query: Option<String>,
|
||
pending_pastes: Vec<(String, String)>,
|
||
large_paste_counters: HashMap<usize, usize>,
|
||
has_focus: bool,
|
||
frame_requester: Option<FrameRequester>,
|
||
/// Invariant: attached images are labeled in vec order as
|
||
/// `[Image #M+1]..[Image #N]`, where `M` is the number of remote images.
|
||
attached_images: Vec<AttachedImage>,
|
||
placeholder_text: String,
|
||
is_task_running: bool,
|
||
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
|
||
input_enabled: bool,
|
||
input_disabled_placeholder: Option<String>,
|
||
/// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`).
|
||
paste_burst: PasteBurst,
|
||
// When true, disables paste-burst logic and inserts characters immediately.
|
||
disable_paste_burst: bool,
|
||
footer_mode: FooterMode,
|
||
footer_hint_override: Option<Vec<(String, String)>>,
|
||
remote_image_urls: Vec<String>,
|
||
/// Tracks keyboard selection for the remote-image rows so Up/Down + Delete/Backspace
|
||
/// can highlight and remove remote attachments from the composer UI.
|
||
selected_remote_image_index: Option<usize>,
|
||
/// 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>,
|
||
footer_flash: Option<FooterFlash>,
|
||
context_window_percent: Option<i64>,
|
||
// Monotonically increasing identifier for textarea elements we insert.
|
||
#[cfg(not(target_os = "linux"))]
|
||
next_element_id: u64,
|
||
context_window_used_tokens: Option<i64>,
|
||
skills: Option<Vec<SkillMetadata>>,
|
||
plugins: Option<Vec<PluginCapabilitySummary>>,
|
||
connectors_snapshot: Option<ConnectorsSnapshot>,
|
||
dismissed_mention_popup_token: Option<String>,
|
||
mention_bindings: HashMap<u64, ComposerMentionBinding>,
|
||
recent_submission_mention_bindings: Vec<MentionBinding>,
|
||
collaboration_modes_enabled: bool,
|
||
config: ChatComposerConfig,
|
||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||
goal_status_indicator: Option<GoalStatusIndicator>,
|
||
connectors_enabled: bool,
|
||
plugins_command_enabled: bool,
|
||
fast_command_enabled: bool,
|
||
goal_command_enabled: bool,
|
||
personality_command_enabled: bool,
|
||
realtime_conversation_enabled: bool,
|
||
audio_device_selection_enabled: bool,
|
||
windows_degraded_sandbox_active: bool,
|
||
side_conversation_active: bool,
|
||
is_zellij: bool,
|
||
status_line_value: Option<Line<'static>>,
|
||
status_line_enabled: bool,
|
||
side_conversation_context_label: Option<String>,
|
||
// Agent label injected into the footer's contextual row when multi-agent mode is active.
|
||
active_agent_label: Option<String>,
|
||
history_search: Option<HistorySearchSession>,
|
||
submit_keys: Vec<KeyBinding>,
|
||
queue_keys: Vec<KeyBinding>,
|
||
toggle_shortcuts_keys: Vec<KeyBinding>,
|
||
history_search_previous_keys: Vec<KeyBinding>,
|
||
history_search_next_keys: Vec<KeyBinding>,
|
||
editor_keymap: EditorKeymap,
|
||
footer_external_editor_key: Option<KeyBinding>,
|
||
footer_show_transcript_key: Option<KeyBinding>,
|
||
footer_insert_newline_key: Option<KeyBinding>,
|
||
footer_queue_key: Option<KeyBinding>,
|
||
footer_toggle_shortcuts_key: Option<KeyBinding>,
|
||
footer_history_search_key: Option<KeyBinding>,
|
||
footer_reasoning_down_key: Option<KeyBinding>,
|
||
footer_reasoning_up_key: Option<KeyBinding>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct FooterFlash {
|
||
line: Line<'static>,
|
||
expires_at: Instant,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct ComposerDraft {
|
||
text: String,
|
||
text_elements: Vec<TextElement>,
|
||
local_image_paths: Vec<PathBuf>,
|
||
remote_image_urls: Vec<String>,
|
||
mention_bindings: Vec<MentionBinding>,
|
||
pending_pastes: Vec<(String, String)>,
|
||
cursor: usize,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct ComposerMentionBinding {
|
||
mention: String,
|
||
path: String,
|
||
}
|
||
|
||
/// Popup state – at most one can be visible at any time.
|
||
enum ActivePopup {
|
||
None,
|
||
Command(CommandPopup),
|
||
File(FileSearchPopup),
|
||
Skill(SkillPopup),
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum SlashValidation {
|
||
Immediate,
|
||
Deferred,
|
||
}
|
||
|
||
const FOOTER_SPACING_HEIGHT: u16 = 0;
|
||
|
||
fn status_line_right_indicator(
|
||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||
goal_status_indicator: Option<&GoalStatusIndicator>,
|
||
show_cycle_hint: bool,
|
||
) -> Option<Line<'static>> {
|
||
mode_indicator_line(collaboration_mode_indicator, show_cycle_hint)
|
||
.or_else(|| goal_status_indicator_line(goal_status_indicator))
|
||
}
|
||
|
||
impl ChatComposer {
|
||
fn builtin_command_flags(&self) -> BuiltinCommandFlags {
|
||
BuiltinCommandFlags {
|
||
collaboration_modes_enabled: self.collaboration_modes_enabled,
|
||
connectors_enabled: self.connectors_enabled,
|
||
plugins_command_enabled: self.plugins_command_enabled,
|
||
fast_command_enabled: self.fast_command_enabled,
|
||
goal_command_enabled: self.goal_command_enabled,
|
||
personality_command_enabled: self.personality_command_enabled,
|
||
realtime_conversation_enabled: self.realtime_conversation_enabled,
|
||
audio_device_selection_enabled: self.audio_device_selection_enabled,
|
||
allow_elevate_sandbox: self.windows_degraded_sandbox_active,
|
||
side_conversation_active: self.side_conversation_active,
|
||
}
|
||
}
|
||
|
||
pub fn new(
|
||
has_input_focus: bool,
|
||
app_event_tx: AppEventSender,
|
||
enhanced_keys_supported: bool,
|
||
placeholder_text: String,
|
||
disable_paste_burst: bool,
|
||
) -> Self {
|
||
Self::new_with_config(
|
||
has_input_focus,
|
||
app_event_tx,
|
||
enhanced_keys_supported,
|
||
placeholder_text,
|
||
disable_paste_burst,
|
||
ChatComposerConfig::default(),
|
||
)
|
||
}
|
||
|
||
/// Construct a composer with explicit feature gating.
|
||
///
|
||
/// This enables reuse in contexts like request-user-input where we want
|
||
/// the same visuals and editing behavior without slash commands or popups.
|
||
pub(crate) fn new_with_config(
|
||
has_input_focus: bool,
|
||
app_event_tx: AppEventSender,
|
||
enhanced_keys_supported: bool,
|
||
placeholder_text: String,
|
||
disable_paste_burst: bool,
|
||
config: ChatComposerConfig,
|
||
) -> Self {
|
||
let use_shift_enter_hint = enhanced_keys_supported;
|
||
let default_keymap = RuntimeKeymap::defaults();
|
||
let default_editor_keymap = default_keymap.editor.clone();
|
||
|
||
let mut this = Self {
|
||
textarea: TextArea::new(),
|
||
textarea_state: RefCell::new(TextAreaState::default()),
|
||
is_bash_mode: false,
|
||
active_popup: ActivePopup::None,
|
||
app_event_tx,
|
||
history: ChatComposerHistory::new(),
|
||
quit_shortcut_expires_at: None,
|
||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||
esc_backtrack_hint: false,
|
||
use_shift_enter_hint,
|
||
dismissed_file_popup_token: None,
|
||
current_file_query: None,
|
||
pending_pastes: Vec::new(),
|
||
large_paste_counters: HashMap::new(),
|
||
has_focus: has_input_focus,
|
||
frame_requester: None,
|
||
attached_images: Vec::new(),
|
||
placeholder_text,
|
||
is_task_running: false,
|
||
input_enabled: true,
|
||
input_disabled_placeholder: None,
|
||
paste_burst: PasteBurst::default(),
|
||
disable_paste_burst: false,
|
||
footer_mode: FooterMode::ComposerEmpty,
|
||
footer_hint_override: None,
|
||
remote_image_urls: Vec::new(),
|
||
selected_remote_image_index: None,
|
||
pending_slash_command_history: None,
|
||
footer_flash: None,
|
||
context_window_percent: None,
|
||
#[cfg(not(target_os = "linux"))]
|
||
next_element_id: 0,
|
||
context_window_used_tokens: None,
|
||
skills: None,
|
||
plugins: None,
|
||
connectors_snapshot: None,
|
||
dismissed_mention_popup_token: None,
|
||
mention_bindings: HashMap::new(),
|
||
recent_submission_mention_bindings: Vec::new(),
|
||
collaboration_modes_enabled: false,
|
||
config,
|
||
collaboration_mode_indicator: None,
|
||
goal_status_indicator: None,
|
||
connectors_enabled: false,
|
||
plugins_command_enabled: false,
|
||
fast_command_enabled: false,
|
||
goal_command_enabled: false,
|
||
personality_command_enabled: false,
|
||
realtime_conversation_enabled: false,
|
||
audio_device_selection_enabled: false,
|
||
windows_degraded_sandbox_active: false,
|
||
side_conversation_active: false,
|
||
is_zellij: matches!(
|
||
codex_terminal_detection::terminal_info().multiplexer,
|
||
Some(codex_terminal_detection::Multiplexer::Zellij {})
|
||
),
|
||
status_line_value: None,
|
||
status_line_enabled: false,
|
||
side_conversation_context_label: None,
|
||
active_agent_label: None,
|
||
history_search: None,
|
||
submit_keys: vec![key_hint::plain(KeyCode::Enter)],
|
||
queue_keys: vec![key_hint::plain(KeyCode::Tab)],
|
||
toggle_shortcuts_keys: vec![
|
||
key_hint::plain(KeyCode::Char('?')),
|
||
key_hint::shift(KeyCode::Char('?')),
|
||
],
|
||
history_search_previous_keys: default_keymap.composer.history_search_previous.clone(),
|
||
history_search_next_keys: default_keymap.composer.history_search_next.clone(),
|
||
editor_keymap: default_editor_keymap,
|
||
footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
|
||
footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
|
||
footer_insert_newline_key: footer_insert_newline_key(
|
||
&default_keymap.editor.insert_newline,
|
||
use_shift_enter_hint,
|
||
),
|
||
footer_queue_key: Some(key_hint::plain(KeyCode::Tab)),
|
||
footer_toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))),
|
||
footer_history_search_key: primary_binding(
|
||
&default_keymap.composer.history_search_previous,
|
||
),
|
||
footer_reasoning_down_key: primary_binding(
|
||
&default_keymap.chat.decrease_reasoning_effort,
|
||
),
|
||
footer_reasoning_up_key: primary_binding(
|
||
&default_keymap.chat.increase_reasoning_effort,
|
||
),
|
||
};
|
||
// Apply configuration via the setter to keep side-effects centralized.
|
||
this.set_disable_paste_burst(disable_paste_burst);
|
||
this
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
fn next_id(&mut self) -> String {
|
||
let id = self.next_element_id;
|
||
self.next_element_id = self.next_element_id.wrapping_add(1);
|
||
id.to_string()
|
||
}
|
||
|
||
pub(crate) fn set_frame_requester(&mut self, frame_requester: FrameRequester) {
|
||
self.frame_requester = Some(frame_requester);
|
||
}
|
||
|
||
pub fn set_skill_mentions(&mut self, skills: Option<Vec<SkillMetadata>>) {
|
||
self.skills = skills;
|
||
self.sync_popups();
|
||
}
|
||
|
||
pub fn set_plugin_mentions(&mut self, plugins: Option<Vec<PluginCapabilitySummary>>) {
|
||
self.plugins = plugins;
|
||
self.sync_popups();
|
||
}
|
||
|
||
pub fn set_plugins_command_enabled(&mut self, enabled: bool) {
|
||
self.plugins_command_enabled = enabled;
|
||
}
|
||
|
||
/// Toggle composer-side image paste handling.
|
||
///
|
||
/// This only affects whether image-like paste content is converted into attachments; the
|
||
/// `ChatWidget` layer still performs capability checks before images are submitted.
|
||
pub fn set_image_paste_enabled(&mut self, enabled: bool) {
|
||
self.config.image_paste_enabled = enabled;
|
||
}
|
||
|
||
pub fn set_connector_mentions(&mut self, connectors_snapshot: Option<ConnectorsSnapshot>) {
|
||
self.connectors_snapshot = connectors_snapshot;
|
||
self.sync_popups();
|
||
}
|
||
|
||
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
|
||
let elements = self.current_mention_elements();
|
||
let mut ordered = Vec::new();
|
||
for (id, mention) in elements {
|
||
if let Some(binding) = self.mention_bindings.remove(&id)
|
||
&& binding.mention == mention
|
||
{
|
||
ordered.push(MentionBinding {
|
||
mention: binding.mention,
|
||
path: binding.path,
|
||
});
|
||
}
|
||
}
|
||
self.mention_bindings.clear();
|
||
ordered
|
||
}
|
||
|
||
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
|
||
self.collaboration_modes_enabled = enabled;
|
||
}
|
||
|
||
pub fn set_connectors_enabled(&mut self, enabled: bool) {
|
||
self.connectors_enabled = enabled;
|
||
}
|
||
|
||
pub fn set_fast_command_enabled(&mut self, enabled: bool) {
|
||
self.fast_command_enabled = enabled;
|
||
}
|
||
|
||
pub fn set_goal_command_enabled(&mut self, enabled: bool) {
|
||
self.goal_command_enabled = enabled;
|
||
}
|
||
|
||
/// Replace composer, editor, and footer-hint key bindings from one runtime snapshot.
|
||
///
|
||
/// Submit and queue bindings are cached here because composer dispatch must
|
||
/// check them before generic textarea editing. The embedded textarea receives
|
||
/// the same snapshot's editor bindings so a live remap cannot leave submit
|
||
/// keys updated while cursor/editing keys still use old defaults.
|
||
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||
self.submit_keys = keymap.composer.submit.clone();
|
||
self.queue_keys = keymap.composer.queue.clone();
|
||
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
|
||
self.history_search_previous_keys = keymap.composer.history_search_previous.clone();
|
||
self.history_search_next_keys = keymap.composer.history_search_next.clone();
|
||
self.editor_keymap = keymap.editor.clone();
|
||
self.textarea.set_keymap_bindings(&self.editor_keymap);
|
||
self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor);
|
||
self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript);
|
||
self.footer_insert_newline_key =
|
||
footer_insert_newline_key(&keymap.editor.insert_newline, self.use_shift_enter_hint);
|
||
self.footer_queue_key = primary_binding(&keymap.composer.queue);
|
||
self.footer_toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts);
|
||
self.footer_history_search_key = primary_binding(&keymap.composer.history_search_previous);
|
||
self.footer_reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort);
|
||
self.footer_reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort);
|
||
}
|
||
|
||
pub fn set_collaboration_mode_indicator(
|
||
&mut self,
|
||
indicator: Option<CollaborationModeIndicator>,
|
||
) {
|
||
self.collaboration_mode_indicator = indicator;
|
||
}
|
||
|
||
pub fn set_goal_status_indicator(&mut self, indicator: Option<GoalStatusIndicator>) {
|
||
self.goal_status_indicator = indicator;
|
||
}
|
||
|
||
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
|
||
self.personality_command_enabled = enabled;
|
||
}
|
||
|
||
pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
|
||
self.realtime_conversation_enabled = enabled;
|
||
}
|
||
|
||
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
|
||
self.audio_device_selection_enabled = enabled;
|
||
}
|
||
|
||
pub fn set_side_conversation_active(&mut self, active: bool) {
|
||
self.side_conversation_active = active;
|
||
}
|
||
|
||
/// Compatibility shim for tests that still toggle the removed steer mode flag.
|
||
#[cfg(test)]
|
||
pub fn set_steer_enabled(&mut self, _enabled: bool) {}
|
||
/// Centralized feature gating keeps config checks out of call sites.
|
||
fn popups_enabled(&self) -> bool {
|
||
self.config.popups_enabled
|
||
}
|
||
|
||
fn slash_commands_enabled(&self) -> bool {
|
||
self.config.slash_commands_enabled
|
||
}
|
||
|
||
fn image_paste_enabled(&self) -> bool {
|
||
self.config.image_paste_enabled
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) {
|
||
self.windows_degraded_sandbox_active = enabled;
|
||
}
|
||
fn layout_areas(&self, area: Rect) -> [Rect; 4] {
|
||
let footer_props = self.footer_props();
|
||
let footer_hint_height = self
|
||
.custom_footer_height()
|
||
.unwrap_or_else(|| footer_height(&footer_props));
|
||
let footer_spacing = Self::footer_spacing(footer_hint_height);
|
||
let footer_total_height = footer_hint_height + footer_spacing;
|
||
let popup_constraint = match &self.active_popup {
|
||
ActivePopup::Command(popup) => {
|
||
Constraint::Max(popup.calculate_required_height(area.width))
|
||
}
|
||
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
|
||
ActivePopup::Skill(popup) => {
|
||
Constraint::Max(popup.calculate_required_height(area.width))
|
||
}
|
||
ActivePopup::None => Constraint::Max(footer_total_height),
|
||
};
|
||
let [composer_rect, popup_rect] =
|
||
Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area);
|
||
let mut textarea_rect = composer_rect.inset(Insets::tlbr(
|
||
/*top*/ 1,
|
||
LIVE_PREFIX_COLS,
|
||
/*bottom*/ 1,
|
||
/*right*/ 1,
|
||
));
|
||
let remote_images_height = self
|
||
.remote_images_lines(textarea_rect.width)
|
||
.len()
|
||
.try_into()
|
||
.unwrap_or(u16::MAX)
|
||
.min(textarea_rect.height.saturating_sub(1));
|
||
let remote_images_separator = u16::from(remote_images_height > 0);
|
||
let consumed = remote_images_height.saturating_add(remote_images_separator);
|
||
let remote_images_rect = Rect {
|
||
x: textarea_rect.x,
|
||
y: textarea_rect.y,
|
||
width: textarea_rect.width,
|
||
height: remote_images_height,
|
||
};
|
||
textarea_rect.y = textarea_rect.y.saturating_add(consumed);
|
||
textarea_rect.height = textarea_rect.height.saturating_sub(consumed);
|
||
[composer_rect, remote_images_rect, textarea_rect, popup_rect]
|
||
}
|
||
|
||
fn footer_spacing(footer_hint_height: u16) -> u16 {
|
||
if footer_hint_height == 0 {
|
||
0
|
||
} else {
|
||
FOOTER_SPACING_HEIGHT
|
||
}
|
||
}
|
||
|
||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||
if !self.input_enabled {
|
||
return None;
|
||
}
|
||
|
||
if let Some(pos) = self.history_search_cursor_pos(area) {
|
||
return Some(pos);
|
||
}
|
||
|
||
let [_, _, textarea_rect, _] = self.layout_areas(area);
|
||
let state = *self.textarea_state.borrow();
|
||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||
}
|
||
/// Returns true if the composer currently contains no user-entered input.
|
||
pub(crate) fn is_empty(&self) -> bool {
|
||
self.textarea.is_empty()
|
||
&& !self.is_bash_mode
|
||
&& self.attached_images.is_empty()
|
||
&& self.remote_image_urls.is_empty()
|
||
}
|
||
|
||
/// Record the history metadata advertised by `SessionConfiguredEvent` so
|
||
/// that the composer can navigate cross-session history.
|
||
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
||
self.history.set_metadata(log_id, entry_count);
|
||
}
|
||
|
||
/// Integrate an asynchronous response to an on-demand history lookup.
|
||
///
|
||
/// If the entry is present and the offset still matches the active history cursor, the
|
||
/// composer rehydrates the entry immediately. This path intentionally routes through
|
||
/// [`Self::apply_history_entry`] so cursor placement remains aligned with keyboard history
|
||
/// recall semantics.
|
||
pub(crate) fn on_history_entry_response(
|
||
&mut self,
|
||
log_id: u64,
|
||
offset: usize,
|
||
entry: Option<String>,
|
||
) -> bool {
|
||
match self
|
||
.history
|
||
.on_entry_response(log_id, offset, entry, &self.app_event_tx)
|
||
{
|
||
HistoryEntryResponse::Found(entry) => {
|
||
// Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting
|
||
// attachments), but local in-session ↑/↓ history can rehydrate elements and image paths.
|
||
self.apply_history_entry(entry);
|
||
true
|
||
}
|
||
HistoryEntryResponse::Search(result) => {
|
||
self.apply_history_search_result(result);
|
||
true
|
||
}
|
||
HistoryEntryResponse::Ignored => false,
|
||
}
|
||
}
|
||
|
||
/// Integrate pasted text into the composer.
|
||
///
|
||
/// Acts as the only place where paste text is integrated, both for:
|
||
///
|
||
/// - Real/explicit paste events surfaced by the terminal, and
|
||
/// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers
|
||
/// and later flushes here.
|
||
///
|
||
/// Behavior:
|
||
///
|
||
/// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder
|
||
/// element (expanded on submit) and stores the full text in `pending_pastes`.
|
||
/// - Otherwise, if the paste looks like an image path, attaches the image and inserts a
|
||
/// trailing space so the user can keep typing naturally.
|
||
/// - Otherwise, inserts the pasted text directly into the textarea.
|
||
///
|
||
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
|
||
/// the next user Enter key, then syncs popup state.
|
||
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
|
||
let char_count = pasted.chars().count();
|
||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||
let placeholder = self.next_large_paste_placeholder(char_count);
|
||
self.textarea.insert_element(&placeholder);
|
||
self.pending_pastes.push((placeholder, pasted));
|
||
} else if char_count > 1
|
||
&& self.image_paste_enabled()
|
||
&& self.handle_paste_image_path(pasted.clone())
|
||
{
|
||
self.textarea.insert_str(" ");
|
||
} else {
|
||
self.insert_str(&pasted);
|
||
}
|
||
self.paste_burst.clear_after_explicit_paste();
|
||
self.sync_popups();
|
||
true
|
||
}
|
||
|
||
pub fn handle_paste_image_path(&mut self, pasted: String) -> bool {
|
||
let Some(path_buf) = normalize_pasted_path(&pasted) else {
|
||
return false;
|
||
};
|
||
|
||
// normalize_pasted_path already handles Windows → WSL path conversion,
|
||
// so we can directly try to read the image dimensions.
|
||
match image::image_dimensions(&path_buf) {
|
||
Ok((width, height)) => {
|
||
tracing::info!("OK: {pasted}");
|
||
tracing::debug!("image dimensions={}x{}", width, height);
|
||
let format = pasted_image_format(&path_buf);
|
||
tracing::debug!("attached image format={}", format.label());
|
||
self.attach_image(path_buf);
|
||
true
|
||
}
|
||
Err(err) => {
|
||
tracing::trace!("ERR: {err}");
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Enable or disable paste-burst handling.
|
||
///
|
||
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
|
||
/// is unwanted or has already been handled elsewhere.
|
||
///
|
||
/// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it
|
||
/// cannot affect subsequent normal typing:
|
||
///
|
||
/// - First, flush any held/buffered text immediately via
|
||
/// [`PasteBurst::flush_before_modified_input`], and feed it through `handle_paste(String)`.
|
||
/// This preserves user input and routes it through the same integration path as explicit
|
||
/// pastes (large-paste placeholders, image-path detection, and popup sync).
|
||
/// - Then clear the burst timing and Enter-suppression window via
|
||
/// [`PasteBurst::clear_after_explicit_paste`].
|
||
///
|
||
/// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state
|
||
/// without emitting any buffered text, which can leave a non-empty buffer unable to flush
|
||
/// later (because `flush_if_due()` relies on `last_plain_char_time` to time out).
|
||
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
|
||
let was_disabled = self.disable_paste_burst;
|
||
self.disable_paste_burst = disabled;
|
||
if disabled && !was_disabled {
|
||
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
||
self.handle_paste(pasted);
|
||
}
|
||
self.paste_burst.clear_after_explicit_paste();
|
||
}
|
||
}
|
||
|
||
/// Replace the composer content with text from an external editor.
|
||
/// Clears pending paste placeholders and keeps only attachments whose
|
||
/// placeholder labels still appear in the new text. Image placeholders
|
||
/// are renumbered to `[Image #M+1]..[Image #N]` (where `M` is the number of
|
||
/// remote images). Cursor is placed at the end after rebuilding elements.
|
||
pub(crate) fn apply_external_edit(&mut self, text: String) {
|
||
self.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.attached_images.iter().map(|img| &img.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.attached_images.drain(..) {
|
||
if let Some(count) = placeholder_counts.get_mut(&img.placeholder)
|
||
&& *count > 0
|
||
{
|
||
*count -= 1;
|
||
kept_images.push(img);
|
||
}
|
||
}
|
||
self.attached_images = kept_images;
|
||
|
||
// Rebuild textarea so placeholders become elements again.
|
||
self.textarea.set_text_clearing_elements("");
|
||
let mut remaining: HashMap<&str, usize> = HashMap::new();
|
||
for img in &self.attached_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.textarea.insert_str(&text[idx..pos]);
|
||
}
|
||
self.textarea.insert_element(ph);
|
||
*count -= 1;
|
||
idx = pos + ph.len();
|
||
}
|
||
if idx < text.len() {
|
||
self.textarea.insert_str(&text[idx..]);
|
||
}
|
||
|
||
// Keep local image placeholders normalized in attachment order after the
|
||
// remote-image prefix.
|
||
self.relabel_attached_images_and_update_placeholders();
|
||
self.textarea.set_cursor(self.textarea.text().len());
|
||
self.sync_popups();
|
||
}
|
||
|
||
pub(crate) fn current_text_with_pending(&self) -> String {
|
||
let mut text = self.current_text();
|
||
for (placeholder, actual) in &self.pending_pastes {
|
||
if text.contains(placeholder) {
|
||
text = text.replace(placeholder, actual);
|
||
}
|
||
}
|
||
text
|
||
}
|
||
|
||
pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> {
|
||
self.pending_pastes.clone()
|
||
}
|
||
|
||
pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) {
|
||
let text = self.current_text();
|
||
self.pending_pastes = pending_pastes
|
||
.into_iter()
|
||
.filter(|(placeholder, _)| text.contains(placeholder))
|
||
.collect();
|
||
}
|
||
|
||
/// Override the footer hint items displayed beneath the composer. Passing
|
||
/// `None` restores the default shortcut footer.
|
||
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||
self.footer_hint_override = items;
|
||
}
|
||
|
||
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
|
||
self.remote_image_urls = urls;
|
||
self.selected_remote_image_index = None;
|
||
self.relabel_attached_images_and_update_placeholders();
|
||
self.sync_popups();
|
||
}
|
||
|
||
pub(crate) fn remote_image_urls(&self) -> Vec<String> {
|
||
self.remote_image_urls.clone()
|
||
}
|
||
|
||
pub(crate) fn take_remote_image_urls(&mut self) -> Vec<String> {
|
||
let urls = std::mem::take(&mut self.remote_image_urls);
|
||
self.selected_remote_image_index = None;
|
||
self.relabel_attached_images_and_update_placeholders();
|
||
self.sync_popups();
|
||
urls
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) {
|
||
let expires_at = Instant::now()
|
||
.checked_add(duration)
|
||
.unwrap_or_else(Instant::now);
|
||
self.footer_flash = Some(FooterFlash { line, expires_at });
|
||
}
|
||
|
||
pub(crate) fn footer_flash_visible(&self) -> bool {
|
||
self.footer_flash
|
||
.as_ref()
|
||
.is_some_and(|flash| Instant::now() < flash.expires_at)
|
||
}
|
||
|
||
/// Replace the entire composer content with `text` and reset cursor.
|
||
///
|
||
/// This is the "fresh draft" path: it clears pending paste payloads and
|
||
/// mention link targets. Callers restoring a previously submitted draft
|
||
/// that must keep `$name -> path` resolution should use
|
||
/// [`Self::set_text_content_with_mention_bindings`] instead.
|
||
pub(crate) fn set_text_content(
|
||
&mut self,
|
||
text: String,
|
||
text_elements: Vec<TextElement>,
|
||
local_image_paths: Vec<PathBuf>,
|
||
) {
|
||
self.set_text_content_with_mention_bindings(
|
||
text,
|
||
text_elements,
|
||
local_image_paths,
|
||
Vec::new(),
|
||
);
|
||
}
|
||
|
||
/// Replace the entire composer content while restoring mention link targets.
|
||
///
|
||
/// Mention popup insertion stores both visible text (for example `$file`)
|
||
/// and hidden mention bindings used to resolve the canonical target during
|
||
/// submission. Use this method when restoring an interrupted or blocked
|
||
/// draft; if callers restore only text and images, mentions can appear
|
||
/// intact to users while resolving to the wrong target or dropping on
|
||
/// retry.
|
||
///
|
||
/// This helper intentionally places the cursor at the start of the restored text. Callers
|
||
/// that need end-of-line restore behavior (for example shell-style history recall) should call
|
||
/// [`Self::move_cursor_to_end`] after this method.
|
||
pub(crate) fn set_text_content_with_mention_bindings(
|
||
&mut self,
|
||
text: String,
|
||
text_elements: Vec<TextElement>,
|
||
local_image_paths: Vec<PathBuf>,
|
||
mention_bindings: Vec<MentionBinding>,
|
||
) {
|
||
// Clear any existing content, placeholders, and attachments first.
|
||
self.textarea.set_text_clearing_elements("");
|
||
self.is_bash_mode = false;
|
||
self.pending_pastes.clear();
|
||
self.attached_images.clear();
|
||
self.mention_bindings.clear();
|
||
|
||
let (text, text_elements) = self.imported_text_for_textarea(text, text_elements);
|
||
self.textarea.set_text_with_elements(&text, &text_elements);
|
||
|
||
for (idx, path) in local_image_paths.into_iter().enumerate() {
|
||
let placeholder = local_image_label_text(self.remote_image_urls.len() + idx + 1);
|
||
self.attached_images
|
||
.push(AttachedImage { placeholder, path });
|
||
}
|
||
|
||
self.bind_mentions_from_snapshot(mention_bindings);
|
||
self.relabel_attached_images_and_update_placeholders();
|
||
self.selected_remote_image_index = None;
|
||
self.textarea.set_cursor(/*pos*/ 0);
|
||
self.sync_popups();
|
||
}
|
||
|
||
fn current_cursor(&self) -> usize {
|
||
self.textarea.cursor() + if self.is_bash_mode { 1 } else { 0 }
|
||
}
|
||
|
||
fn history_navigation_cursor(&self) -> usize {
|
||
if self.is_bash_mode && self.textarea.cursor() == 0 {
|
||
0
|
||
} else {
|
||
self.current_cursor()
|
||
}
|
||
}
|
||
|
||
fn set_current_cursor(&mut self, cursor: usize) {
|
||
let visible_cursor = if self.is_bash_mode {
|
||
cursor.saturating_sub(1)
|
||
} else {
|
||
cursor
|
||
};
|
||
self.textarea
|
||
.set_cursor(visible_cursor.min(self.textarea.text().len()));
|
||
}
|
||
|
||
fn current_text_elements(&self) -> Vec<TextElement> {
|
||
let shift = if self.is_bash_mode { 1 } else { 0 };
|
||
self.textarea
|
||
.text_elements()
|
||
.into_iter()
|
||
.filter_map(|element| Self::shift_text_element(element, shift))
|
||
.collect()
|
||
}
|
||
|
||
fn shift_text_element(element: TextElement, shift: isize) -> Option<TextElement> {
|
||
let start = element.byte_range.start.checked_add_signed(shift)?;
|
||
let end = element.byte_range.end.checked_add_signed(shift)?;
|
||
if start >= end {
|
||
return None;
|
||
}
|
||
|
||
Some(element.map_range(|_| (start..end).into()))
|
||
}
|
||
|
||
fn snapshot_draft(&self) -> ComposerDraft {
|
||
ComposerDraft {
|
||
text: self.current_text(),
|
||
text_elements: self.current_text_elements(),
|
||
local_image_paths: self
|
||
.attached_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect(),
|
||
remote_image_urls: self.remote_image_urls.clone(),
|
||
mention_bindings: self.snapshot_mention_bindings(),
|
||
pending_pastes: self.pending_pastes.clone(),
|
||
cursor: self.current_cursor(),
|
||
}
|
||
}
|
||
|
||
fn restore_draft(&mut self, draft: ComposerDraft) {
|
||
let ComposerDraft {
|
||
text,
|
||
text_elements,
|
||
local_image_paths,
|
||
remote_image_urls,
|
||
mention_bindings,
|
||
pending_pastes,
|
||
cursor,
|
||
} = draft;
|
||
self.set_remote_image_urls(remote_image_urls);
|
||
self.set_text_content_with_mention_bindings(
|
||
text,
|
||
text_elements,
|
||
local_image_paths,
|
||
mention_bindings,
|
||
);
|
||
self.set_pending_pastes(pending_pastes);
|
||
self.set_current_cursor(cursor);
|
||
self.sync_popups();
|
||
}
|
||
|
||
/// Update the placeholder text without changing input enablement.
|
||
pub(crate) fn set_placeholder_text(&mut self, placeholder: String) {
|
||
self.placeholder_text = placeholder;
|
||
}
|
||
|
||
/// Move the cursor to the end of the current text buffer.
|
||
pub(crate) fn move_cursor_to_end(&mut self) {
|
||
self.textarea.set_cursor(self.textarea.text().len());
|
||
self.sync_popups();
|
||
}
|
||
|
||
/// Convert canonical composer text into the textarea's internal representation.
|
||
///
|
||
/// Shell mode stores the leading `!` as prompt state instead of editable text,
|
||
/// so full-buffer imports must absorb that prefix before rebuilding the textarea.
|
||
fn imported_text_for_textarea(
|
||
&mut self,
|
||
text: String,
|
||
text_elements: Vec<TextElement>,
|
||
) -> (String, Vec<TextElement>) {
|
||
if let Some(stripped) = text.strip_prefix('!') {
|
||
self.is_bash_mode = true;
|
||
(
|
||
stripped.to_string(),
|
||
text_elements
|
||
.into_iter()
|
||
.filter_map(|element| Self::shift_text_element(element, /*shift*/ -1))
|
||
.collect(),
|
||
)
|
||
} else {
|
||
self.is_bash_mode = false;
|
||
(text, text_elements)
|
||
}
|
||
}
|
||
|
||
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
|
||
if self.is_empty() {
|
||
return None;
|
||
}
|
||
let previous = self.current_text();
|
||
let text_elements = self.current_text_elements();
|
||
let local_image_paths = self
|
||
.attached_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect();
|
||
let pending_pastes = std::mem::take(&mut self.pending_pastes);
|
||
let remote_image_urls = self.remote_image_urls.clone();
|
||
let mention_bindings = self.snapshot_mention_bindings();
|
||
self.set_text_content(String::new(), Vec::new(), Vec::new());
|
||
self.remote_image_urls.clear();
|
||
self.selected_remote_image_index = None;
|
||
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.is_bash_mode {
|
||
format!("!{}", self.textarea.text())
|
||
} else {
|
||
self.textarea.text().to_string()
|
||
}
|
||
}
|
||
|
||
/// Rehydrate a history entry into the composer with shell-like cursor placement.
|
||
///
|
||
/// This path restores text, elements, images, mention bindings, and pending paste payloads,
|
||
/// then moves the cursor to end-of-line. If a caller reused
|
||
/// [`Self::set_text_content_with_mention_bindings`] directly for history recall and forgot the
|
||
/// final cursor move, repeated Up/Down would stop navigating history because cursor-gating
|
||
/// treats interior positions as normal editing mode.
|
||
fn apply_history_entry(&mut self, entry: HistoryEntry) {
|
||
let HistoryEntry {
|
||
text,
|
||
text_elements,
|
||
local_image_paths,
|
||
remote_image_urls,
|
||
mention_bindings,
|
||
pending_pastes,
|
||
} = entry;
|
||
self.set_remote_image_urls(remote_image_urls);
|
||
self.set_text_content_with_mention_bindings(
|
||
text,
|
||
text_elements,
|
||
local_image_paths,
|
||
mention_bindings,
|
||
);
|
||
self.set_pending_pastes(pending_pastes);
|
||
self.move_cursor_to_end();
|
||
}
|
||
|
||
pub(crate) fn text_elements(&self) -> Vec<TextElement> {
|
||
self.current_text_elements()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn local_image_paths(&self) -> Vec<PathBuf> {
|
||
self.attached_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn status_line_text(&self) -> Option<String> {
|
||
self.status_line_value.as_ref().map(|line| {
|
||
line.spans
|
||
.iter()
|
||
.map(|span| span.content.as_ref())
|
||
.collect::<String>()
|
||
})
|
||
}
|
||
|
||
pub(crate) fn local_images(&self) -> Vec<LocalImageAttachment> {
|
||
self.attached_images
|
||
.iter()
|
||
.map(|img| LocalImageAttachment {
|
||
placeholder: img.placeholder.clone(),
|
||
path: img.path.clone(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
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.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);
|
||
}
|
||
}
|
||
|
||
fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) {
|
||
if self.attached_images.is_empty() {
|
||
return;
|
||
}
|
||
let image_placeholders: HashSet<&str> = text_elements
|
||
.iter()
|
||
.filter_map(|elem| elem.placeholder(text))
|
||
.collect();
|
||
self.attached_images
|
||
.retain(|img| image_placeholders.contains(img.placeholder.as_str()));
|
||
}
|
||
|
||
/// Insert an attachment placeholder and track it for the next submission.
|
||
pub fn attach_image(&mut self, path: PathBuf) {
|
||
let image_number = self.remote_image_urls.len() + self.attached_images.len() + 1;
|
||
let placeholder = local_image_label_text(image_number);
|
||
// Insert as an element to match large paste placeholder behavior:
|
||
// styled distinctly and treated atomically for cursor/mutations.
|
||
self.textarea.insert_element(&placeholder);
|
||
self.attached_images
|
||
.push(AttachedImage { placeholder, path });
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||
let images = std::mem::take(&mut self.attached_images);
|
||
images.into_iter().map(|img| img.path).collect()
|
||
}
|
||
|
||
pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec<LocalImageAttachment> {
|
||
let images = std::mem::take(&mut self.attached_images);
|
||
images
|
||
.into_iter()
|
||
.map(|img| LocalImageAttachment {
|
||
placeholder: img.placeholder,
|
||
path: img.path,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Flushes any due paste-burst state.
|
||
///
|
||
/// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits:
|
||
///
|
||
/// - If a burst times out, flush it via `handle_paste(String)`.
|
||
/// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it
|
||
/// as normal typed input.
|
||
///
|
||
/// This also allows a single "held" ASCII char to render even when it turns out not to be part
|
||
/// of a paste burst.
|
||
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||
self.handle_paste_burst_flush(Instant::now())
|
||
}
|
||
|
||
/// Returns whether the composer is currently in any paste-burst related transient state.
|
||
///
|
||
/// This includes actively buffering, having a non-empty burst buffer, or holding the first
|
||
/// ASCII char for flicker suppression.
|
||
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||
self.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 = Self::current_at_token(&self.textarea);
|
||
let Some(current_token) = current_opt else {
|
||
return;
|
||
};
|
||
|
||
if !current_token.starts_with(&query) {
|
||
return;
|
||
}
|
||
|
||
if let ActivePopup::File(popup) = &mut self.active_popup {
|
||
popup.set_matches(&query, matches);
|
||
}
|
||
}
|
||
|
||
/// Show the transient "press again to quit" hint for `key`.
|
||
///
|
||
/// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a
|
||
/// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear
|
||
/// even when the UI is otherwise idle.
|
||
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
|
||
self.quit_shortcut_expires_at = Instant::now()
|
||
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
|
||
.or_else(|| Some(Instant::now()));
|
||
self.quit_shortcut_key = key;
|
||
self.footer_mode = FooterMode::QuitShortcutReminder;
|
||
self.set_has_focus(has_focus);
|
||
}
|
||
|
||
/// Clear the "press again to quit" hint immediately.
|
||
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
|
||
self.quit_shortcut_expires_at = None;
|
||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||
self.set_has_focus(has_focus);
|
||
}
|
||
|
||
/// Whether the quit shortcut hint should currently be shown.
|
||
///
|
||
/// This is time-based rather than event-based: it may become false without
|
||
/// any additional user input, so the UI schedules a redraw when the hint
|
||
/// expires.
|
||
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
|
||
self.quit_shortcut_expires_at
|
||
.is_some_and(|expires_at| Instant::now() < expires_at)
|
||
}
|
||
|
||
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
|
||
let base = format!("[Pasted Content {char_count} chars]");
|
||
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
|
||
*next_suffix += 1;
|
||
if *next_suffix == 1 {
|
||
base
|
||
} else {
|
||
format!("{base} #{next_suffix}")
|
||
}
|
||
}
|
||
|
||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||
self.textarea.insert_str(text);
|
||
self.sync_bash_mode_from_text();
|
||
self.sync_popups();
|
||
}
|
||
|
||
/// Handle a key event coming from the main UI.
|
||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||
if !self.input_enabled {
|
||
return (InputResult::None, false);
|
||
}
|
||
|
||
if matches!(key_event.kind, KeyEventKind::Release) {
|
||
return (InputResult::None, false);
|
||
}
|
||
|
||
if self.history_search.is_some() {
|
||
return self.handle_history_search_key(key_event);
|
||
}
|
||
|
||
if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) {
|
||
return self.begin_history_search();
|
||
}
|
||
|
||
let result = match &mut self.active_popup {
|
||
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::None => self.handle_key_event_without_popup(key_event),
|
||
};
|
||
// Update (or hide/show) popup after processing the key.
|
||
self.sync_popups();
|
||
result
|
||
}
|
||
|
||
/// Return true if either the slash-command popup or the file-search popup is active.
|
||
pub(crate) fn popup_active(&self) -> bool {
|
||
self.history_search.is_some() || !matches!(self.active_popup, ActivePopup::None)
|
||
}
|
||
|
||
/// Handle key event when the slash-command popup is visible.
|
||
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||
if self.handle_shortcut_overlay_key(&key_event) {
|
||
return (InputResult::None, true);
|
||
}
|
||
if key_event.code == KeyCode::Esc {
|
||
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||
if next_mode != self.footer_mode {
|
||
self.footer_mode = next_mode;
|
||
return (InputResult::None, true);
|
||
}
|
||
} else {
|
||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||
}
|
||
let ActivePopup::Command(popup) = &mut self.active_popup else {
|
||
unreachable!();
|
||
};
|
||
|
||
match key_event {
|
||
KeyEvent {
|
||
code: KeyCode::Up, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('p'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
popup.move_up();
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Down,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('n'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
popup.move_down();
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Esc, ..
|
||
} => {
|
||
// Dismiss the slash popup; keep the current input untouched.
|
||
self.active_popup = ActivePopup::None;
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Tab, ..
|
||
} => {
|
||
// Ensure popup filtering/selection reflects the latest composer text
|
||
// before applying completion.
|
||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||
popup.on_composer_text_change(first_line.to_string());
|
||
let selected_cmd = popup.selected_item().map(|sel| {
|
||
let CommandItem::Builtin(cmd) = sel;
|
||
cmd
|
||
});
|
||
if let Some(cmd) = selected_cmd {
|
||
if cmd == SlashCommand::Skills {
|
||
self.stage_selected_slash_command_history(cmd);
|
||
self.textarea.set_text_clearing_elements("");
|
||
self.is_bash_mode = false;
|
||
return (InputResult::Command(cmd), true);
|
||
}
|
||
|
||
let selected_command_text = format!("/{}", cmd.command());
|
||
let starts_with_cmd =
|
||
first_line.trim_start().starts_with(&selected_command_text);
|
||
if !starts_with_cmd {
|
||
self.textarea
|
||
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
|
||
if !self.textarea.text().is_empty() {
|
||
self.textarea.set_cursor(self.textarea.text().len());
|
||
}
|
||
return (InputResult::None, true);
|
||
}
|
||
}
|
||
if self.is_task_running {
|
||
return self.handle_submission(/*should_queue*/ true);
|
||
}
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('/'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
// Treat "/" as accepting the highlighted command as text completion
|
||
// while the slash-command popup is active.
|
||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||
popup.on_composer_text_change(first_line.to_string());
|
||
let selected_cmd = popup.selected_item().map(|sel| {
|
||
let CommandItem::Builtin(cmd) = sel;
|
||
cmd
|
||
});
|
||
if let Some(cmd) = selected_cmd {
|
||
let starts_with_cmd = first_line
|
||
.trim_start()
|
||
.starts_with(&format!("/{}", cmd.command()));
|
||
if !starts_with_cmd {
|
||
self.textarea
|
||
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
|
||
self.is_bash_mode = false;
|
||
}
|
||
if !self.textarea.text().is_empty() {
|
||
self.textarea.set_cursor(self.textarea.text().len());
|
||
}
|
||
}
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Enter,
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
if let Some(sel) = popup.selected_item() {
|
||
let CommandItem::Builtin(cmd) = sel;
|
||
self.stage_selected_slash_command_history(cmd);
|
||
self.textarea.set_text_clearing_elements("");
|
||
self.is_bash_mode = false;
|
||
return (InputResult::Command(cmd), true);
|
||
}
|
||
// Fallback to default newline handling if no command selected.
|
||
self.handle_key_event_without_popup(key_event)
|
||
}
|
||
input => self.handle_input_basic(input),
|
||
}
|
||
}
|
||
|
||
#[inline]
|
||
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
|
||
let mut p = pos.min(text.len());
|
||
if p < text.len() && !text.is_char_boundary(p) {
|
||
p = text
|
||
.char_indices()
|
||
.map(|(i, _)| i)
|
||
.take_while(|&i| i <= p)
|
||
.last()
|
||
.unwrap_or(0);
|
||
}
|
||
p
|
||
}
|
||
|
||
/// Handle non-ASCII character input (often IME) while still supporting paste-burst detection.
|
||
///
|
||
/// This handler exists because non-ASCII input often comes from IMEs, where characters can
|
||
/// legitimately arrive in short bursts that should **not** be treated as paste.
|
||
///
|
||
/// The key differences from the ASCII path:
|
||
///
|
||
/// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a
|
||
/// non-ASCII char can feel like dropped input.
|
||
/// - If a burst is detected, we may need to retroactively remove already-inserted text before
|
||
/// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`).
|
||
///
|
||
/// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp
|
||
/// the cursor to a UTF-8 char boundary before slicing `textarea.text()`.
|
||
#[inline]
|
||
fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) {
|
||
if self.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.textarea.input_with_keymap(input, &self.editor_keymap);
|
||
let text_after = self.textarea.text();
|
||
self.pending_pastes
|
||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||
return (InputResult::None, true);
|
||
}
|
||
if let KeyEvent {
|
||
code: KeyCode::Char(ch),
|
||
..
|
||
} = input
|
||
{
|
||
if self.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.paste_burst.flush_before_modified_input() {
|
||
self.handle_paste(pasted);
|
||
}
|
||
if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) {
|
||
match decision {
|
||
CharDecision::BufferAppend => {
|
||
self.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.textarea.cursor();
|
||
let txt = self.textarea.text();
|
||
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
|
||
let before = &txt[..safe_cur];
|
||
if let Some(grab) =
|
||
self.paste_burst
|
||
.decide_begin_buffer(now, before, retro_chars as usize)
|
||
{
|
||
if !grab.grabbed.is_empty() {
|
||
self.textarea.replace_range(grab.start_byte..safe_cur, "");
|
||
}
|
||
// seed the paste burst buffer with everything (grabbed + new)
|
||
self.paste_burst.append_char_to_buffer(ch, now);
|
||
return (InputResult::None, true);
|
||
}
|
||
// If decide_begin_buffer opted not to start buffering,
|
||
// fall through to normal insertion below.
|
||
}
|
||
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
|
||
}
|
||
}
|
||
}
|
||
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
||
self.handle_paste(pasted);
|
||
}
|
||
self.textarea.input_with_keymap(input, &self.editor_keymap);
|
||
|
||
let text_after = self.textarea.text();
|
||
self.pending_pastes
|
||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||
(InputResult::None, true)
|
||
}
|
||
|
||
/// Handle key events when file search popup is visible.
|
||
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||
if self.handle_shortcut_overlay_key(&key_event) {
|
||
return (InputResult::None, true);
|
||
}
|
||
if key_event.code == KeyCode::Esc {
|
||
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||
if next_mode != self.footer_mode {
|
||
self.footer_mode = next_mode;
|
||
return (InputResult::None, true);
|
||
}
|
||
} else {
|
||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||
}
|
||
let ActivePopup::File(popup) = &mut self.active_popup else {
|
||
unreachable!();
|
||
};
|
||
|
||
match key_event {
|
||
KeyEvent {
|
||
code: KeyCode::Up, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('p'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
popup.move_up();
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Down,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('n'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
popup.move_down();
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Esc, ..
|
||
} => {
|
||
// Hide popup without modifying text, remember token to avoid immediate reopen.
|
||
if let Some(tok) = Self::current_at_token(&self.textarea) {
|
||
self.dismissed_file_popup_token = Some(tok);
|
||
}
|
||
self.active_popup = ActivePopup::None;
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Tab, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Enter,
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
let Some(sel) = popup.selected_match() else {
|
||
self.active_popup = ActivePopup::None;
|
||
return if key_event.code == KeyCode::Enter {
|
||
self.handle_key_event_without_popup(key_event)
|
||
} else {
|
||
(InputResult::None, true)
|
||
};
|
||
};
|
||
|
||
let sel_path = sel.to_string_lossy().to_string();
|
||
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
|
||
let is_image = Self::is_image_path(&sel_path);
|
||
if is_image {
|
||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||
let path_buf = PathBuf::from(&sel_path);
|
||
match image::image_dimensions(&path_buf) {
|
||
Ok((width, height)) => {
|
||
tracing::debug!("selected image dimensions={}x{}", width, height);
|
||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||
// using the flat text and byte-offset cursor API.
|
||
let cursor_offset = self.textarea.cursor();
|
||
let text = self.textarea.text();
|
||
// Clamp to a valid char boundary to avoid panics when slicing.
|
||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||
let before_cursor = &text[..safe_cursor];
|
||
let after_cursor = &text[safe_cursor..];
|
||
|
||
// Determine token boundaries in the full text.
|
||
let start_idx = before_cursor
|
||
.char_indices()
|
||
.rfind(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, c)| idx + c.len_utf8())
|
||
.unwrap_or(0);
|
||
let end_rel_idx = after_cursor
|
||
.char_indices()
|
||
.find(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, _)| idx)
|
||
.unwrap_or(after_cursor.len());
|
||
let end_idx = safe_cursor + end_rel_idx;
|
||
|
||
self.textarea.replace_range(start_idx..end_idx, "");
|
||
self.textarea.set_cursor(start_idx);
|
||
|
||
self.attach_image(path_buf);
|
||
// Add a trailing space to keep typing fluid.
|
||
self.textarea.insert_str(" ");
|
||
}
|
||
Err(err) => {
|
||
tracing::trace!("image dimensions lookup failed: {err}");
|
||
// Fallback to plain path insertion if metadata read fails.
|
||
self.insert_selected_path(&sel_path);
|
||
}
|
||
}
|
||
} else {
|
||
// Non-image: inserting file path.
|
||
self.insert_selected_path(&sel_path);
|
||
}
|
||
self.active_popup = ActivePopup::None;
|
||
(InputResult::None, true)
|
||
}
|
||
input => self.handle_input_basic(input),
|
||
}
|
||
}
|
||
|
||
fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||
if self.handle_shortcut_overlay_key(&key_event) {
|
||
return (InputResult::None, true);
|
||
}
|
||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||
|
||
let ActivePopup::Skill(popup) = &mut self.active_popup else {
|
||
unreachable!();
|
||
};
|
||
|
||
let mut selected_mention: Option<(String, Option<String>)> = None;
|
||
let mut close_popup = false;
|
||
|
||
let result = match key_event {
|
||
KeyEvent {
|
||
code: KeyCode::Up, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('p'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
popup.move_up();
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Down,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('n'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
popup.move_down();
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Esc, ..
|
||
} => {
|
||
if let Some(tok) = self.current_mention_token() {
|
||
self.dismissed_mention_popup_token = Some(tok);
|
||
}
|
||
self.active_popup = ActivePopup::None;
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Tab, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Enter,
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
if let Some(mention) = popup.selected_mention() {
|
||
selected_mention = Some((mention.insert_text.clone(), mention.path.clone()));
|
||
}
|
||
close_popup = true;
|
||
(InputResult::None, true)
|
||
}
|
||
input => self.handle_input_basic(input),
|
||
};
|
||
|
||
if close_popup {
|
||
if let Some((insert_text, path)) = selected_mention {
|
||
self.insert_selected_mention(&insert_text, path.as_deref());
|
||
}
|
||
self.active_popup = ActivePopup::None;
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
fn is_image_path(path: &str) -> bool {
|
||
let lower = path.to_ascii_lowercase();
|
||
lower.ends_with(".png")
|
||
|| lower.ends_with(".jpg")
|
||
|| lower.ends_with(".jpeg")
|
||
|| lower.ends_with(".gif")
|
||
|| lower.ends_with(".webp")
|
||
}
|
||
|
||
fn trim_text_elements(
|
||
original: &str,
|
||
trimmed: &str,
|
||
elements: Vec<TextElement>,
|
||
) -> Vec<TextElement> {
|
||
if trimmed.is_empty() || elements.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let trimmed_start = original.len().saturating_sub(original.trim_start().len());
|
||
let trimmed_end = trimmed_start.saturating_add(trimmed.len());
|
||
|
||
elements
|
||
.into_iter()
|
||
.filter_map(|elem| {
|
||
let start = elem.byte_range.start;
|
||
let end = elem.byte_range.end;
|
||
if end <= trimmed_start || start >= trimmed_end {
|
||
return None;
|
||
}
|
||
let new_start = start.saturating_sub(trimmed_start);
|
||
let new_end = end.saturating_sub(trimmed_start).min(trimmed.len());
|
||
if new_start >= new_end {
|
||
return None;
|
||
}
|
||
let placeholder = trimmed.get(new_start..new_end).map(str::to_string);
|
||
Some(TextElement::new(
|
||
ByteRange {
|
||
start: new_start,
|
||
end: new_end,
|
||
},
|
||
placeholder,
|
||
))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Expand large-paste placeholders using element ranges and rebuild other element spans.
|
||
pub(crate) fn expand_pending_pastes(
|
||
text: &str,
|
||
mut elements: Vec<TextElement>,
|
||
pending_pastes: &[(String, String)],
|
||
) -> (String, Vec<TextElement>) {
|
||
if pending_pastes.is_empty() || elements.is_empty() {
|
||
return (text.to_string(), elements);
|
||
}
|
||
|
||
// Stage 1: index pending paste payloads by placeholder for deterministic replacements.
|
||
let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new();
|
||
for (placeholder, actual) in pending_pastes {
|
||
pending_by_placeholder
|
||
.entry(placeholder.as_str())
|
||
.or_default()
|
||
.push_back(actual.as_str());
|
||
}
|
||
|
||
// Stage 2: walk elements in order and rebuild text/spans in a single pass.
|
||
elements.sort_by_key(|elem| elem.byte_range.start);
|
||
|
||
let mut rebuilt = String::with_capacity(text.len());
|
||
let mut rebuilt_elements = Vec::with_capacity(elements.len());
|
||
let mut cursor = 0usize;
|
||
|
||
for elem in elements {
|
||
let start = elem.byte_range.start.min(text.len());
|
||
let end = elem.byte_range.end.min(text.len());
|
||
if start > end {
|
||
continue;
|
||
}
|
||
if start > cursor {
|
||
rebuilt.push_str(&text[cursor..start]);
|
||
}
|
||
let elem_text = &text[start..end];
|
||
let placeholder = elem.placeholder(text).map(str::to_string);
|
||
let replacement = placeholder
|
||
.as_deref()
|
||
.and_then(|ph| pending_by_placeholder.get_mut(ph))
|
||
.and_then(VecDeque::pop_front);
|
||
if let Some(actual) = replacement {
|
||
// Stage 3: inline actual paste payloads and drop their placeholder elements.
|
||
rebuilt.push_str(actual);
|
||
} else {
|
||
// Stage 4: keep non-paste elements, updating their byte ranges for the new text.
|
||
let new_start = rebuilt.len();
|
||
rebuilt.push_str(elem_text);
|
||
let new_end = rebuilt.len();
|
||
let placeholder = placeholder.or_else(|| Some(elem_text.to_string()));
|
||
rebuilt_elements.push(TextElement::new(
|
||
ByteRange {
|
||
start: new_start,
|
||
end: new_end,
|
||
},
|
||
placeholder,
|
||
));
|
||
}
|
||
cursor = end;
|
||
}
|
||
|
||
// Stage 5: append any trailing text that followed the last element.
|
||
if cursor < text.len() {
|
||
rebuilt.push_str(&text[cursor..]);
|
||
}
|
||
|
||
(rebuilt, rebuilt_elements)
|
||
}
|
||
|
||
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
|
||
self.skills.as_ref()
|
||
}
|
||
|
||
pub fn plugins(&self) -> Option<&Vec<PluginCapabilitySummary>> {
|
||
self.plugins.as_ref()
|
||
}
|
||
|
||
fn mentions_enabled(&self) -> bool {
|
||
let skills_ready = self
|
||
.skills
|
||
.as_ref()
|
||
.is_some_and(|skills| !skills.is_empty());
|
||
let plugins_ready = self
|
||
.plugins
|
||
.as_ref()
|
||
.is_some_and(|plugins| !plugins.is_empty());
|
||
let connectors_ready = self.connectors_enabled
|
||
&& self
|
||
.connectors_snapshot
|
||
.as_ref()
|
||
.is_some_and(|snapshot| !snapshot.connectors.is_empty());
|
||
skills_ready || plugins_ready || connectors_ready
|
||
}
|
||
|
||
/// Extract a token prefixed with `prefix` under the cursor, if any.
|
||
///
|
||
/// The returned string **does not** include the prefix.
|
||
///
|
||
/// Behavior:
|
||
/// - The cursor may be anywhere *inside* the token (including on the
|
||
/// leading prefix). It does **not** need to be at the end of the line.
|
||
/// - A token is delimited by ASCII whitespace (space, tab, newline).
|
||
/// - If the cursor is on `prefix` inside an existing token (for example the
|
||
/// second `@` in `@scope/pkg@latest`), keep treating the surrounding
|
||
/// whitespace-delimited token as the active token rather than starting a
|
||
/// new token at that nested prefix.
|
||
/// - If the token under the cursor starts with `prefix`, that token is
|
||
/// returned without the leading prefix. When `allow_empty` is true, a
|
||
/// lone prefix character yields `Some(String::new())` to surface hints.
|
||
fn current_prefixed_token(
|
||
textarea: &TextArea,
|
||
prefix: char,
|
||
allow_empty: bool,
|
||
) -> Option<String> {
|
||
let cursor_offset = textarea.cursor();
|
||
let text = textarea.text();
|
||
|
||
// Adjust the provided byte offset to the nearest valid char boundary at or before it.
|
||
let mut safe_cursor = cursor_offset.min(text.len());
|
||
// If we're not on a char boundary, move back to the start of the current char.
|
||
if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) {
|
||
// Find the last valid boundary <= cursor_offset.
|
||
safe_cursor = text
|
||
.char_indices()
|
||
.map(|(i, _)| i)
|
||
.take_while(|&i| i <= cursor_offset)
|
||
.last()
|
||
.unwrap_or(0);
|
||
}
|
||
|
||
// Split the line around the (now safe) cursor position.
|
||
let before_cursor = &text[..safe_cursor];
|
||
let after_cursor = &text[safe_cursor..];
|
||
|
||
// Detect whether we're on whitespace at the cursor boundary.
|
||
let at_whitespace = if safe_cursor < text.len() {
|
||
text[safe_cursor..]
|
||
.chars()
|
||
.next()
|
||
.map(char::is_whitespace)
|
||
.unwrap_or(false)
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// Left candidate: token containing the cursor position.
|
||
let start_left = before_cursor
|
||
.char_indices()
|
||
.rfind(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, c)| idx + c.len_utf8())
|
||
.unwrap_or(0);
|
||
let end_left_rel = after_cursor
|
||
.char_indices()
|
||
.find(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, _)| idx)
|
||
.unwrap_or(after_cursor.len());
|
||
let end_left = safe_cursor + end_left_rel;
|
||
let token_left = if start_left < end_left {
|
||
Some(&text[start_left..end_left])
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Right candidate: token immediately after any whitespace from the cursor.
|
||
let ws_len_right: usize = after_cursor
|
||
.chars()
|
||
.take_while(|c| c.is_whitespace())
|
||
.map(char::len_utf8)
|
||
.sum();
|
||
let start_right = safe_cursor + ws_len_right;
|
||
let end_right_rel = text[start_right..]
|
||
.char_indices()
|
||
.find(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, _)| idx)
|
||
.unwrap_or(text.len() - start_right);
|
||
let end_right = start_right + end_right_rel;
|
||
let token_right = if start_right < end_right {
|
||
Some(&text[start_right..end_right])
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let prefix_str = prefix.to_string();
|
||
let left_match = token_left.filter(|t| t.starts_with(prefix));
|
||
let right_match = token_right.filter(|t| t.starts_with(prefix));
|
||
|
||
let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string());
|
||
let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string());
|
||
|
||
if at_whitespace {
|
||
if right_prefixed.is_some() {
|
||
return right_prefixed;
|
||
}
|
||
if token_left.is_some_and(|t| t == prefix_str) {
|
||
return allow_empty.then(String::new);
|
||
}
|
||
return left_prefixed;
|
||
}
|
||
if after_cursor.starts_with(prefix) {
|
||
let prefix_starts_token = before_cursor
|
||
.chars()
|
||
.next_back()
|
||
.is_none_or(char::is_whitespace);
|
||
return if prefix_starts_token {
|
||
right_prefixed.or(left_prefixed)
|
||
} else {
|
||
left_prefixed
|
||
};
|
||
}
|
||
left_prefixed.or(right_prefixed)
|
||
}
|
||
|
||
/// Extract the `@token` that the cursor is currently positioned on, if any.
|
||
///
|
||
/// The returned string **does not** include the leading `@`.
|
||
fn current_at_token(textarea: &TextArea) -> Option<String> {
|
||
Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false)
|
||
}
|
||
|
||
fn current_mention_token(&self) -> Option<String> {
|
||
if !self.mentions_enabled() {
|
||
return None;
|
||
}
|
||
Self::current_prefixed_token(&self.textarea, '$', /*allow_empty*/ true)
|
||
}
|
||
|
||
/// Replace the active `@token` (the one under the cursor) with `path`.
|
||
///
|
||
/// The algorithm mirrors `current_at_token` so replacement works no matter
|
||
/// where the cursor is within the token and regardless of how many
|
||
/// `@tokens` exist in the line.
|
||
fn insert_selected_path(&mut self, path: &str) {
|
||
let cursor_offset = self.textarea.cursor();
|
||
let text = self.textarea.text();
|
||
// Clamp to a valid char boundary to avoid panics when slicing.
|
||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||
|
||
let before_cursor = &text[..safe_cursor];
|
||
let after_cursor = &text[safe_cursor..];
|
||
|
||
// Determine token boundaries.
|
||
let start_idx = before_cursor
|
||
.char_indices()
|
||
.rfind(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, c)| idx + c.len_utf8())
|
||
.unwrap_or(0);
|
||
|
||
let end_rel_idx = after_cursor
|
||
.char_indices()
|
||
.find(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, _)| idx)
|
||
.unwrap_or(after_cursor.len());
|
||
let end_idx = safe_cursor + end_rel_idx;
|
||
|
||
// If the path contains whitespace, wrap it in double quotes so the
|
||
// local prompt arg parser treats it as a single argument. Avoid adding
|
||
// quotes when the path already contains one to keep behavior simple.
|
||
let needs_quotes = path.chars().any(char::is_whitespace);
|
||
let inserted = if needs_quotes && !path.contains('"') {
|
||
format!("\"{path}\"")
|
||
} else {
|
||
path.to_string()
|
||
};
|
||
|
||
// Replace just the active `@token` so unrelated text elements, such as
|
||
// large-paste placeholders, remain atomic and can still expand on submit.
|
||
self.textarea
|
||
.replace_range(start_idx..end_idx, &format!("{inserted} "));
|
||
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
|
||
self.textarea.set_cursor(new_cursor);
|
||
}
|
||
|
||
fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) {
|
||
let cursor_offset = self.textarea.cursor();
|
||
let text = self.textarea.text();
|
||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||
|
||
let before_cursor = &text[..safe_cursor];
|
||
let after_cursor = &text[safe_cursor..];
|
||
|
||
let start_idx = before_cursor
|
||
.char_indices()
|
||
.rfind(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, c)| idx + c.len_utf8())
|
||
.unwrap_or(0);
|
||
|
||
let end_rel_idx = after_cursor
|
||
.char_indices()
|
||
.find(|(_, c)| c.is_whitespace())
|
||
.map(|(idx, _)| idx)
|
||
.unwrap_or(after_cursor.len());
|
||
let end_idx = safe_cursor + end_rel_idx;
|
||
|
||
// Remove the active token and insert the selected mention as an atomic element.
|
||
self.textarea.replace_range(start_idx..end_idx, "");
|
||
self.textarea.set_cursor(start_idx);
|
||
let id = self.textarea.insert_element(insert_text);
|
||
|
||
if let (Some(path), Some(mention)) =
|
||
(path, Self::mention_name_from_insert_text(insert_text))
|
||
{
|
||
self.mention_bindings.insert(
|
||
id,
|
||
ComposerMentionBinding {
|
||
mention,
|
||
path: path.to_string(),
|
||
},
|
||
);
|
||
}
|
||
|
||
self.textarea.insert_str(" ");
|
||
let new_cursor = start_idx
|
||
.saturating_add(insert_text.len())
|
||
.saturating_add(1);
|
||
self.textarea.set_cursor(new_cursor);
|
||
}
|
||
|
||
fn mention_name_from_insert_text(insert_text: &str) -> Option<String> {
|
||
let name = insert_text.strip_prefix('$')?;
|
||
if name.is_empty() {
|
||
return None;
|
||
}
|
||
if name
|
||
.as_bytes()
|
||
.iter()
|
||
.all(|byte| is_mention_name_char(*byte))
|
||
{
|
||
Some(name.to_string())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn current_mention_elements(&self) -> Vec<(u64, String)> {
|
||
self.textarea
|
||
.text_element_snapshots()
|
||
.into_iter()
|
||
.filter_map(|snapshot| {
|
||
Self::mention_name_from_insert_text(snapshot.text.as_str())
|
||
.map(|mention| (snapshot.id, mention))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn snapshot_mention_bindings(&self) -> Vec<MentionBinding> {
|
||
let mut ordered = Vec::new();
|
||
for (id, mention) in self.current_mention_elements() {
|
||
if let Some(binding) = self.mention_bindings.get(&id)
|
||
&& binding.mention == mention
|
||
{
|
||
ordered.push(MentionBinding {
|
||
mention: binding.mention.clone(),
|
||
path: binding.path.clone(),
|
||
});
|
||
}
|
||
}
|
||
ordered
|
||
}
|
||
|
||
fn bind_mentions_from_snapshot(&mut self, mention_bindings: Vec<MentionBinding>) {
|
||
self.mention_bindings.clear();
|
||
if mention_bindings.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let text = self.textarea.text().to_string();
|
||
let mut scan_from = 0usize;
|
||
for binding in mention_bindings {
|
||
let token = format!("${}", binding.mention);
|
||
let Some(range) =
|
||
find_next_mention_token_range(text.as_str(), token.as_str(), scan_from)
|
||
else {
|
||
continue;
|
||
};
|
||
|
||
let id = if let Some(id) = self.textarea.add_element_range(range.clone()) {
|
||
Some(id)
|
||
} else {
|
||
self.textarea.element_id_for_exact_range(range.clone())
|
||
};
|
||
|
||
if let Some(id) = id {
|
||
self.mention_bindings.insert(
|
||
id,
|
||
ComposerMentionBinding {
|
||
mention: binding.mention,
|
||
path: binding.path,
|
||
},
|
||
);
|
||
scan_from = range.end;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
|
||
/// On success, clears pending paste payloads because placeholders have been expanded.
|
||
///
|
||
/// When `record_history` is true, the final submission is stored for ↑/↓ recall.
|
||
fn prepare_submission_text(
|
||
&mut self,
|
||
record_history: bool,
|
||
) -> Option<(String, Vec<TextElement>)> {
|
||
self.prepare_submission_text_with_options(record_history, SlashValidation::Immediate)
|
||
}
|
||
|
||
fn prepare_submission_text_with_options(
|
||
&mut self,
|
||
record_history: bool,
|
||
slash_validation: SlashValidation,
|
||
) -> Option<(String, Vec<TextElement>)> {
|
||
let mut text = self.current_text();
|
||
let original_input = text.clone();
|
||
let original_text_elements = self.current_text_elements();
|
||
let original_mention_bindings = self.snapshot_mention_bindings();
|
||
let original_local_image_paths = self
|
||
.attached_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect::<Vec<_>>();
|
||
let original_pending_pastes = self.pending_pastes.clone();
|
||
let mut text_elements = original_text_elements.clone();
|
||
let input_starts_with_space = original_input.starts_with(' ');
|
||
self.recent_submission_mention_bindings.clear();
|
||
self.textarea.set_text_clearing_elements("");
|
||
self.is_bash_mode = false;
|
||
|
||
if !self.pending_pastes.is_empty() {
|
||
// Expand placeholders so element byte ranges stay aligned.
|
||
let (expanded, expanded_elements) =
|
||
Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes);
|
||
text = expanded;
|
||
text_elements = expanded_elements;
|
||
}
|
||
|
||
let expanded_input = text.clone();
|
||
|
||
// If there is neither text nor attachments, suppress submission entirely.
|
||
text = text.trim().to_string();
|
||
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
|
||
|
||
if slash_validation == SlashValidation::Immediate
|
||
&& self.slash_commands_enabled()
|
||
&& let Some((name, _rest, _rest_offset)) = parse_slash_name(&text)
|
||
{
|
||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||
if !treat_as_plain_text {
|
||
let is_builtin =
|
||
slash_commands::find_builtin_command(name, self.builtin_command_flags())
|
||
.is_some();
|
||
if !is_builtin {
|
||
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.pending_pastes.clone_from(&original_pending_pastes);
|
||
self.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.pending_pastes.clone_from(&original_pending_pastes);
|
||
self.textarea.set_cursor(original_input.len());
|
||
return None;
|
||
}
|
||
self.prune_attached_images_for_submission(&text, &text_elements);
|
||
if text.is_empty() && self.attached_images.is_empty() && self.remote_image_urls.is_empty() {
|
||
return None;
|
||
}
|
||
self.recent_submission_mention_bindings = original_mention_bindings.clone();
|
||
if record_history
|
||
&& (!text.is_empty()
|
||
|| !self.attached_images.is_empty()
|
||
|| !self.remote_image_urls.is_empty())
|
||
{
|
||
let local_image_paths = self
|
||
.attached_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect();
|
||
self.history.record_local_submission(HistoryEntry {
|
||
text: text.clone(),
|
||
text_elements: text_elements.clone(),
|
||
local_image_paths,
|
||
remote_image_urls: self.remote_image_urls.clone(),
|
||
mention_bindings: original_mention_bindings,
|
||
pending_pastes: Vec::new(),
|
||
});
|
||
}
|
||
self.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) {
|
||
self.handle_submission_with_time(should_queue, Instant::now())
|
||
}
|
||
|
||
fn handle_submission_with_time(
|
||
&mut self,
|
||
should_queue: bool,
|
||
now: Instant,
|
||
) -> (InputResult, bool) {
|
||
if should_queue {
|
||
let raw_text = self.textarea.text();
|
||
let defer_slash_validation =
|
||
self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text);
|
||
if let Some((text, text_elements)) = self.prepare_submission_text_with_options(
|
||
/*record_history*/ true,
|
||
if defer_slash_validation {
|
||
SlashValidation::Deferred
|
||
} else {
|
||
SlashValidation::Immediate
|
||
},
|
||
) {
|
||
let action = self.queued_input_action(&text, defer_slash_validation);
|
||
return (
|
||
InputResult::Queued {
|
||
text,
|
||
text_elements,
|
||
action,
|
||
},
|
||
true,
|
||
);
|
||
}
|
||
return (InputResult::None, true);
|
||
}
|
||
|
||
// If the first line is a bare built-in slash command (no args),
|
||
// dispatch it even when the slash popup isn't visible. This preserves
|
||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||
// "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond
|
||
// the '/name' token and our caret-based heuristic hides the popup,
|
||
// but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit
|
||
// literal text.
|
||
if let Some(result) = self.try_dispatch_bare_slash_command() {
|
||
return (result, true);
|
||
}
|
||
|
||
// If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst
|
||
// and accumulate it rather than submitting or inserting immediately.
|
||
// Do not treat as paste inside a slash-command context.
|
||
let in_slash_context = self.slash_commands_enabled()
|
||
&& !self.is_bash_mode
|
||
&& (matches!(self.active_popup, ActivePopup::Command(_))
|
||
|| self
|
||
.textarea
|
||
.text()
|
||
.lines()
|
||
.next()
|
||
.unwrap_or("")
|
||
.starts_with('/'));
|
||
if !self.disable_paste_burst
|
||
&& self.paste_burst.is_active()
|
||
&& !in_slash_context
|
||
&& self.paste_burst.append_newline_if_active(now)
|
||
{
|
||
return (InputResult::None, true);
|
||
}
|
||
|
||
// During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit.
|
||
if !in_slash_context
|
||
&& !self.disable_paste_burst
|
||
&& self
|
||
.paste_burst
|
||
.newline_should_insert_instead_of_submit(now)
|
||
{
|
||
self.textarea.insert_str("\n");
|
||
self.paste_burst.extend_window(now);
|
||
return (InputResult::None, true);
|
||
}
|
||
|
||
let original_input = self.current_text();
|
||
let original_text_elements = self.current_text_elements();
|
||
let original_mention_bindings = self.snapshot_mention_bindings();
|
||
let original_local_image_paths = self
|
||
.attached_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect::<Vec<_>>();
|
||
let original_pending_pastes = self.pending_pastes.clone();
|
||
if let Some(result) = self.try_dispatch_slash_command_with_args() {
|
||
return (result, true);
|
||
}
|
||
|
||
if let Some((text, text_elements)) =
|
||
self.prepare_submission_text(/*record_history*/ true)
|
||
{
|
||
if should_queue {
|
||
(
|
||
InputResult::Queued {
|
||
text,
|
||
text_elements,
|
||
action: QueuedInputAction::Plain,
|
||
},
|
||
true,
|
||
)
|
||
} else {
|
||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||
(
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
},
|
||
true,
|
||
)
|
||
}
|
||
} else {
|
||
// Restore text if submission was suppressed.
|
||
self.set_text_content_with_mention_bindings(
|
||
original_input,
|
||
original_text_elements,
|
||
original_local_image_paths,
|
||
original_mention_bindings,
|
||
);
|
||
self.pending_pastes = original_pending_pastes;
|
||
(InputResult::None, true)
|
||
}
|
||
}
|
||
|
||
/// Check if the first line is a bare slash command (no args) and dispatch it.
|
||
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
|
||
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
|
||
if !self.slash_commands_enabled() || self.is_bash_mode {
|
||
return None;
|
||
}
|
||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||
if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line)
|
||
&& rest.is_empty()
|
||
&& let Some(cmd) =
|
||
slash_commands::find_builtin_command(name, self.builtin_command_flags())
|
||
{
|
||
if self.reject_slash_command_if_unavailable(cmd) {
|
||
self.stage_slash_command_history();
|
||
self.record_pending_slash_command_history();
|
||
return Some(InputResult::None);
|
||
}
|
||
self.stage_slash_command_history();
|
||
self.textarea.set_text_clearing_elements("");
|
||
self.is_bash_mode = false;
|
||
Some(InputResult::Command(cmd))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Check if the input is a slash command with args (e.g., /review args) and dispatch it.
|
||
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
|
||
fn try_dispatch_slash_command_with_args(&mut self) -> Option<InputResult> {
|
||
if !self.slash_commands_enabled() || self.is_bash_mode {
|
||
return None;
|
||
}
|
||
let text = self.textarea.text().to_string();
|
||
if text.starts_with(' ') {
|
||
return None;
|
||
}
|
||
|
||
let (name, rest, rest_offset) = parse_slash_name(&text)?;
|
||
if rest.is_empty() || name.contains('/') {
|
||
return None;
|
||
}
|
||
|
||
let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;
|
||
|
||
if !cmd.supports_inline_args() {
|
||
return None;
|
||
}
|
||
if self.reject_slash_command_if_unavailable(cmd) {
|
||
self.stage_slash_command_history();
|
||
self.record_pending_slash_command_history();
|
||
return Some(InputResult::None);
|
||
}
|
||
|
||
self.stage_slash_command_history();
|
||
|
||
let mut args_elements =
|
||
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
|
||
let trimmed_rest = rest.trim();
|
||
args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements);
|
||
Some(InputResult::CommandWithArgs(
|
||
cmd,
|
||
trimmed_rest.to_string(),
|
||
args_elements,
|
||
))
|
||
}
|
||
|
||
/// Expand pending placeholders and extract normalized inline-command args.
|
||
///
|
||
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
|
||
/// not consume user input. Once a command needs its args, this helper performs the usual
|
||
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
|
||
/// full-text offsets to command-arg offsets.
|
||
///
|
||
/// Callers that already staged slash-command history should normally pass `false` for
|
||
/// `record_history`; otherwise a command such as `/plan investigate` would be entered into
|
||
/// local recall through both the slash-command path and the message-submission path.
|
||
pub(crate) fn prepare_inline_args_submission(
|
||
&mut self,
|
||
record_history: bool,
|
||
) -> Option<(String, Vec<TextElement>)> {
|
||
let (prepared_text, prepared_elements) = self.prepare_submission_text(record_history)?;
|
||
let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(&prepared_text)?;
|
||
let mut args_elements = Self::slash_command_args_elements(
|
||
prepared_rest,
|
||
prepared_rest_offset,
|
||
&prepared_elements,
|
||
);
|
||
let trimmed_rest = prepared_rest.trim();
|
||
args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements);
|
||
Some((trimmed_rest.to_string(), args_elements))
|
||
}
|
||
|
||
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
|
||
if !self.is_task_running || cmd.available_during_task() {
|
||
return false;
|
||
}
|
||
let message = format!(
|
||
"'/{}' is disabled while a task is in progress.",
|
||
cmd.command()
|
||
);
|
||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||
history_cell::new_error_event(message),
|
||
)));
|
||
true
|
||
}
|
||
|
||
fn should_parse_as_slash_on_dequeue_from_raw_text(&self, text: &str) -> bool {
|
||
self.slash_commands_enabled() && !text.starts_with(' ') && text.trim().starts_with('/')
|
||
}
|
||
|
||
fn queued_input_action(
|
||
&self,
|
||
prepared_text: &str,
|
||
defer_slash_validation: bool,
|
||
) -> QueuedInputAction {
|
||
if defer_slash_validation && prepared_text.starts_with('/') {
|
||
QueuedInputAction::ParseSlash
|
||
} else if prepared_text.starts_with('!') {
|
||
QueuedInputAction::RunShell
|
||
} else {
|
||
QueuedInputAction::Plain
|
||
}
|
||
}
|
||
|
||
/// Stage the current slash-command text for later local recall.
|
||
///
|
||
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
|
||
/// commits the staged entry after dispatch so command recall follows the submitted text, not
|
||
/// the command outcome.
|
||
fn stage_slash_command_history(&mut self) {
|
||
self.stage_slash_command_history_text(self.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, cmd: SlashCommand) {
|
||
self.stage_slash_command_history_text(format!("/{}", cmd.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.textarea.text_elements(),
|
||
local_image_paths: self
|
||
.attached_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect(),
|
||
remote_image_urls: self.remote_image_urls.clone(),
|
||
mention_bindings: self.snapshot_mention_bindings(),
|
||
pending_pastes: self.pending_pastes.clone(),
|
||
});
|
||
}
|
||
|
||
/// Translate full-text element ranges into command-argument ranges.
|
||
///
|
||
/// `rest_offset` is the byte offset where `rest` begins in the full text.
|
||
fn slash_command_args_elements(
|
||
rest: &str,
|
||
rest_offset: usize,
|
||
text_elements: &[TextElement],
|
||
) -> Vec<TextElement> {
|
||
if rest.is_empty() || text_elements.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
text_elements
|
||
.iter()
|
||
.filter_map(|elem| {
|
||
if elem.byte_range.end <= rest_offset {
|
||
return None;
|
||
}
|
||
let start = elem.byte_range.start.saturating_sub(rest_offset);
|
||
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
|
||
if start >= rest.len() {
|
||
return None;
|
||
}
|
||
end = end.min(rest.len());
|
||
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn remote_images_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||
self.remote_image_urls
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(idx, _)| {
|
||
let label = local_image_label_text(idx + 1);
|
||
if self.selected_remote_image_index == Some(idx) {
|
||
label.cyan().reversed().into()
|
||
} else {
|
||
label.cyan().into()
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn clear_remote_image_selection(&mut self) {
|
||
self.selected_remote_image_index = None;
|
||
}
|
||
|
||
fn remove_selected_remote_image(&mut self, selected_index: usize) {
|
||
if selected_index >= self.remote_image_urls.len() {
|
||
self.clear_remote_image_selection();
|
||
return;
|
||
}
|
||
self.remote_image_urls.remove(selected_index);
|
||
self.selected_remote_image_index = if self.remote_image_urls.is_empty() {
|
||
None
|
||
} else {
|
||
Some(selected_index.min(self.remote_image_urls.len() - 1))
|
||
};
|
||
self.relabel_attached_images_and_update_placeholders();
|
||
self.sync_popups();
|
||
}
|
||
|
||
fn handle_remote_image_selection_key(
|
||
&mut self,
|
||
key_event: &KeyEvent,
|
||
) -> Option<(InputResult, bool)> {
|
||
if self.remote_image_urls.is_empty()
|
||
|| key_event.modifiers != KeyModifiers::NONE
|
||
|| key_event.kind != KeyEventKind::Press
|
||
{
|
||
return None;
|
||
}
|
||
|
||
match key_event.code {
|
||
KeyCode::Up => {
|
||
if let Some(selected) = self.selected_remote_image_index {
|
||
self.selected_remote_image_index = Some(selected.saturating_sub(1));
|
||
Some((InputResult::None, true))
|
||
} else if self.textarea.cursor() == 0 {
|
||
self.selected_remote_image_index = Some(self.remote_image_urls.len() - 1);
|
||
Some((InputResult::None, true))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
KeyCode::Down => {
|
||
if let Some(selected) = self.selected_remote_image_index {
|
||
if selected + 1 < self.remote_image_urls.len() {
|
||
self.selected_remote_image_index = Some(selected + 1);
|
||
} else {
|
||
self.clear_remote_image_selection();
|
||
}
|
||
Some((InputResult::None, true))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
KeyCode::Delete | KeyCode::Backspace => {
|
||
if let Some(selected) = self.selected_remote_image_index {
|
||
self.remove_selected_remote_image(selected);
|
||
Some((InputResult::None, true))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// 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.selected_remote_image_index.is_some() {
|
||
self.clear_remote_image_selection();
|
||
}
|
||
if self.handle_shortcut_overlay_key(&key_event) {
|
||
return (InputResult::None, true);
|
||
}
|
||
if key_event.code == KeyCode::Esc {
|
||
if self.is_empty() {
|
||
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||
if next_mode != self.footer_mode {
|
||
self.footer_mode = next_mode;
|
||
return (InputResult::None, true);
|
||
}
|
||
}
|
||
} else {
|
||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||
}
|
||
if self.queue_keys.is_pressed(key_event)
|
||
&& (self.is_task_running || !self.is_bang_shell_command())
|
||
{
|
||
return self.handle_submission(self.is_task_running);
|
||
}
|
||
|
||
if self.submit_keys.is_pressed(key_event) {
|
||
return self.handle_submission(/*should_queue*/ false);
|
||
}
|
||
|
||
match key_event {
|
||
KeyEvent {
|
||
code: KeyCode::Char('d'),
|
||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} if self.is_empty() => (InputResult::None, false),
|
||
// -------------------------------------------------------------
|
||
// History navigation (Up / Down) – only when the composer is not
|
||
// empty or when the cursor is at the correct position, to avoid
|
||
// interfering with normal cursor movement.
|
||
// -------------------------------------------------------------
|
||
KeyEvent {
|
||
code: KeyCode::Up | KeyCode::Down,
|
||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('p') | KeyCode::Char('n'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
if self.history.should_handle_navigation(
|
||
&self.current_text(),
|
||
self.history_navigation_cursor(),
|
||
) {
|
||
let replace_entry = match key_event.code {
|
||
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
|
||
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
|
||
KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx),
|
||
KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx),
|
||
_ => unreachable!(),
|
||
};
|
||
if let Some(entry) = replace_entry {
|
||
self.apply_history_entry(entry);
|
||
return (InputResult::None, true);
|
||
}
|
||
}
|
||
self.handle_input_basic(key_event)
|
||
}
|
||
input => self.handle_input_basic(input),
|
||
}
|
||
}
|
||
|
||
fn is_bang_shell_command(&self) -> bool {
|
||
self.current_text().trim_start().starts_with('!')
|
||
}
|
||
|
||
fn shell_mode_footer_line(&self) -> Option<Line<'static>> {
|
||
self.is_bang_shell_command()
|
||
.then_some(())
|
||
.map(|_| Line::from(vec![Span::from("Shell mode").light_red()]))
|
||
}
|
||
|
||
/// Applies any due `PasteBurst` flush at time `now`.
|
||
///
|
||
/// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations.
|
||
///
|
||
/// Callers:
|
||
///
|
||
/// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render.
|
||
/// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag.
|
||
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
|
||
match self.paste_burst.flush_if_due(now) {
|
||
FlushResult::Paste(pasted) => {
|
||
self.handle_paste(pasted);
|
||
true
|
||
}
|
||
FlushResult::Typed(ch) => {
|
||
self.insert_str(ch.to_string().as_str());
|
||
true
|
||
}
|
||
FlushResult::None => false,
|
||
}
|
||
}
|
||
|
||
/// Handles keys that mutate the textarea, including paste-burst detection.
|
||
///
|
||
/// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain
|
||
/// character streams are converted into explicit paste operations on terminals that do not
|
||
/// reliably provide bracketed paste.
|
||
///
|
||
/// Ordering is important:
|
||
///
|
||
/// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated
|
||
/// edits.
|
||
/// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input.
|
||
/// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key;
|
||
/// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a
|
||
/// timestamp to time out against.
|
||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||
// Ignore key releases here to avoid treating them as additional input
|
||
// (e.g., appending the same character twice via paste-burst logic).
|
||
if !matches!(input.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||
return (InputResult::None, false);
|
||
}
|
||
|
||
self.handle_input_basic_with_time(input, Instant::now())
|
||
}
|
||
|
||
fn handle_input_basic_with_time(
|
||
&mut self,
|
||
input: KeyEvent,
|
||
now: Instant,
|
||
) -> (InputResult, bool) {
|
||
// If we have a buffered non-bracketed paste burst and enough time has
|
||
// elapsed since the last char, flush it before handling a new input.
|
||
self.handle_paste_burst_flush(now);
|
||
|
||
if !matches!(input.code, KeyCode::Esc) {
|
||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||
}
|
||
|
||
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
||
if matches!(input.code, KeyCode::Enter)
|
||
&& !self.disable_paste_burst
|
||
&& self.paste_burst.is_active()
|
||
&& self.paste_burst.append_newline_if_active(now)
|
||
{
|
||
return (InputResult::None, true);
|
||
}
|
||
|
||
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
|
||
//
|
||
// This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their
|
||
// normal semantics, and so we can aggressively flush/clear any burst state when non-char
|
||
// keys are pressed.
|
||
if let KeyEvent {
|
||
code: KeyCode::Char(ch),
|
||
modifiers,
|
||
..
|
||
} = input
|
||
{
|
||
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
|
||
if !has_ctrl_or_alt && !self.disable_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.paste_burst.on_plain_char(ch, now) {
|
||
CharDecision::BufferAppend => {
|
||
self.paste_burst.append_char_to_buffer(ch, now);
|
||
return (InputResult::None, true);
|
||
}
|
||
CharDecision::BeginBuffer { retro_chars } => {
|
||
let cur = self.textarea.cursor();
|
||
let txt = self.textarea.text();
|
||
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
|
||
let before = &txt[..safe_cur];
|
||
if let Some(grab) =
|
||
self.paste_burst
|
||
.decide_begin_buffer(now, before, retro_chars as usize)
|
||
{
|
||
if !grab.grabbed.is_empty() {
|
||
self.textarea.replace_range(grab.start_byte..safe_cur, "");
|
||
}
|
||
self.paste_burst.append_char_to_buffer(ch, now);
|
||
return (InputResult::None, true);
|
||
}
|
||
// If decide_begin_buffer opted not to start buffering,
|
||
// fall through to normal insertion below.
|
||
}
|
||
CharDecision::BeginBufferFromPending => {
|
||
// First char was held; now append the current one.
|
||
self.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.paste_burst.flush_before_modified_input() {
|
||
self.handle_paste(pasted);
|
||
}
|
||
}
|
||
|
||
// Flush any buffered burst before applying a non-char input (arrow keys, etc).
|
||
//
|
||
// `clear_window_after_non_char()` clears `last_plain_char_time`. If we cleared that while
|
||
// `PasteBurst.buffer` is non-empty, `flush_if_due()` would no longer have a timestamp to
|
||
// time out against, and the buffered paste could remain stuck until another plain char
|
||
// arrives.
|
||
if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter)
|
||
&& let Some(pasted) = self.paste_burst.flush_before_modified_input()
|
||
{
|
||
self.handle_paste(pasted);
|
||
}
|
||
// For non-char inputs (or after flushing), handle normally.
|
||
// Track element removals so we can drop any corresponding placeholders without scanning
|
||
// the full text. (Placeholders are atomic elements; when deleted, the element disappears.)
|
||
let elements_before = if self.pending_pastes.is_empty()
|
||
&& self.attached_images.is_empty()
|
||
&& self.remote_image_urls.is_empty()
|
||
{
|
||
None
|
||
} else {
|
||
Some(self.textarea.element_payloads())
|
||
};
|
||
|
||
if self.is_bash_mode
|
||
&& matches!(input.code, KeyCode::Backspace)
|
||
&& self.textarea.cursor() == 0
|
||
{
|
||
self.is_bash_mode = false;
|
||
return (InputResult::None, true);
|
||
}
|
||
|
||
self.textarea.input_with_keymap(input, &self.editor_keymap);
|
||
self.sync_bash_mode_from_text();
|
||
|
||
if let Some(elements_before) = elements_before {
|
||
self.reconcile_deleted_elements(elements_before);
|
||
}
|
||
|
||
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
|
||
let crossterm::event::KeyEvent {
|
||
code, modifiers, ..
|
||
} = input;
|
||
match code {
|
||
KeyCode::Char(_) => {
|
||
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
|
||
if has_ctrl_or_alt {
|
||
self.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.paste_burst.clear_window_after_non_char();
|
||
}
|
||
}
|
||
|
||
(InputResult::None, true)
|
||
}
|
||
|
||
fn sync_bash_mode_from_text(&mut self) {
|
||
if !self.is_bash_mode && self.textarea.text().starts_with('!') {
|
||
self.textarea.replace_range(0..1, "");
|
||
self.is_bash_mode = true;
|
||
}
|
||
}
|
||
|
||
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
|
||
let elements_after: HashSet<String> =
|
||
self.textarea.element_payloads().into_iter().collect();
|
||
|
||
let mut removed_any_image = false;
|
||
for removed in elements_before
|
||
.into_iter()
|
||
.filter(|payload| !elements_after.contains(payload))
|
||
{
|
||
self.pending_pastes.retain(|(ph, _)| ph != &removed);
|
||
|
||
if let Some(idx) = self
|
||
.attached_images
|
||
.iter()
|
||
.position(|img| img.placeholder == removed)
|
||
{
|
||
self.attached_images.remove(idx);
|
||
removed_any_image = true;
|
||
}
|
||
}
|
||
|
||
if removed_any_image {
|
||
self.relabel_attached_images_and_update_placeholders();
|
||
}
|
||
}
|
||
|
||
fn relabel_attached_images_and_update_placeholders(&mut self) {
|
||
for idx in 0..self.attached_images.len() {
|
||
let expected = local_image_label_text(self.remote_image_urls.len() + idx + 1);
|
||
let current = self.attached_images[idx].placeholder.clone();
|
||
if current == expected {
|
||
continue;
|
||
}
|
||
|
||
self.attached_images[idx].placeholder = expected.clone();
|
||
let _renamed = self.textarea.replace_element_payload(¤t, &expected);
|
||
}
|
||
}
|
||
|
||
/// Handle the dedicated shortcut-overlay toggle key(s).
|
||
///
|
||
/// This only toggles when the composer is empty and no paste burst is in
|
||
/// progress, so typing/pasting `?` still inserts text instead of opening
|
||
/// help. The bound key list intentionally supports terminal-variant
|
||
/// modifier reporting (for example `?` vs `shift-?`).
|
||
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
|
||
if key_event.kind != KeyEventKind::Press {
|
||
return false;
|
||
}
|
||
|
||
let toggles = self.toggle_shortcuts_keys.is_pressed(*key_event)
|
||
&& self.is_empty()
|
||
&& !self.is_in_paste_burst();
|
||
|
||
if !toggles {
|
||
return false;
|
||
}
|
||
|
||
let next = toggle_shortcut_mode(
|
||
self.footer_mode,
|
||
self.quit_shortcut_hint_visible(),
|
||
self.is_empty(),
|
||
);
|
||
let changed = next != self.footer_mode;
|
||
self.footer_mode = next;
|
||
changed
|
||
}
|
||
|
||
fn footer_props(&self) -> FooterProps {
|
||
let mode = self.footer_mode();
|
||
let is_wsl = {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
mode == FooterMode::ShortcutOverlay && crate::clipboard_paste::is_probably_wsl()
|
||
}
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
false
|
||
}
|
||
};
|
||
|
||
FooterProps {
|
||
mode,
|
||
esc_backtrack_hint: self.esc_backtrack_hint,
|
||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||
is_task_running: self.is_task_running,
|
||
quit_shortcut_key: self.quit_shortcut_key,
|
||
collaboration_modes_enabled: self.collaboration_modes_enabled,
|
||
is_wsl,
|
||
context_window_percent: self.context_window_percent,
|
||
context_window_used_tokens: self.context_window_used_tokens,
|
||
status_line_value: self.status_line_value.clone(),
|
||
status_line_enabled: self.status_line_enabled,
|
||
key_hints: FooterKeyHints {
|
||
toggle_shortcuts: self.footer_toggle_shortcuts_key,
|
||
queue: self.footer_queue_key,
|
||
insert_newline: self.footer_insert_newline_key,
|
||
external_editor: self.footer_external_editor_key,
|
||
edit_previous: Some(key_hint::plain(KeyCode::Esc)),
|
||
show_transcript: self.footer_show_transcript_key,
|
||
history_search: self.footer_history_search_key,
|
||
reasoning_down: self.footer_reasoning_down_key,
|
||
reasoning_up: self.footer_reasoning_up_key,
|
||
},
|
||
active_agent_label: self.active_agent_label.clone(),
|
||
}
|
||
}
|
||
|
||
/// Resolve the effective footer mode via a small priority waterfall.
|
||
///
|
||
/// The base mode is derived solely from whether the composer is empty:
|
||
/// `ComposerEmpty` iff empty, otherwise `ComposerHasDraft`. Transient
|
||
/// modes (Esc hint, overlay, quit reminder) can override that base when
|
||
/// their conditions are active.
|
||
fn footer_mode(&self) -> FooterMode {
|
||
if self.history_search.is_some() {
|
||
return FooterMode::HistorySearch;
|
||
}
|
||
|
||
let base_mode = if self.is_empty() {
|
||
FooterMode::ComposerEmpty
|
||
} else {
|
||
FooterMode::ComposerHasDraft
|
||
};
|
||
|
||
match self.footer_mode {
|
||
FooterMode::HistorySearch => FooterMode::HistorySearch,
|
||
FooterMode::EscHint => FooterMode::EscHint,
|
||
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
|
||
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
|
||
FooterMode::QuitShortcutReminder
|
||
}
|
||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||
if self.quit_shortcut_hint_visible() =>
|
||
{
|
||
FooterMode::QuitShortcutReminder
|
||
}
|
||
FooterMode::QuitShortcutReminder => base_mode,
|
||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => base_mode,
|
||
}
|
||
}
|
||
|
||
fn custom_footer_height(&self) -> Option<u16> {
|
||
if self.footer_flash_visible() {
|
||
return Some(1);
|
||
}
|
||
self.footer_hint_override
|
||
.as_ref()
|
||
.map(|items| if items.is_empty() { 0 } else { 1 })
|
||
}
|
||
|
||
pub(crate) fn sync_popups(&mut self) {
|
||
self.sync_slash_command_elements();
|
||
if self.history_search.is_some() {
|
||
if self.current_file_query.is_some() {
|
||
self.app_event_tx
|
||
.send(AppEvent::StartFileSearch(String::new()));
|
||
self.current_file_query = None;
|
||
}
|
||
self.active_popup = ActivePopup::None;
|
||
self.dismissed_file_popup_token = None;
|
||
self.dismissed_mention_popup_token = None;
|
||
return;
|
||
}
|
||
if !self.popups_enabled() {
|
||
self.active_popup = ActivePopup::None;
|
||
return;
|
||
}
|
||
let file_token = Self::current_at_token(&self.textarea);
|
||
let browsing_history = self
|
||
.history
|
||
.should_handle_navigation(&self.current_text(), self.history_navigation_cursor());
|
||
// When browsing input history (shell-style Up/Down recall), skip all popup
|
||
// synchronization so nothing steals focus from continued history navigation.
|
||
if browsing_history {
|
||
if self.current_file_query.is_some() {
|
||
self.app_event_tx
|
||
.send(AppEvent::StartFileSearch(String::new()));
|
||
self.current_file_query = None;
|
||
}
|
||
self.active_popup = ActivePopup::None;
|
||
return;
|
||
}
|
||
let mention_token = self.current_mention_token();
|
||
|
||
let allow_command_popup = self.slash_commands_enabled()
|
||
&& !self.is_bash_mode
|
||
&& file_token.is_none()
|
||
&& mention_token.is_none();
|
||
self.sync_command_popup(allow_command_popup);
|
||
|
||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||
if self.current_file_query.is_some() {
|
||
self.app_event_tx
|
||
.send(AppEvent::StartFileSearch(String::new()));
|
||
self.current_file_query = None;
|
||
}
|
||
self.dismissed_file_popup_token = None;
|
||
self.dismissed_mention_popup_token = None;
|
||
return;
|
||
}
|
||
|
||
if let Some(token) = mention_token {
|
||
if self.current_file_query.is_some() {
|
||
self.app_event_tx
|
||
.send(AppEvent::StartFileSearch(String::new()));
|
||
self.current_file_query = None;
|
||
}
|
||
self.sync_mention_popup(token);
|
||
return;
|
||
}
|
||
self.dismissed_mention_popup_token = None;
|
||
|
||
if let Some(token) = file_token {
|
||
self.sync_file_search_popup(token);
|
||
return;
|
||
}
|
||
|
||
if self.current_file_query.is_some() {
|
||
self.app_event_tx
|
||
.send(AppEvent::StartFileSearch(String::new()));
|
||
self.current_file_query = None;
|
||
}
|
||
self.dismissed_file_popup_token = None;
|
||
if matches!(
|
||
self.active_popup,
|
||
ActivePopup::File(_) | ActivePopup::Skill(_)
|
||
) {
|
||
self.active_popup = ActivePopup::None;
|
||
}
|
||
}
|
||
|
||
/// Keep slash command elements aligned with the current first line.
|
||
fn sync_slash_command_elements(&mut self) {
|
||
if !self.slash_commands_enabled() {
|
||
return;
|
||
}
|
||
let text = self.textarea.text();
|
||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||
let first_line = &text[..first_line_end];
|
||
let desired_range = self.slash_command_element_range(first_line);
|
||
// Slash commands are only valid at byte 0 of the first line.
|
||
// Any slash-shaped element not matching the current desired prefix is stale.
|
||
let mut has_desired = false;
|
||
let mut stale_ranges = Vec::new();
|
||
for elem in self.textarea.text_elements() {
|
||
let Some(payload) = elem.placeholder(text) else {
|
||
continue;
|
||
};
|
||
if payload.strip_prefix('/').is_none() {
|
||
continue;
|
||
}
|
||
let range = elem.byte_range.start..elem.byte_range.end;
|
||
if desired_range.as_ref() == Some(&range) {
|
||
has_desired = true;
|
||
} else {
|
||
stale_ranges.push(range);
|
||
}
|
||
}
|
||
|
||
for range in stale_ranges {
|
||
self.textarea.remove_element_range(range);
|
||
}
|
||
|
||
if let Some(range) = desired_range
|
||
&& !has_desired
|
||
{
|
||
self.textarea.add_element_range(range);
|
||
}
|
||
}
|
||
|
||
fn slash_command_element_range(&self, first_line: &str) -> Option<Range<usize>> {
|
||
if self.is_bash_mode {
|
||
return None;
|
||
}
|
||
let (name, _rest, _rest_offset) = parse_slash_name(first_line)?;
|
||
if name.contains('/') {
|
||
return None;
|
||
}
|
||
let element_end = 1 + name.len();
|
||
let has_space_after = first_line
|
||
.get(element_end..)
|
||
.and_then(|tail| tail.chars().next())
|
||
.is_some_and(char::is_whitespace);
|
||
if !has_space_after {
|
||
return None;
|
||
}
|
||
if self.is_known_slash_name(name) {
|
||
Some(0..element_end)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn is_known_slash_name(&self, name: &str) -> bool {
|
||
slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some()
|
||
}
|
||
|
||
/// If the cursor is currently within a slash command on the first line,
|
||
/// extract the command name and the rest of the line after it.
|
||
/// Returns None if the cursor is outside a slash command.
|
||
fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> {
|
||
if !first_line.starts_with('/') {
|
||
return None;
|
||
}
|
||
|
||
let name_start = 1usize;
|
||
let name_end = first_line[name_start..]
|
||
.find(char::is_whitespace)
|
||
.map(|idx| name_start + idx)
|
||
.unwrap_or_else(|| first_line.len());
|
||
|
||
if cursor > name_end {
|
||
return None;
|
||
}
|
||
|
||
let name = &first_line[name_start..name_end];
|
||
let rest_start = first_line[name_end..]
|
||
.find(|c: char| !c.is_whitespace())
|
||
.map(|idx| name_end + idx)
|
||
.unwrap_or(name_end);
|
||
let rest = &first_line[rest_start..];
|
||
|
||
Some((name, rest))
|
||
}
|
||
|
||
/// Heuristic for whether the typed slash command looks like a valid
|
||
/// prefix for any known built-in command.
|
||
/// Empty names only count when there is no extra content after the '/'.
|
||
fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool {
|
||
if !self.slash_commands_enabled() {
|
||
return false;
|
||
}
|
||
if name.is_empty() {
|
||
return rest_after_name.is_empty();
|
||
}
|
||
|
||
slash_commands::has_builtin_prefix(name, self.builtin_command_flags())
|
||
}
|
||
|
||
/// Synchronize `self.command_popup` with the current text in the
|
||
/// textarea. This must be called after every modification that can change
|
||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||
fn sync_command_popup(&mut self, allow: bool) {
|
||
if !allow {
|
||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||
self.active_popup = ActivePopup::None;
|
||
}
|
||
return;
|
||
}
|
||
// Determine whether the caret is inside the initial '/name' token on the first line.
|
||
let text = self.textarea.text();
|
||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||
let first_line = &text[..first_line_end];
|
||
let cursor = self.textarea.cursor();
|
||
let caret_on_first_line = cursor <= first_line_end;
|
||
|
||
let is_editing_slash_command_name = caret_on_first_line
|
||
&& Self::slash_command_under_cursor(first_line, cursor)
|
||
.is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest));
|
||
|
||
// If the cursor is currently positioned within an `@token`, prefer the
|
||
// file-search popup over the slash popup so users can insert a file path
|
||
// as an argument to the command (e.g., "/review @docs/...").
|
||
if Self::current_at_token(&self.textarea).is_some() {
|
||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||
self.active_popup = ActivePopup::None;
|
||
}
|
||
return;
|
||
}
|
||
match &mut self.active_popup {
|
||
ActivePopup::Command(popup) => {
|
||
if is_editing_slash_command_name {
|
||
popup.on_composer_text_change(first_line.to_string());
|
||
} else {
|
||
self.active_popup = ActivePopup::None;
|
||
}
|
||
}
|
||
_ => {
|
||
if is_editing_slash_command_name {
|
||
let collaboration_modes_enabled = self.collaboration_modes_enabled;
|
||
let connectors_enabled = self.connectors_enabled;
|
||
let plugins_command_enabled = self.plugins_command_enabled;
|
||
let fast_command_enabled = self.fast_command_enabled;
|
||
let goal_command_enabled = self.goal_command_enabled;
|
||
let personality_command_enabled = self.personality_command_enabled;
|
||
let realtime_conversation_enabled = self.realtime_conversation_enabled;
|
||
let audio_device_selection_enabled = self.audio_device_selection_enabled;
|
||
let mut command_popup = CommandPopup::new(CommandPopupFlags {
|
||
collaboration_modes_enabled,
|
||
connectors_enabled,
|
||
plugins_command_enabled,
|
||
fast_command_enabled,
|
||
goal_command_enabled,
|
||
personality_command_enabled,
|
||
realtime_conversation_enabled,
|
||
audio_device_selection_enabled,
|
||
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
|
||
side_conversation_active: self.side_conversation_active,
|
||
});
|
||
command_popup.on_composer_text_change(first_line.to_string());
|
||
self.active_popup = ActivePopup::Command(command_popup);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
||
/// Note this is only called when self.active_popup is NOT Command.
|
||
fn sync_file_search_popup(&mut self, query: String) {
|
||
// If user dismissed popup for this exact query, don't reopen until text changes.
|
||
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
|
||
return;
|
||
}
|
||
|
||
if query.is_empty() {
|
||
self.app_event_tx
|
||
.send(AppEvent::StartFileSearch(String::new()));
|
||
} else {
|
||
self.app_event_tx
|
||
.send(AppEvent::StartFileSearch(query.clone()));
|
||
}
|
||
|
||
match &mut self.active_popup {
|
||
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.active_popup = ActivePopup::File(popup);
|
||
}
|
||
}
|
||
|
||
if query.is_empty() {
|
||
self.current_file_query = None;
|
||
} else {
|
||
self.current_file_query = Some(query);
|
||
}
|
||
self.dismissed_file_popup_token = None;
|
||
}
|
||
|
||
fn sync_mention_popup(&mut self, query: String) {
|
||
if self.dismissed_mention_popup_token.as_ref() == Some(&query) {
|
||
return;
|
||
}
|
||
|
||
let mentions = self.mention_items();
|
||
if mentions.is_empty() {
|
||
self.active_popup = ActivePopup::None;
|
||
return;
|
||
}
|
||
|
||
match &mut self.active_popup {
|
||
ActivePopup::Skill(popup) => {
|
||
popup.set_query(&query);
|
||
popup.set_mentions(mentions);
|
||
}
|
||
_ => {
|
||
let mut popup = SkillPopup::new(mentions);
|
||
popup.set_query(&query);
|
||
self.active_popup = ActivePopup::Skill(popup);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn mention_items(&self) -> Vec<MentionItem> {
|
||
let mut mentions = Vec::new();
|
||
if let Some(skills) = self.skills.as_ref() {
|
||
for skill in skills {
|
||
let display_name = skill_display_name(skill);
|
||
let description = skill_description(skill);
|
||
let skill_name = skill.name.clone();
|
||
let search_terms = if display_name == skill.name {
|
||
vec![skill_name.clone()]
|
||
} else {
|
||
vec![skill_name.clone(), display_name.clone()]
|
||
};
|
||
mentions.push(MentionItem {
|
||
display_name,
|
||
description,
|
||
insert_text: format!("${skill_name}"),
|
||
search_terms,
|
||
path: Some(skill.path_to_skills_md.to_string_lossy().into_owned()),
|
||
category_tag: Some("[Skill]".to_string()),
|
||
sort_rank: 1,
|
||
});
|
||
}
|
||
}
|
||
|
||
if let Some(plugins) = self.plugins.as_ref() {
|
||
for plugin in plugins {
|
||
let (plugin_name, marketplace_name) = plugin
|
||
.config_name
|
||
.split_once('@')
|
||
.unwrap_or((plugin.config_name.as_str(), ""));
|
||
let mut capability_labels = Vec::new();
|
||
if plugin.has_skills {
|
||
capability_labels.push("skills".to_string());
|
||
}
|
||
if !plugin.mcp_server_names.is_empty() {
|
||
let mcp_server_count = plugin.mcp_server_names.len();
|
||
capability_labels.push(if mcp_server_count == 1 {
|
||
"1 MCP server".to_string()
|
||
} else {
|
||
format!("{mcp_server_count} MCP servers")
|
||
});
|
||
}
|
||
if !plugin.app_connector_ids.is_empty() {
|
||
let app_count = plugin.app_connector_ids.len();
|
||
capability_labels.push(if app_count == 1 {
|
||
"1 app".to_string()
|
||
} else {
|
||
format!("{app_count} apps")
|
||
});
|
||
}
|
||
let description = plugin.description.clone().or_else(|| {
|
||
Some(if capability_labels.is_empty() {
|
||
"Plugin".to_string()
|
||
} else {
|
||
format!("Plugin · {}", capability_labels.join(" · "))
|
||
})
|
||
});
|
||
let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()];
|
||
if plugin.display_name != plugin_name {
|
||
search_terms.push(plugin.display_name.clone());
|
||
}
|
||
if !marketplace_name.is_empty() {
|
||
search_terms.push(marketplace_name.to_string());
|
||
}
|
||
mentions.push(MentionItem {
|
||
display_name: plugin.display_name.clone(),
|
||
description,
|
||
insert_text: format!("${plugin_name}"),
|
||
search_terms,
|
||
path: Some(format!("plugin://{}", plugin.config_name)),
|
||
category_tag: Some("[Plugin]".to_string()),
|
||
sort_rank: 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
if self.connectors_enabled
|
||
&& let Some(snapshot) = self.connectors_snapshot.as_ref()
|
||
{
|
||
for connector in &snapshot.connectors {
|
||
if !connector.is_accessible || !connector.is_enabled {
|
||
continue;
|
||
}
|
||
let display_name = codex_connectors::metadata::connector_display_label(connector);
|
||
let description = Some(Self::connector_brief_description(connector));
|
||
let slug = codex_connectors::metadata::connector_mention_slug(connector);
|
||
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
|
||
let connector_id = connector.id.as_str();
|
||
mentions.push(MentionItem {
|
||
display_name: display_name.clone(),
|
||
description,
|
||
insert_text: format!("${slug}"),
|
||
search_terms,
|
||
path: Some(format!("app://{connector_id}")),
|
||
category_tag: Some("[App]".to_string()),
|
||
sort_rank: 1,
|
||
});
|
||
}
|
||
}
|
||
|
||
mentions
|
||
}
|
||
|
||
fn connector_brief_description(connector: &AppInfo) -> String {
|
||
Self::connector_description(connector).unwrap_or_default()
|
||
}
|
||
|
||
fn connector_description(connector: &AppInfo) -> Option<String> {
|
||
connector
|
||
.description
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|description| !description.is_empty())
|
||
.map(str::to_string)
|
||
}
|
||
|
||
fn set_has_focus(&mut self, has_focus: bool) {
|
||
self.has_focus = has_focus;
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option<String>) {
|
||
self.input_enabled = enabled;
|
||
self.input_disabled_placeholder = if enabled { None } else { placeholder };
|
||
|
||
// Avoid leaving interactive popups open while input is blocked.
|
||
if !enabled && !matches!(self.active_popup, ActivePopup::None) {
|
||
self.active_popup = ActivePopup::None;
|
||
}
|
||
}
|
||
|
||
pub fn set_task_running(&mut self, running: bool) {
|
||
self.is_task_running = running;
|
||
}
|
||
|
||
pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) {
|
||
if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens
|
||
{
|
||
return;
|
||
}
|
||
self.context_window_percent = percent;
|
||
self.context_window_used_tokens = used_tokens;
|
||
}
|
||
|
||
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
|
||
self.esc_backtrack_hint = show;
|
||
if show {
|
||
self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||
} else {
|
||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) -> bool {
|
||
if self.status_line_value == status_line {
|
||
return false;
|
||
}
|
||
self.status_line_value = status_line;
|
||
true
|
||
}
|
||
|
||
pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool {
|
||
if self.status_line_enabled == enabled {
|
||
return false;
|
||
}
|
||
self.status_line_enabled = enabled;
|
||
true
|
||
}
|
||
|
||
pub(crate) fn set_side_conversation_context_label(&mut self, label: Option<String>) -> bool {
|
||
if self.side_conversation_context_label == label {
|
||
return false;
|
||
}
|
||
self.side_conversation_context_label = label;
|
||
true
|
||
}
|
||
|
||
/// Replaces the contextual footer label for the currently viewed agent.
|
||
///
|
||
/// Returning `false` means the value was unchanged, so callers can skip redraw work. This
|
||
/// field is intentionally just cached presentation state; `ChatComposer` does not infer which
|
||
/// thread is active on its own.
|
||
pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option<String>) -> bool {
|
||
if self.active_agent_label == active_agent_label {
|
||
return false;
|
||
}
|
||
self.active_agent_label = active_agent_label;
|
||
true
|
||
}
|
||
}
|
||
|
||
fn footer_insert_newline_key(
|
||
bindings: &[KeyBinding],
|
||
enhanced_keys_supported: bool,
|
||
) -> Option<KeyBinding> {
|
||
let shift_enter = key_hint::shift(KeyCode::Enter);
|
||
if enhanced_keys_supported && bindings.contains(&shift_enter) {
|
||
return Some(shift_enter);
|
||
}
|
||
|
||
let plain_enter = key_hint::plain(KeyCode::Enter);
|
||
bindings
|
||
.iter()
|
||
.copied()
|
||
.find(|binding| *binding != plain_enter)
|
||
.or_else(|| bindings.first().copied())
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
impl ChatComposer {
|
||
pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
|
||
self.textarea.update_named_element_by_id(id, text)
|
||
}
|
||
|
||
pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
|
||
let id = self.next_id();
|
||
self.textarea.insert_named_element(text, id.clone());
|
||
id
|
||
}
|
||
|
||
pub fn remove_recording_meter_placeholder(&mut self, id: &str) {
|
||
let _ = self.textarea.replace_element_by_id(id, "");
|
||
}
|
||
}
|
||
|
||
fn skill_description(skill: &SkillMetadata) -> Option<String> {
|
||
let description = skill
|
||
.interface
|
||
.as_ref()
|
||
.and_then(|interface| interface.short_description.as_deref())
|
||
.or(skill.short_description.as_deref())
|
||
.unwrap_or(&skill.description);
|
||
let trimmed = description.trim();
|
||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||
}
|
||
|
||
fn is_mention_name_char(byte: u8) -> bool {
|
||
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
|
||
}
|
||
|
||
fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option<Range<usize>> {
|
||
if token.is_empty() || from >= text.len() {
|
||
return None;
|
||
}
|
||
let bytes = text.as_bytes();
|
||
let token_bytes = token.as_bytes();
|
||
let mut index = from;
|
||
|
||
while index < bytes.len() {
|
||
if bytes[index] != b'$' {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
let end = index.saturating_add(token_bytes.len());
|
||
if end > bytes.len() {
|
||
return None;
|
||
}
|
||
if &bytes[index..end] != token_bytes {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
if bytes
|
||
.get(end)
|
||
.is_none_or(|byte| !is_mention_name_char(*byte))
|
||
{
|
||
return Some(index..end);
|
||
}
|
||
|
||
index = end;
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
impl Renderable for ChatComposer {
|
||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||
if !self.input_enabled || self.selected_remote_image_index.is_some() {
|
||
return None;
|
||
}
|
||
|
||
if let Some(pos) = self.history_search_cursor_pos(area) {
|
||
return Some(pos);
|
||
}
|
||
|
||
let [_, _, textarea_rect, _] = self.layout_areas(area);
|
||
let state = *self.textarea_state.borrow();
|
||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||
}
|
||
|
||
fn desired_height(&self, width: u16) -> u16 {
|
||
let footer_props = self.footer_props();
|
||
let footer_hint_height = self
|
||
.custom_footer_height()
|
||
.unwrap_or_else(|| footer_height(&footer_props));
|
||
let footer_spacing = Self::footer_spacing(footer_hint_height);
|
||
let footer_total_height = footer_hint_height + footer_spacing;
|
||
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
|
||
let inner_width = width.saturating_sub(COLS_WITH_MARGIN);
|
||
let remote_images_height: u16 = self
|
||
.remote_images_lines(inner_width)
|
||
.len()
|
||
.try_into()
|
||
.unwrap_or(u16::MAX);
|
||
let remote_images_separator = u16::from(remote_images_height > 0);
|
||
self.textarea.desired_height(inner_width)
|
||
+ remote_images_height
|
||
+ remote_images_separator
|
||
+ 2
|
||
+ match &self.active_popup {
|
||
ActivePopup::None => footer_total_height,
|
||
ActivePopup::Command(c) => c.calculate_required_height(width),
|
||
ActivePopup::File(c) => c.calculate_required_height(),
|
||
ActivePopup::Skill(c) => c.calculate_required_height(width),
|
||
}
|
||
}
|
||
|
||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||
self.render_with_mask(area, buf, /*mask_char*/ None);
|
||
}
|
||
}
|
||
|
||
impl ChatComposer {
|
||
pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option<char>) {
|
||
let [composer_rect, remote_images_rect, textarea_rect, popup_rect] =
|
||
self.layout_areas(area);
|
||
match &self.active_popup {
|
||
ActivePopup::Command(popup) => {
|
||
popup.render_ref(popup_rect, buf);
|
||
}
|
||
ActivePopup::File(popup) => {
|
||
popup.render_ref(popup_rect, buf);
|
||
}
|
||
ActivePopup::Skill(popup) => {
|
||
popup.render_ref(popup_rect, buf);
|
||
}
|
||
ActivePopup::None => {
|
||
let footer_props = self.footer_props();
|
||
let show_cycle_hint =
|
||
!footer_props.is_task_running && self.collaboration_mode_indicator.is_some();
|
||
let show_shortcuts_hint = match footer_props.mode {
|
||
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
|
||
FooterMode::ComposerHasDraft => false,
|
||
FooterMode::HistorySearch
|
||
| FooterMode::QuitShortcutReminder
|
||
| FooterMode::ShortcutOverlay
|
||
| FooterMode::EscHint => false,
|
||
};
|
||
let show_queue_hint = match footer_props.mode {
|
||
FooterMode::ComposerHasDraft => footer_props.is_task_running,
|
||
FooterMode::HistorySearch
|
||
| FooterMode::QuitShortcutReminder
|
||
| FooterMode::ComposerEmpty
|
||
| FooterMode::ShortcutOverlay
|
||
| FooterMode::EscHint => false,
|
||
};
|
||
let custom_height = self.custom_footer_height();
|
||
let footer_hint_height =
|
||
custom_height.unwrap_or_else(|| footer_height(&footer_props));
|
||
let footer_spacing = Self::footer_spacing(footer_hint_height);
|
||
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
|
||
let [_, hint_rect] = Layout::vertical([
|
||
Constraint::Length(footer_spacing),
|
||
Constraint::Length(footer_hint_height),
|
||
])
|
||
.areas(popup_rect);
|
||
hint_rect
|
||
} else {
|
||
popup_rect
|
||
};
|
||
if let Some(line) = self.history_search_footer_line() {
|
||
render_footer_line(hint_rect, buf, line);
|
||
} else {
|
||
let available_width =
|
||
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
|
||
let status_line_active = uses_passive_footer_status_layout(&footer_props);
|
||
let combined_status_line = if status_line_active {
|
||
passive_footer_status_line(&footer_props)
|
||
.map(ratatui::prelude::Stylize::dim)
|
||
} else {
|
||
None
|
||
};
|
||
let mut truncated_status_line = if status_line_active {
|
||
combined_status_line.as_ref().map(|line| {
|
||
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
let left_mode_indicator = if status_line_active {
|
||
None
|
||
} else {
|
||
self.collaboration_mode_indicator
|
||
};
|
||
let active_footer_hint_override = self.footer_hint_override.as_ref();
|
||
let mut left_width = if self.footer_flash_visible() {
|
||
self.footer_flash
|
||
.as_ref()
|
||
.map(|flash| flash.line.width() as u16)
|
||
.unwrap_or(0)
|
||
} else if let Some(items) = active_footer_hint_override {
|
||
footer_hint_items_width(items)
|
||
} else if status_line_active {
|
||
truncated_status_line
|
||
.as_ref()
|
||
.map(|line| line.width() as u16)
|
||
.unwrap_or(0)
|
||
} else {
|
||
footer_line_width(
|
||
&footer_props,
|
||
left_mode_indicator,
|
||
show_cycle_hint,
|
||
show_shortcuts_hint,
|
||
show_queue_hint,
|
||
)
|
||
};
|
||
let right_line =
|
||
if let Some(label) = self.side_conversation_context_label.as_ref() {
|
||
Some(side_conversation_context_line(label))
|
||
} else if let Some(line) = self.shell_mode_footer_line() {
|
||
Some(line)
|
||
} else if status_line_active {
|
||
let full = status_line_right_indicator(
|
||
self.collaboration_mode_indicator,
|
||
self.goal_status_indicator.as_ref(),
|
||
show_cycle_hint,
|
||
);
|
||
let compact = status_line_right_indicator(
|
||
self.collaboration_mode_indicator,
|
||
self.goal_status_indicator.as_ref(),
|
||
/*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(context_window_line(
|
||
footer_props.context_window_percent,
|
||
footer_props.context_window_used_tokens,
|
||
))
|
||
};
|
||
let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0);
|
||
if status_line_active
|
||
&& let Some(max_left) = max_left_width_for_right(hint_rect, right_width)
|
||
&& left_width > max_left
|
||
&& let Some(line) = combined_status_line.as_ref().map(|line| {
|
||
truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize)
|
||
})
|
||
{
|
||
left_width = line.width() as u16;
|
||
truncated_status_line = Some(line);
|
||
}
|
||
let can_show_left_and_context =
|
||
can_show_left_with_context(hint_rect, left_width, right_width);
|
||
let has_override =
|
||
self.footer_flash_visible() || active_footer_hint_override.is_some();
|
||
let single_line_layout = if has_override || status_line_active {
|
||
None
|
||
} else {
|
||
match footer_props.mode {
|
||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => {
|
||
// Both of these modes render the single-line footer style (with
|
||
// either the shortcuts hint or the optional queue hint). We still
|
||
// want the single-line collapse rules so the mode label can win over
|
||
// the context indicator on narrow widths.
|
||
Some(single_line_footer_layout(
|
||
hint_rect,
|
||
right_width,
|
||
left_mode_indicator,
|
||
show_cycle_hint,
|
||
show_shortcuts_hint,
|
||
show_queue_hint,
|
||
footer_props.key_hints,
|
||
))
|
||
}
|
||
FooterMode::EscHint
|
||
| FooterMode::HistorySearch
|
||
| FooterMode::QuitShortcutReminder
|
||
| FooterMode::ShortcutOverlay => None,
|
||
}
|
||
};
|
||
let show_right = if matches!(
|
||
footer_props.mode,
|
||
FooterMode::EscHint
|
||
| FooterMode::HistorySearch
|
||
| FooterMode::QuitShortcutReminder
|
||
| FooterMode::ShortcutOverlay
|
||
) {
|
||
false
|
||
} else {
|
||
single_line_layout
|
||
.as_ref()
|
||
.map(|(_, show_context)| *show_context)
|
||
.unwrap_or(can_show_left_and_context)
|
||
};
|
||
|
||
if let Some((summary_left, _)) = single_line_layout {
|
||
match summary_left {
|
||
SummaryLeft::Default => {
|
||
if status_line_active {
|
||
if let Some(line) = truncated_status_line.clone() {
|
||
render_footer_line(hint_rect, buf, line);
|
||
} else {
|
||
render_footer_from_props(
|
||
hint_rect,
|
||
buf,
|
||
&footer_props,
|
||
left_mode_indicator,
|
||
show_cycle_hint,
|
||
show_shortcuts_hint,
|
||
show_queue_hint,
|
||
);
|
||
}
|
||
} else {
|
||
render_footer_from_props(
|
||
hint_rect,
|
||
buf,
|
||
&footer_props,
|
||
left_mode_indicator,
|
||
show_cycle_hint,
|
||
show_shortcuts_hint,
|
||
show_queue_hint,
|
||
);
|
||
}
|
||
}
|
||
SummaryLeft::Custom(line) => {
|
||
render_footer_line(hint_rect, buf, line);
|
||
}
|
||
SummaryLeft::None => {}
|
||
}
|
||
} else if self.footer_flash_visible() {
|
||
if let Some(flash) = self.footer_flash.as_ref() {
|
||
flash.line.render(inset_footer_hint_area(hint_rect), buf);
|
||
}
|
||
} else if let Some(items) = active_footer_hint_override {
|
||
render_footer_hint_items(hint_rect, buf, items);
|
||
} else if status_line_active {
|
||
if let Some(line) = truncated_status_line {
|
||
render_footer_line(hint_rect, buf, line);
|
||
}
|
||
} else {
|
||
render_footer_from_props(
|
||
hint_rect,
|
||
buf,
|
||
&footer_props,
|
||
self.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.render_textarea(
|
||
composer_rect,
|
||
remote_images_rect,
|
||
textarea_rect,
|
||
buf,
|
||
mask_char,
|
||
);
|
||
}
|
||
|
||
/// Paint the composer's text input area, prompt chevron, and placeholder text.
|
||
///
|
||
/// In Zellij sessions the textarea uses explicit `Color::Reset` foreground styling
|
||
/// to prevent the multiplexer's pane chrome from bleeding into cell styles, and
|
||
/// substitutes hardcoded colors for `.bold()` / `.dim()` modifiers that Zellij
|
||
/// renders inconsistently. The standard path is unchanged.
|
||
fn render_textarea(
|
||
&self,
|
||
composer_rect: Rect,
|
||
remote_images_rect: Rect,
|
||
textarea_rect: Rect,
|
||
buf: &mut Buffer,
|
||
mask_char: Option<char>,
|
||
) {
|
||
let is_zellij = self.is_zellij;
|
||
let style = user_message_style();
|
||
let textarea_style = style.fg(ratatui::style::Color::Reset);
|
||
Block::default().style(style).render_ref(composer_rect, buf);
|
||
if !remote_images_rect.is_empty() {
|
||
Paragraph::new(self.remote_images_lines(remote_images_rect.width))
|
||
.style(style)
|
||
.render_ref(remote_images_rect, buf);
|
||
}
|
||
if is_zellij && !textarea_rect.is_empty() {
|
||
buf.set_style(textarea_rect, textarea_style);
|
||
}
|
||
if !textarea_rect.is_empty() {
|
||
let prompt = if self.input_enabled {
|
||
if self.is_bash_mode {
|
||
if is_zellij {
|
||
Span::from("!").light_red()
|
||
} else {
|
||
Span::from("!").light_red().bold()
|
||
}
|
||
} else if is_zellij {
|
||
Span::styled("›", style.fg(ratatui::style::Color::Cyan))
|
||
} else {
|
||
"›".bold()
|
||
}
|
||
} else if is_zellij {
|
||
Span::styled("›", style.fg(ratatui::style::Color::DarkGray))
|
||
} else {
|
||
"›".dim()
|
||
};
|
||
buf.set_span(
|
||
textarea_rect.x - LIVE_PREFIX_COLS,
|
||
textarea_rect.y,
|
||
&prompt,
|
||
textarea_rect.width,
|
||
);
|
||
}
|
||
|
||
let mut state = self.textarea_state.borrow_mut();
|
||
let textarea_is_empty = self.textarea.text().is_empty() && !self.is_bash_mode;
|
||
if let Some(mask_char) = mask_char {
|
||
self.textarea.render_ref_masked(
|
||
textarea_rect,
|
||
buf,
|
||
&mut state,
|
||
mask_char,
|
||
if is_zellij {
|
||
textarea_style
|
||
} else {
|
||
ratatui::style::Style::default()
|
||
},
|
||
);
|
||
} else if is_zellij && textarea_is_empty {
|
||
buf.set_style(textarea_rect, textarea_style);
|
||
} else if is_zellij {
|
||
let highlight_ranges = self.history_search_highlight_ranges();
|
||
if highlight_ranges.is_empty() {
|
||
self.textarea
|
||
.render_ref_styled(textarea_rect, buf, &mut state, textarea_style);
|
||
} else {
|
||
let highlight_style =
|
||
textarea_style.add_modifier(Modifier::REVERSED | Modifier::BOLD);
|
||
let highlights = highlight_ranges
|
||
.into_iter()
|
||
.map(|range| (range, highlight_style))
|
||
.collect::<Vec<_>>();
|
||
self.textarea.render_ref_styled_with_highlights(
|
||
textarea_rect,
|
||
buf,
|
||
&mut state,
|
||
textarea_style,
|
||
&highlights,
|
||
);
|
||
}
|
||
} else {
|
||
let highlight_ranges = self.history_search_highlight_ranges();
|
||
if highlight_ranges.is_empty() {
|
||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||
} else {
|
||
let highlight_style =
|
||
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
|
||
let highlights = highlight_ranges
|
||
.into_iter()
|
||
.map(|range| (range, highlight_style))
|
||
.collect::<Vec<_>>();
|
||
self.textarea.render_ref_styled_with_highlights(
|
||
textarea_rect,
|
||
buf,
|
||
&mut state,
|
||
Style::default(),
|
||
&highlights,
|
||
);
|
||
}
|
||
}
|
||
if textarea_is_empty {
|
||
let text = if self.input_enabled {
|
||
self.placeholder_text.as_str().to_string()
|
||
} else {
|
||
self.input_disabled_placeholder
|
||
.as_deref()
|
||
.unwrap_or("Input disabled.")
|
||
.to_string()
|
||
};
|
||
if !textarea_rect.is_empty() {
|
||
if is_zellij {
|
||
buf.set_string(
|
||
textarea_rect.x,
|
||
textarea_rect.y,
|
||
text,
|
||
textarea_style.fg(ratatui::style::Color::White).italic(),
|
||
);
|
||
} else {
|
||
let placeholder = Span::from(text).dim();
|
||
let line = Line::from(vec![placeholder]);
|
||
line.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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::AttachedImage;
|
||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||
use crate::bottom_pane::textarea::TextArea;
|
||
use tokio::sync::mpsc::unbounded_channel;
|
||
|
||
#[test]
|
||
fn footer_hint_row_is_separated_from_composer() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let area = Rect::new(0, 0, 40, 6);
|
||
let mut buf = Buffer::empty(area);
|
||
composer.render(area, &mut buf);
|
||
|
||
let row_to_string = |y: u16| {
|
||
let mut row = String::new();
|
||
for x in 0..area.width {
|
||
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||
}
|
||
row
|
||
};
|
||
|
||
let mut hint_row: Option<(u16, String)> = None;
|
||
for y in 0..area.height {
|
||
let row = row_to_string(y);
|
||
if row.contains("? for shortcuts") {
|
||
hint_row = Some((y, row));
|
||
break;
|
||
}
|
||
}
|
||
|
||
let (hint_row_idx, hint_row_contents) =
|
||
hint_row.expect("expected footer hint row to be rendered");
|
||
assert_eq!(
|
||
hint_row_idx,
|
||
area.height - 1,
|
||
"hint row should occupy the bottom line: {hint_row_contents:?}",
|
||
);
|
||
|
||
assert!(
|
||
hint_row_idx > 0,
|
||
"expected a spacing row above the footer hints",
|
||
);
|
||
|
||
let spacing_row = row_to_string(hint_row_idx - 1);
|
||
assert_eq!(
|
||
spacing_row.trim(),
|
||
"",
|
||
"expected blank spacing row above hints but saw: {spacing_row:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn footer_flash_overrides_footer_hint_override() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())]));
|
||
composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10));
|
||
|
||
let area = Rect::new(0, 0, 60, 6);
|
||
let mut buf = Buffer::empty(area);
|
||
composer.render(area, &mut buf);
|
||
|
||
let mut bottom_row = String::new();
|
||
for x in 0..area.width {
|
||
bottom_row.push(
|
||
buf[(x, area.height - 1)]
|
||
.symbol()
|
||
.chars()
|
||
.next()
|
||
.unwrap_or(' '),
|
||
);
|
||
}
|
||
assert!(
|
||
bottom_row.contains("FLASH"),
|
||
"expected flash content to render in footer row, saw: {bottom_row:?}",
|
||
);
|
||
assert!(
|
||
!bottom_row.contains("K label"),
|
||
"expected flash to override hint override, saw: {bottom_row:?}",
|
||
);
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[test]
|
||
fn remove_recording_meter_placeholder_clears_placeholder_text() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let id = composer.insert_recording_meter_placeholder("⠤⠤⠤⠤");
|
||
composer.remove_recording_meter_placeholder(&id);
|
||
|
||
assert_eq!(composer.textarea.text(), "");
|
||
assert!(composer.textarea.named_element_range(&id).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn footer_flash_expires_and_falls_back_to_hint_override() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())]));
|
||
composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10));
|
||
composer.footer_flash.as_mut().unwrap().expires_at =
|
||
Instant::now() - Duration::from_secs(1);
|
||
|
||
let area = Rect::new(0, 0, 60, 6);
|
||
let mut buf = Buffer::empty(area);
|
||
composer.render(area, &mut buf);
|
||
|
||
let mut bottom_row = String::new();
|
||
for x in 0..area.width {
|
||
bottom_row.push(
|
||
buf[(x, area.height - 1)]
|
||
.symbol()
|
||
.chars()
|
||
.next()
|
||
.unwrap_or(' '),
|
||
);
|
||
}
|
||
assert!(
|
||
bottom_row.contains("K label"),
|
||
"expected hint override to render after flash expired, saw: {bottom_row:?}",
|
||
);
|
||
assert!(
|
||
!bottom_row.contains("FLASH"),
|
||
"expected expired flash to be hidden, saw: {bottom_row:?}",
|
||
);
|
||
}
|
||
|
||
fn snapshot_composer_state_with_width<F>(
|
||
name: &str,
|
||
width: u16,
|
||
enhanced_keys_supported: bool,
|
||
setup: F,
|
||
) where
|
||
F: FnOnce(&mut ChatComposer),
|
||
{
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TestBackend;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
enhanced_keys_supported,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
setup(&mut composer);
|
||
let footer_props = composer.footer_props();
|
||
let footer_lines = footer_height(&footer_props);
|
||
let footer_spacing = ChatComposer::footer_spacing(footer_lines);
|
||
let height = footer_lines + footer_spacing + 8;
|
||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||
terminal
|
||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||
.unwrap();
|
||
insta::assert_snapshot!(name, terminal.backend());
|
||
}
|
||
|
||
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
|
||
where
|
||
F: FnOnce(&mut ChatComposer),
|
||
{
|
||
snapshot_composer_state_with_width(
|
||
name,
|
||
/*width*/ 100,
|
||
enhanced_keys_supported,
|
||
setup,
|
||
);
|
||
}
|
||
|
||
fn snapshot_zellij_composer_state<F>(name: &str, 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*/ true,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.is_zellij = true;
|
||
setup(&mut composer);
|
||
let footer_props = composer.footer_props();
|
||
let footer_lines = footer_height(&footer_props);
|
||
let footer_spacing = ChatComposer::footer_spacing(footer_lines);
|
||
let height = footer_lines + footer_spacing + 8;
|
||
let mut terminal = Terminal::new(TestBackend::new(100, height)).unwrap();
|
||
terminal
|
||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||
.unwrap();
|
||
insta::assert_snapshot!(name, terminal.backend());
|
||
}
|
||
|
||
#[test]
|
||
fn footer_mode_snapshots() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_shortcut_overlay",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
composer.set_esc_backtrack_hint(/*show*/ true);
|
||
let _ = composer
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_ctrl_c_quit",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
composer.show_quit_shortcut_hint(
|
||
key_hint::ctrl(KeyCode::Char('c')),
|
||
/*has_focus*/ true,
|
||
);
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_ctrl_c_interrupt",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
composer.set_task_running(/*running*/ true);
|
||
composer.show_quit_shortcut_hint(
|
||
key_hint::ctrl(KeyCode::Char('c')),
|
||
/*has_focus*/ true,
|
||
);
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_ctrl_c_then_esc_hint",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
composer.show_quit_shortcut_hint(
|
||
key_hint::ctrl(KeyCode::Char('c')),
|
||
/*has_focus*/ true,
|
||
);
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_esc_hint_from_overlay",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
let _ = composer
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_esc_hint_backtrack",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
composer.set_esc_backtrack_hint(/*show*/ true);
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_overlay_then_external_esc_hint",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
let _ = composer
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
composer.set_esc_backtrack_hint(/*show*/ true);
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_hidden_while_typing",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
type_chars_humanlike(composer, &['h']);
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_history_search",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
composer
|
||
.history
|
||
.record_local_submission(HistoryEntry::new("cargo test".to_string()));
|
||
let _ = composer
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||
let _ = composer
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_shell_command_absorbs_bang",
|
||
/*enhanced_keys_supported*/ true,
|
||
|composer| {
|
||
composer.set_status_line_enabled(/*enabled*/ true);
|
||
composer.set_status_line(Some(Line::from(
|
||
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
|
||
)));
|
||
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shell_command_cursor_uses_absorbed_prefix() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ true,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
let area = Rect::new(0, 0, 40, 5);
|
||
|
||
composer.set_text_content("!git".to_string(), Vec::new(), Vec::new());
|
||
composer.move_cursor_to_end();
|
||
assert_eq!(composer.cursor_pos(area), Some((5, 1)));
|
||
|
||
composer.set_text_content("! git".to_string(), Vec::new(), Vec::new());
|
||
composer.move_cursor_to_end();
|
||
assert_eq!(composer.cursor_pos(area), Some((6, 1)));
|
||
}
|
||
|
||
#[test]
|
||
fn shell_command_uses_shell_accent_style() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ true,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_status_line_enabled(/*enabled*/ true);
|
||
composer.set_status_line(Some(Line::from(
|
||
"gpt-5.4 high fast · ~/code/codex-1 · Context 0% used",
|
||
)));
|
||
composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new());
|
||
|
||
let area = Rect::new(0, 0, 100, 9);
|
||
let mut buf = Buffer::empty(area);
|
||
composer.render(area, &mut buf);
|
||
|
||
let prompt_cell = &buf[(0, 1)];
|
||
assert_eq!(prompt_cell.symbol(), "!");
|
||
assert_eq!(prompt_cell.style().fg, Some(Color::LightRed));
|
||
|
||
let footer_y = area.height - 1;
|
||
let footer_text = (0..area.width)
|
||
.map(|x| buf[(x, footer_y)].symbol().chars().next().unwrap_or(' '))
|
||
.collect::<String>();
|
||
let shell_label_x = footer_text
|
||
.find("Shell mode")
|
||
.expect("expected shell mode footer label");
|
||
assert_eq!(
|
||
buf[(shell_label_x as u16, footer_y)].style().fg,
|
||
Some(Color::LightRed)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn 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 zellij_empty_composer_snapshot() {
|
||
snapshot_zellij_composer_state("zellij_empty_composer", |_composer| {});
|
||
}
|
||
|
||
#[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.active_popup, ActivePopup::None));
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
|
||
assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty);
|
||
assert!(!composer.esc_backtrack_hint);
|
||
}
|
||
|
||
#[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.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.textarea.text(), placeholder);
|
||
assert_eq!(
|
||
composer.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.textarea.text(), placeholder);
|
||
assert_eq!(composer.pending_pastes, vec![(placeholder.clone(), large)]);
|
||
assert_eq!(composer.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 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.textarea.text(), placeholder);
|
||
assert_eq!(composer.local_image_paths(), vec![path]);
|
||
assert_eq!(composer.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.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.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
|
||
.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.textarea.text(), "");
|
||
|
||
let _ = flush_after_paste_burst(&mut composer);
|
||
|
||
assert_eq!(composer.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.active_popup, ActivePopup::None));
|
||
|
||
let connectors = vec![AppInfo {
|
||
id: "connector_1".to_string(),
|
||
name: "Notion".to_string(),
|
||
description: Some("Workspace docs".to_string()),
|
||
logo_url: None,
|
||
logo_url_dark: None,
|
||
distribution_channel: None,
|
||
branding: None,
|
||
app_metadata: None,
|
||
labels: None,
|
||
install_url: Some("https://example.test/notion".to_string()),
|
||
is_accessible: true,
|
||
is_enabled: true,
|
||
plugin_display_names: Vec::new(),
|
||
}];
|
||
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
|
||
|
||
let ActivePopup::Skill(popup) = &composer.active_popup 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.active_popup, ActivePopup::None));
|
||
|
||
let connectors = vec![AppInfo {
|
||
id: "connector_1".to_string(),
|
||
name: "Notion".to_string(),
|
||
description: Some("Workspace docs".to_string()),
|
||
logo_url: None,
|
||
logo_url_dark: None,
|
||
distribution_channel: None,
|
||
branding: None,
|
||
app_metadata: None,
|
||
labels: None,
|
||
install_url: Some("https://example.test/notion".to_string()),
|
||
is_accessible: true,
|
||
is_enabled: false,
|
||
plugin_display_names: Vec::new(),
|
||
}];
|
||
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
|
||
|
||
assert!(
|
||
matches!(composer.active_popup, 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.active_popup, ActivePopup::None));
|
||
|
||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||
config_name: "sample@test".to_string(),
|
||
display_name: "Sample Plugin".to_string(),
|
||
description: None,
|
||
has_skills: true,
|
||
mcp_server_names: vec!["sample".to_string()],
|
||
app_connector_ids: Vec::new(),
|
||
}]));
|
||
|
||
let ActivePopup::Skill(popup) = &composer.active_popup else {
|
||
panic!("expected mention popup to open after plugin update");
|
||
};
|
||
let mention = popup
|
||
.selected_mention()
|
||
.expect("expected plugin mention to be selected");
|
||
assert_eq!(mention.insert_text, "$sample".to_string());
|
||
assert_eq!(mention.path, Some("plugin://sample@test".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn set_skill_mentions_refreshes_open_mention_popup() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
|
||
assert!(matches!(composer.active_popup, ActivePopup::None));
|
||
|
||
let skill_path = test_path_buf("/tmp/skill/SKILL.md").abs();
|
||
composer.set_skill_mentions(Some(vec![SkillMetadata {
|
||
name: "codex".to_string(),
|
||
description: "Primary personal Codex repo skill.".to_string(),
|
||
short_description: None,
|
||
interface: None,
|
||
dependencies: None,
|
||
policy: None,
|
||
path_to_skills_md: skill_path.clone(),
|
||
scope: codex_protocol::protocol::SkillScope::User,
|
||
}]));
|
||
|
||
let ActivePopup::Skill(popup) = &composer.active_popup else {
|
||
panic!("expected mention popup to open after skills update");
|
||
};
|
||
let mention = popup
|
||
.selected_mention()
|
||
.expect("expected skill mention to be selected");
|
||
assert_eq!(mention.insert_text, "$codex".to_string());
|
||
assert_eq!(mention.path, Some(skill_path.display().to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn mention_items_show_plugin_owned_skill_and_app_duplicates() {
|
||
let skill_path = test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs();
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_connectors_enabled(/*enabled*/ true);
|
||
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
|
||
composer.set_skill_mentions(Some(vec![SkillMetadata {
|
||
name: "google-calendar:availability".to_string(),
|
||
description: "Find availability and plan event changes".to_string(),
|
||
short_description: None,
|
||
interface: Some(SkillInterface {
|
||
display_name: Some("Google Calendar".to_string()),
|
||
short_description: None,
|
||
icon_small: None,
|
||
icon_large: None,
|
||
brand_color: None,
|
||
default_prompt: None,
|
||
}),
|
||
dependencies: None,
|
||
policy: None,
|
||
path_to_skills_md: skill_path.clone(),
|
||
scope: codex_protocol::protocol::SkillScope::Repo,
|
||
}]));
|
||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||
config_name: "google-calendar@debug".to_string(),
|
||
display_name: "Google Calendar".to_string(),
|
||
description: Some(
|
||
"Connect Google Calendar for scheduling, availability, and event management."
|
||
.to_string(),
|
||
),
|
||
has_skills: true,
|
||
mcp_server_names: vec!["google-calendar".to_string()],
|
||
app_connector_ids: vec![AppConnectorId("google_calendar".to_string())],
|
||
}]));
|
||
composer.set_connector_mentions(Some(ConnectorsSnapshot {
|
||
connectors: vec![AppInfo {
|
||
id: "google_calendar".to_string(),
|
||
name: "Google Calendar".to_string(),
|
||
description: Some("Look up events and availability".to_string()),
|
||
logo_url: None,
|
||
logo_url_dark: None,
|
||
distribution_channel: None,
|
||
branding: None,
|
||
app_metadata: None,
|
||
labels: None,
|
||
install_url: Some("https://example.test/google-calendar".to_string()),
|
||
is_accessible: true,
|
||
is_enabled: true,
|
||
plugin_display_names: vec!["Google Calendar".to_string()],
|
||
}],
|
||
}));
|
||
|
||
let mentions = composer.mention_items();
|
||
assert_eq!(mentions.len(), 3);
|
||
assert_eq!(mentions[0].category_tag, Some("[Skill]".to_string()));
|
||
assert_eq!(mentions[0].path, Some(skill_path.display().to_string()));
|
||
assert_eq!(mentions[0].display_name, "Google Calendar".to_string());
|
||
assert_eq!(mentions[1].category_tag, Some("[Plugin]".to_string()));
|
||
assert_eq!(
|
||
mentions[1].path,
|
||
Some("plugin://google-calendar@debug".to_string())
|
||
);
|
||
assert_eq!(mentions[2].category_tag, Some("[App]".to_string()));
|
||
assert_eq!(mentions[2].path, Some("app://google_calendar".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn plugin_mention_popup_snapshot() {
|
||
snapshot_composer_state(
|
||
"plugin_mention_popup",
|
||
/*enhanced_keys_supported*/ false,
|
||
|composer| {
|
||
composer.set_text_content("$sa".to_string(), Vec::new(), Vec::new());
|
||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||
config_name: "sample@test".to_string(),
|
||
display_name: "Sample Plugin".to_string(),
|
||
description: Some(
|
||
"Plugin that includes the Figma MCP server and Skills for common workflows"
|
||
.to_string(),
|
||
),
|
||
has_skills: true,
|
||
mcp_server_names: vec!["sample".to_string()],
|
||
app_connector_ids: vec![AppConnectorId("calendar".to_string())],
|
||
}]));
|
||
},
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mention_popup_type_prefixes_snapshot() {
|
||
snapshot_composer_state_with_width(
|
||
"mention_popup_type_prefixes",
|
||
/*width*/ 72,
|
||
/*enhanced_keys_supported*/ false,
|
||
|composer| {
|
||
composer.set_connectors_enabled(/*enabled*/ true);
|
||
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
|
||
composer.set_skill_mentions(Some(vec![SkillMetadata {
|
||
name: "google-calendar-skill".to_string(),
|
||
description: "Find availability and plan event changes".to_string(),
|
||
short_description: None,
|
||
interface: Some(SkillInterface {
|
||
display_name: Some("Google Calendar".to_string()),
|
||
short_description: None,
|
||
icon_small: None,
|
||
icon_large: None,
|
||
brand_color: None,
|
||
default_prompt: None,
|
||
}),
|
||
dependencies: None,
|
||
policy: None,
|
||
path_to_skills_md: test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs(),
|
||
scope: codex_protocol::protocol::SkillScope::Repo,
|
||
}]));
|
||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||
config_name: "google-calendar@debug".to_string(),
|
||
display_name: "Google Calendar".to_string(),
|
||
description: Some(
|
||
"Connect Google Calendar for scheduling, availability, and event management."
|
||
.to_string(),
|
||
),
|
||
has_skills: false,
|
||
mcp_server_names: vec!["google-calendar".to_string()],
|
||
app_connector_ids: Vec::new(),
|
||
}]));
|
||
composer.set_connector_mentions(Some(ConnectorsSnapshot {
|
||
connectors: vec![AppInfo {
|
||
id: "google_calendar".to_string(),
|
||
name: "Google Calendar".to_string(),
|
||
description: Some("Look up events and availability".to_string()),
|
||
logo_url: None,
|
||
logo_url_dark: None,
|
||
distribution_channel: None,
|
||
branding: None,
|
||
app_metadata: None,
|
||
labels: None,
|
||
install_url: Some("https://example.test/google-calendar".to_string()),
|
||
is_accessible: true,
|
||
is_enabled: true,
|
||
plugin_display_names: Vec::new(),
|
||
}],
|
||
}));
|
||
},
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_connectors_enabled(/*enabled*/ true);
|
||
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
|
||
|
||
let connectors = vec![AppInfo {
|
||
id: "connector_1".to_string(),
|
||
name: "Notion".to_string(),
|
||
description: Some("Workspace docs".to_string()),
|
||
logo_url: None,
|
||
logo_url_dark: None,
|
||
distribution_channel: None,
|
||
branding: None,
|
||
app_metadata: None,
|
||
labels: None,
|
||
install_url: Some("https://example.test/notion".to_string()),
|
||
is_accessible: true,
|
||
is_enabled: false,
|
||
plugin_display_names: Vec::new(),
|
||
}];
|
||
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
|
||
|
||
assert!(matches!(composer.active_popup, ActivePopup::None));
|
||
}
|
||
|
||
#[test]
|
||
fn shortcut_overlay_persists_while_task_running() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
|
||
|
||
composer.set_task_running(/*running*/ true);
|
||
|
||
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
|
||
assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay);
|
||
}
|
||
|
||
#[test]
|
||
fn test_current_at_token_basic_cases() {
|
||
let test_cases = vec![
|
||
// Valid @ tokens
|
||
("@hello", 3, Some("hello".to_string()), "Basic ASCII token"),
|
||
(
|
||
"@file.txt",
|
||
4,
|
||
Some("file.txt".to_string()),
|
||
"ASCII with extension",
|
||
),
|
||
(
|
||
"hello @world test",
|
||
8,
|
||
Some("world".to_string()),
|
||
"ASCII token in middle",
|
||
),
|
||
(
|
||
"@test123",
|
||
5,
|
||
Some("test123".to_string()),
|
||
"ASCII with numbers",
|
||
),
|
||
// Unicode examples
|
||
("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"),
|
||
(
|
||
"@testЙЦУ.rs",
|
||
8,
|
||
Some("testЙЦУ.rs".to_string()),
|
||
"Mixed ASCII and Cyrillic",
|
||
),
|
||
("@诶", 2, Some("诶".to_string()), "Chinese character"),
|
||
("@👍", 2, Some("👍".to_string()), "Emoji token"),
|
||
// Invalid cases (should return None)
|
||
("hello", 2, None, "No @ symbol"),
|
||
(
|
||
"@",
|
||
1,
|
||
Some("".to_string()),
|
||
"Only @ symbol triggers empty query",
|
||
),
|
||
("@ hello", 2, None, "@ followed by space"),
|
||
("test @ world", 6, None, "@ with spaces around"),
|
||
];
|
||
|
||
for (input, cursor_pos, expected, description) in test_cases {
|
||
let mut textarea = TextArea::new();
|
||
textarea.insert_str(input);
|
||
textarea.set_cursor(cursor_pos);
|
||
|
||
let result = ChatComposer::current_at_token(&textarea);
|
||
assert_eq!(
|
||
result, expected,
|
||
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_current_at_token_cursor_positions() {
|
||
let test_cases = vec![
|
||
// Different cursor positions within a token
|
||
("@test", 0, Some("test".to_string()), "Cursor at @"),
|
||
("@test", 1, Some("test".to_string()), "Cursor after @"),
|
||
("@test", 5, Some("test".to_string()), "Cursor at end"),
|
||
// Multiple tokens - cursor determines which token
|
||
("@file1 @file2", 0, Some("file1".to_string()), "First token"),
|
||
(
|
||
"@file1 @file2",
|
||
8,
|
||
Some("file2".to_string()),
|
||
"Second token",
|
||
),
|
||
// Edge cases
|
||
("@", 0, Some("".to_string()), "Only @ symbol"),
|
||
("@a", 2, Some("a".to_string()), "Single character after @"),
|
||
("", 0, None, "Empty input"),
|
||
];
|
||
|
||
for (input, cursor_pos, expected, description) in test_cases {
|
||
let mut textarea = TextArea::new();
|
||
textarea.insert_str(input);
|
||
textarea.set_cursor(cursor_pos);
|
||
|
||
let result = ChatComposer::current_at_token(&textarea);
|
||
assert_eq!(
|
||
result, expected,
|
||
"Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_current_at_token_whitespace_boundaries() {
|
||
let test_cases = vec![
|
||
// Space boundaries
|
||
(
|
||
"aaa@aaa",
|
||
4,
|
||
None,
|
||
"Connected @ token - no completion by design",
|
||
),
|
||
(
|
||
"aaa @aaa",
|
||
5,
|
||
Some("aaa".to_string()),
|
||
"@ token after space",
|
||
),
|
||
(
|
||
"test @file.txt",
|
||
7,
|
||
Some("file.txt".to_string()),
|
||
"@ token after space",
|
||
),
|
||
// Full-width space boundaries
|
||
(
|
||
"test @İstanbul",
|
||
8,
|
||
Some("İstanbul".to_string()),
|
||
"@ token after full-width space",
|
||
),
|
||
(
|
||
"@ЙЦУ @诶",
|
||
10,
|
||
Some("诶".to_string()),
|
||
"Full-width space between Unicode tokens",
|
||
),
|
||
// Tab and newline boundaries
|
||
(
|
||
"test\t@file",
|
||
6,
|
||
Some("file".to_string()),
|
||
"@ token after tab",
|
||
),
|
||
];
|
||
|
||
for (input, cursor_pos, expected, description) in test_cases {
|
||
let mut textarea = TextArea::new();
|
||
textarea.insert_str(input);
|
||
textarea.set_cursor(cursor_pos);
|
||
|
||
let result = ChatComposer::current_at_token(&textarea);
|
||
assert_eq!(
|
||
result, expected,
|
||
"Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_current_at_token_tracks_tokens_with_second_at() {
|
||
let input = "npx -y @kaeawc/auto-mobile@latest";
|
||
let token_start = input.find("@kaeawc").expect("scoped npm package present");
|
||
let version_at = input
|
||
.rfind("@latest")
|
||
.expect("version suffix present in scoped npm package");
|
||
let test_cases = vec![
|
||
(token_start, "Cursor at leading @"),
|
||
(token_start + 8, "Cursor inside scoped package name"),
|
||
(version_at, "Cursor at version @"),
|
||
(input.len(), "Cursor at end of token"),
|
||
];
|
||
|
||
for (cursor_pos, description) in test_cases {
|
||
let mut textarea = TextArea::new();
|
||
textarea.insert_str(input);
|
||
textarea.set_cursor(cursor_pos);
|
||
|
||
let result = ChatComposer::current_at_token(&textarea);
|
||
assert_eq!(
|
||
result,
|
||
Some("kaeawc/auto-mobile@latest".to_string()),
|
||
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_current_at_token_allows_file_queries_with_second_at() {
|
||
let input = "@icons/icon@2x.png";
|
||
let version_at = input
|
||
.rfind("@2x")
|
||
.expect("second @ in file token should be present");
|
||
let test_cases = vec![
|
||
(0, "Cursor at leading @"),
|
||
(8, "Cursor before second @"),
|
||
(version_at, "Cursor at second @"),
|
||
(input.len(), "Cursor at end of token"),
|
||
];
|
||
|
||
for (cursor_pos, description) in test_cases {
|
||
let mut textarea = TextArea::new();
|
||
textarea.insert_str(input);
|
||
textarea.set_cursor(cursor_pos);
|
||
|
||
let result = ChatComposer::current_at_token(&textarea);
|
||
assert!(
|
||
result.is_some(),
|
||
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_current_at_token_ignores_mid_word_at() {
|
||
let input = "foo@bar";
|
||
let at_pos = input.find('@').expect("@ present");
|
||
let test_cases = vec![
|
||
(at_pos, "Cursor at mid-word @"),
|
||
(input.len(), "Cursor at end of word containing @"),
|
||
];
|
||
|
||
for (cursor_pos, description) in test_cases {
|
||
let mut textarea = TextArea::new();
|
||
textarea.insert_str(input);
|
||
textarea.set_cursor(cursor_pos);
|
||
|
||
let result = ChatComposer::current_at_token(&textarea);
|
||
assert_eq!(
|
||
result, None,
|
||
"Failed for case: {description} - input: '{input}', cursor: {cursor_pos}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn enter_submits_when_file_popup_has_no_selection() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let input = "npx -y @kaeawc/auto-mobile@latest";
|
||
composer.textarea.insert_str(input);
|
||
composer.textarea.set_cursor(input.len());
|
||
composer.sync_popups();
|
||
|
||
assert!(matches!(composer.active_popup, ActivePopup::File(_)));
|
||
|
||
let (result, consumed) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(consumed);
|
||
match result {
|
||
InputResult::Submitted { text, .. } => assert_eq!(text, input),
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
}
|
||
|
||
/// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII
|
||
/// char arrives next, the pending ASCII char should still be preserved and the overall input
|
||
/// should submit normally (i.e. we should not misclassify this as a paste burst).
|
||
#[test]
|
||
fn ascii_prefix_survives_non_ascii_followup() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||
assert!(composer.is_in_paste_burst());
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
|
||
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"),
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
}
|
||
|
||
/// Behavior: a single non-ASCII char should be inserted immediately (IME-friendly) and should
|
||
/// not create any paste-burst state.
|
||
#[test]
|
||
fn non_ascii_char_inserts_immediately_without_burst_state() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
|
||
|
||
assert_eq!(composer.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
|
||
.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.textarea.text().is_empty());
|
||
let _ = flush_after_paste_burst(&mut composer);
|
||
assert_eq!(composer.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
|
||
.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.textarea.text().is_empty());
|
||
let _ = flush_after_paste_burst(&mut composer);
|
||
assert_eq!(composer.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
|
||
.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.textarea.text().is_empty());
|
||
let _ = flush_after_paste_burst(&mut composer);
|
||
assert_eq!(composer.textarea.text(), LARGE_MIXED_PAYLOAD);
|
||
}
|
||
|
||
/// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a
|
||
/// newline into the buffered payload and flush as a single paste later.
|
||
#[test]
|
||
fn ascii_burst_treats_enter_as_newline() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let mut now = Instant::now();
|
||
let step = Duration::from_millis(1);
|
||
|
||
let _ = composer.handle_input_basic_with_time(
|
||
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
|
||
now,
|
||
);
|
||
now += step;
|
||
let _ = composer.handle_input_basic_with_time(
|
||
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
|
||
now,
|
||
);
|
||
now += step;
|
||
|
||
let (result, _) = composer.handle_submission_with_time(/*should_queue*/ false, now);
|
||
assert!(
|
||
matches!(result, InputResult::None),
|
||
"Enter during a burst should insert newline, not submit"
|
||
);
|
||
|
||
for ch in ['t', 'h', 'e', 'r', 'e'] {
|
||
now += step;
|
||
let _ = composer.handle_input_basic_with_time(
|
||
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
||
now,
|
||
);
|
||
}
|
||
|
||
assert!(composer.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.textarea.text(), "hi\nthere");
|
||
}
|
||
|
||
/// Behavior: even if Enter suppression would normally be active for a burst, Enter should
|
||
/// still dispatch a built-in slash command when the first line begins with `/`.
|
||
#[test]
|
||
fn slash_context_enter_ignores_paste_burst_enter_suppression() {
|
||
use crate::slash_command::SlashCommand;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
composer.textarea.set_text_clearing_elements("/diff");
|
||
composer.textarea.set_cursor("/diff".len());
|
||
composer
|
||
.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
|
||
.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.textarea.text().is_empty());
|
||
assert!(composer.is_in_paste_burst());
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||
assert_eq!(composer.textarea.text(), "hi");
|
||
assert_eq!(composer.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.textarea.text().is_empty());
|
||
|
||
composer.set_disable_paste_burst(/*disabled*/ true);
|
||
assert_eq!(composer.textarea.text(), "a");
|
||
assert!(!composer.is_in_paste_burst());
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
|
||
assert_eq!(composer.textarea.text(), "ab");
|
||
assert!(!composer.is_in_paste_burst());
|
||
}
|
||
|
||
/// Behavior: a small explicit paste inserts text directly (no placeholder), and the submitted
|
||
/// text matches what is visible in the textarea.
|
||
#[test]
|
||
fn handle_paste_small_inserts_text() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let needs_redraw = composer.handle_paste("hello".to_string());
|
||
assert!(needs_redraw);
|
||
assert_eq!(composer.textarea.text(), "hello");
|
||
assert!(composer.pending_pastes.is_empty());
|
||
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted { text, .. } => assert_eq!(text, "hello"),
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn empty_enter_returns_none() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
// Ensure composer is empty and press Enter.
|
||
assert!(composer.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.textarea.text(), placeholder);
|
||
assert_eq!(composer.pending_pastes.len(), 1);
|
||
assert_eq!(composer.pending_pastes[0].0, placeholder);
|
||
assert_eq!(composer.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.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.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.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.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.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.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.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.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.textarea.set_cursor(composer.textarea.text().len());
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
}
|
||
|
||
terminal
|
||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
|
||
|
||
insta::assert_snapshot!(name, terminal.backend());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn image_placeholder_snapshots() {
|
||
snapshot_composer_state(
|
||
"image_placeholder_single",
|
||
/*enhanced_keys_supported*/ false,
|
||
|composer| {
|
||
composer.attach_image(PathBuf::from("/tmp/image1.png"));
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"image_placeholder_multiple",
|
||
/*enhanced_keys_supported*/ false,
|
||
|composer| {
|
||
composer.attach_image(PathBuf::from("/tmp/image1.png"));
|
||
composer.attach_image(PathBuf::from("/tmp/image2.png"));
|
||
},
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn remote_image_rows_snapshots() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
snapshot_composer_state(
|
||
"remote_image_rows",
|
||
/*enhanced_keys_supported*/ false,
|
||
|composer| {
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"remote_image_rows_selected",
|
||
/*enhanced_keys_supported*/ false,
|
||
|composer| {
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
|
||
composer.textarea.set_cursor(/*pos*/ 0);
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state(
|
||
"remote_image_rows_after_delete_first",
|
||
/*enhanced_keys_supported*/ false,
|
||
|composer| {
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
|
||
composer.textarea.set_cursor(/*pos*/ 0);
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
let _ =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||
},
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn slash_popup_model_first_for_mo_ui() {
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TestBackend;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
// Type "/mo" humanlike so paste-burst doesn’t interfere.
|
||
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
||
|
||
let mut terminal = match Terminal::new(TestBackend::new(60, 5)) {
|
||
Ok(t) => t,
|
||
Err(e) => panic!("Failed to create terminal: {e}"),
|
||
};
|
||
terminal
|
||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
|
||
|
||
// Visual snapshot should show the slash popup with /model as the first entry.
|
||
insta::assert_snapshot!("slash_popup_mo", terminal.backend());
|
||
}
|
||
|
||
#[test]
|
||
fn slash_popup_model_first_for_mo_logic() {
|
||
use super::super::command_popup::CommandItem;
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
||
|
||
match &composer.active_popup {
|
||
ActivePopup::Command(popup) => match popup.selected_item() {
|
||
Some(CommandItem::Builtin(cmd)) => {
|
||
assert_eq!(cmd.command(), "model")
|
||
}
|
||
None => panic!("no selected command for '/mo'"),
|
||
},
|
||
_ => panic!("slash popup not active after typing '/mo'"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn slash_popup_resume_for_res_ui() {
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TestBackend;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
// Type "/res" humanlike so paste-burst doesn’t interfere.
|
||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
|
||
|
||
let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal");
|
||
terminal
|
||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||
.expect("draw composer");
|
||
|
||
// Snapshot should show /resume as the first entry for /res.
|
||
insta::assert_snapshot!("slash_popup_res", terminal.backend());
|
||
}
|
||
|
||
#[test]
|
||
fn slash_popup_resume_for_res_logic() {
|
||
use super::super::command_popup::CommandItem;
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
|
||
|
||
match &composer.active_popup {
|
||
ActivePopup::Command(popup) => match popup.selected_item() {
|
||
Some(CommandItem::Builtin(cmd)) => {
|
||
assert_eq!(cmd.command(), "resume")
|
||
}
|
||
None => panic!("no selected command for '/res'"),
|
||
},
|
||
_ => panic!("slash popup not active after typing '/res'"),
|
||
}
|
||
}
|
||
|
||
fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool {
|
||
std::thread::sleep(PasteBurst::recommended_active_flush_delay());
|
||
composer.flush_paste_burst_if_due()
|
||
}
|
||
|
||
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
|
||
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use crossterm::event::KeyModifiers;
|
||
for &ch in chars {
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
|
||
let _ = composer.flush_paste_burst_if_due();
|
||
if ch == ' ' {
|
||
let _ = composer.handle_key_event(KeyEvent::new_with_kind(
|
||
KeyCode::Char(' '),
|
||
KeyModifiers::NONE,
|
||
KeyEventKind::Release,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
// Type the slash command.
|
||
type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']);
|
||
|
||
// Press Enter to dispatch the selected command.
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
// When a slash command is dispatched, the composer should return a
|
||
// Command result (not submit literal text) and clear its textarea.
|
||
match result {
|
||
InputResult::Command(cmd) => {
|
||
assert_eq!(cmd.command(), "init");
|
||
}
|
||
InputResult::CommandWithArgs(_, _, _) => {
|
||
panic!("expected command dispatch without args for '/init'")
|
||
}
|
||
InputResult::Submitted { text, .. } => {
|
||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||
}
|
||
InputResult::Queued { .. } => {
|
||
panic!("expected command dispatch, but composer queued literal text")
|
||
}
|
||
InputResult::None => panic!("expected Command result for '/init'"),
|
||
}
|
||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||
}
|
||
|
||
#[test]
|
||
fn kill_buffer_persists_after_submit() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_steer_enabled(true);
|
||
composer.textarea.insert_str("restore me");
|
||
composer.textarea.set_cursor(/*pos*/ 0);
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||
assert!(composer.textarea.is_empty());
|
||
|
||
composer.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.textarea.is_empty());
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
|
||
assert_eq!(composer.textarea.text(), "restore me");
|
||
}
|
||
|
||
#[test]
|
||
fn kill_buffer_persists_after_slash_command_dispatch() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.textarea.insert_str("restore me");
|
||
composer.textarea.set_cursor(/*pos*/ 0);
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||
assert!(composer.textarea.is_empty());
|
||
|
||
composer.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.textarea.is_empty());
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
|
||
assert_eq!(composer.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
|
||
.textarea
|
||
.set_text_clearing_elements("/review these changes");
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_eq!(InputResult::None, result);
|
||
assert_eq!("/review these changes", composer.textarea.text());
|
||
|
||
let mut found_error = false;
|
||
while let Ok(event) = rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
let message = cell
|
||
.display_lines(/*width*/ 80)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(message.contains("disabled while a task is in progress"));
|
||
found_error = true;
|
||
break;
|
||
}
|
||
}
|
||
assert!(found_error, "expected error history cell to be sent");
|
||
}
|
||
|
||
#[test]
|
||
fn tab_queues_slash_led_prompts_while_task_running_without_validation() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
fn assert_queued_slash(input: &str) {
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_task_running(/*running*/ true);
|
||
composer.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.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 on");
|
||
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
|
||
.textarea
|
||
.set_text_clearing_elements("explain the change");
|
||
composer.textarea.set_cursor(composer.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.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.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.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
|
||
.textarea
|
||
.set_text_clearing_elements(" /does-not-exist");
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
|
||
match result {
|
||
InputResult::Queued { text, action, .. } => {
|
||
assert_eq!(text, "/does-not-exist");
|
||
assert_eq!(action, QueuedInputAction::Plain);
|
||
}
|
||
other => panic!("expected leading-space slash input to queue, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tab_queues_bang_shell_prompts_while_task_running_without_execution() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
fn assert_queued_shell(input: &str, expected_text: &str) {
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
composer.set_task_running(/*running*/ true);
|
||
composer.textarea.set_text_clearing_elements(input);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
|
||
match result {
|
||
InputResult::Queued {
|
||
text,
|
||
text_elements,
|
||
action,
|
||
} => {
|
||
assert_eq!(text, expected_text);
|
||
assert!(text_elements.is_empty());
|
||
assert_eq!(action, QueuedInputAction::RunShell);
|
||
}
|
||
other => panic!("expected bang shell input to queue, got {other:?}"),
|
||
}
|
||
assert!(composer.textarea.is_empty());
|
||
assert!(
|
||
rx.try_recv().is_err(),
|
||
"queueing should not show shell help immediately"
|
||
);
|
||
}
|
||
|
||
assert_queued_shell("!echo hi", "!echo hi");
|
||
assert_queued_shell("!", "!");
|
||
assert_queued_shell(" !echo hi", "!echo hi");
|
||
}
|
||
|
||
#[test]
|
||
fn slash_tab_completion_moves_cursor_to_end() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['/', 'c']);
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
|
||
assert_eq!(composer.textarea.text(), "/compact ");
|
||
assert_eq!(composer.textarea.cursor(), composer.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.textarea.text(), "/model ");
|
||
assert_eq!(composer.textarea.cursor(), composer.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.textarea.text(), "/model ");
|
||
assert_eq!(composer.textarea.cursor(), composer.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.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::Submitted { text, .. } => {
|
||
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
|
||
}
|
||
InputResult::Queued { .. } => {
|
||
panic!("expected command dispatch after Tab completion, got literal queue")
|
||
}
|
||
InputResult::None => panic!("expected Command result for '/diff'"),
|
||
}
|
||
assert!(composer.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.textarea.text().to_string();
|
||
let elements = composer.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.textarea.text().to_string();
|
||
let elements = composer.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.textarea.text().to_string();
|
||
let elements = composer.textarea.text_elements();
|
||
assert_eq!(text, "/review ");
|
||
assert_eq!(elements.len(), 1);
|
||
|
||
composer.textarea.set_cursor(/*pos*/ 0);
|
||
type_chars_humanlike(&mut composer, &['x']);
|
||
|
||
let text = composer.textarea.text().to_string();
|
||
let elements = composer.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.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::Submitted { text, .. } => {
|
||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||
}
|
||
InputResult::Queued { .. } => {
|
||
panic!("expected command dispatch, but composer queued literal text")
|
||
}
|
||
InputResult::None => panic!("expected Command result for '/mention'"),
|
||
}
|
||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||
composer.insert_str("@");
|
||
assert_eq!(composer.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.textarea.text().to_string();
|
||
assert_eq!(text, format!("{placeholder} src/main.rs "));
|
||
let elements = composer.textarea.text_elements();
|
||
assert_eq!(elements.len(), 1);
|
||
assert_eq!(elements[0].placeholder(&text), Some(placeholder.as_str()));
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
assert_eq!(text, format!("{large} src/main.rs"));
|
||
assert!(text_elements.is_empty());
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
}
|
||
|
||
/// Behavior: multiple paste operations can coexist; placeholders should be expanded to their
|
||
/// original content on submission.
|
||
#[test]
|
||
fn test_multiple_pastes_submission() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
// Define test cases: (paste content, is_large)
|
||
let test_cases = [
|
||
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
|
||
(" and ".to_string(), false),
|
||
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
|
||
];
|
||
|
||
// Expected states after each paste
|
||
let mut expected_text = String::new();
|
||
let mut expected_pending_count = 0;
|
||
|
||
// Apply all pastes and build expected state
|
||
let states: Vec<_> = test_cases
|
||
.iter()
|
||
.map(|(content, is_large)| {
|
||
composer.handle_paste(content.clone());
|
||
if *is_large {
|
||
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
|
||
expected_text.push_str(&placeholder);
|
||
expected_pending_count += 1;
|
||
} else {
|
||
expected_text.push_str(content);
|
||
}
|
||
(expected_text.clone(), expected_pending_count)
|
||
})
|
||
.collect();
|
||
|
||
// Verify all intermediate states were correct
|
||
assert_eq!(
|
||
states,
|
||
vec![
|
||
(
|
||
format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()),
|
||
1
|
||
),
|
||
(
|
||
format!(
|
||
"[Pasted Content {} chars] and ",
|
||
test_cases[0].0.chars().count()
|
||
),
|
||
1
|
||
),
|
||
(
|
||
format!(
|
||
"[Pasted Content {} chars] and [Pasted Content {} chars]",
|
||
test_cases[0].0.chars().count(),
|
||
test_cases[2].0.chars().count()
|
||
),
|
||
2
|
||
),
|
||
]
|
||
);
|
||
|
||
// Submit and verify final expansion
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
if let InputResult::Submitted { text, .. } = result {
|
||
assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
|
||
} else {
|
||
panic!("expected Submitted");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_placeholder_deletion() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
// Define test cases: (content, is_large)
|
||
let test_cases = [
|
||
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
|
||
(" and ".to_string(), false),
|
||
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
|
||
];
|
||
|
||
// Apply all pastes
|
||
let mut current_pos = 0;
|
||
let states: Vec<_> = test_cases
|
||
.iter()
|
||
.map(|(content, is_large)| {
|
||
composer.handle_paste(content.clone());
|
||
if *is_large {
|
||
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
|
||
current_pos += placeholder.len();
|
||
} else {
|
||
current_pos += content.len();
|
||
}
|
||
(
|
||
composer.textarea.text().to_string(),
|
||
composer.pending_pastes.len(),
|
||
current_pos,
|
||
)
|
||
})
|
||
.collect();
|
||
|
||
// Delete placeholders one by one and collect states
|
||
let mut deletion_states = vec![];
|
||
|
||
// First deletion
|
||
composer.textarea.set_cursor(states[0].2);
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
deletion_states.push((
|
||
composer.textarea.text().to_string(),
|
||
composer.pending_pastes.len(),
|
||
));
|
||
|
||
// Second deletion
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
deletion_states.push((
|
||
composer.textarea.text().to_string(),
|
||
composer.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.textarea.text(),
|
||
format!("{placeholder_base}{placeholder_second}")
|
||
);
|
||
assert_eq!(composer.pending_pastes.len(), 2);
|
||
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
|
||
assert_eq!(composer.textarea.text(), placeholder_base);
|
||
assert_eq!(composer.pending_pastes.len(), 1);
|
||
assert_eq!(composer.pending_pastes[0].0, placeholder_base);
|
||
assert_eq!(composer.pending_pastes[0].1, paste);
|
||
}
|
||
|
||
/// Behavior: large-paste placeholder numbering does not get reused after deletion, so a new
|
||
/// paste of the same length gets a new unique placeholder label.
|
||
#[test]
|
||
fn large_paste_numbering_does_not_reuse_after_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,
|
||
);
|
||
|
||
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.textarea.text(), format!("{base}{second}"));
|
||
|
||
composer.textarea.set_cursor(base.len());
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
assert_eq!(composer.textarea.text(), second);
|
||
assert_eq!(composer.pending_pastes.len(), 1);
|
||
assert_eq!(composer.pending_pastes[0].0, second);
|
||
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
composer.handle_paste(paste);
|
||
|
||
assert_eq!(composer.textarea.text(), format!("{second}{third}"));
|
||
assert_eq!(composer.pending_pastes.len(), 2);
|
||
assert_eq!(composer.pending_pastes[0].0, second);
|
||
assert_eq!(composer.pending_pastes[1].0, third);
|
||
}
|
||
|
||
#[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
|
||
.textarea
|
||
.set_cursor(placeholder.len() - pos_from_end);
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
let result = (
|
||
composer.textarea.text().contains(&placeholder),
|
||
composer.pending_pastes.len(),
|
||
);
|
||
composer.textarea.set_text_clearing_elements("");
|
||
result
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(
|
||
states,
|
||
vec![
|
||
(false, 0), // After deleting from middle
|
||
(false, 0), // After deleting from end
|
||
]
|
||
);
|
||
}
|
||
|
||
// --- Image attachment tests ---
|
||
#[test]
|
||
fn attach_image_and_submit_includes_local_image_paths() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
let path = PathBuf::from("/tmp/image1.png");
|
||
composer.attach_image(path.clone());
|
||
composer.handle_paste(" hi".into());
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
assert_eq!(text, "[Image #1] hi");
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||
assert_eq!(
|
||
text_elements[0].byte_range,
|
||
ByteRange {
|
||
start: 0,
|
||
end: "[Image #1]".len()
|
||
}
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
let imgs = composer.take_recent_submission_images();
|
||
assert_eq!(vec![path], imgs);
|
||
}
|
||
|
||
#[test]
|
||
fn submit_captures_recent_mention_bindings_before_clearing_textarea() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let mention_bindings = vec![MentionBinding {
|
||
mention: "figma".to_string(),
|
||
path: "/tmp/user/figma/SKILL.md".to_string(),
|
||
}];
|
||
composer.set_text_content_with_mention_bindings(
|
||
"$figma please".to_string(),
|
||
Vec::new(),
|
||
Vec::new(),
|
||
mention_bindings.clone(),
|
||
);
|
||
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||
assert_eq!(
|
||
composer.take_recent_submission_mention_bindings(),
|
||
mention_bindings
|
||
);
|
||
assert!(composer.take_mention_bindings().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn history_navigation_restores_remote_and_local_image_attachments() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
let remote_image_url = "https://example.com/remote.png".to_string();
|
||
composer.set_remote_image_urls(vec![remote_image_url.clone()]);
|
||
let path = PathBuf::from("/tmp/image1.png");
|
||
composer.attach_image(path.clone());
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||
|
||
let _ = composer.take_remote_image_urls();
|
||
composer.set_text_content(String::new(), Vec::new(), Vec::new());
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
|
||
let text = composer.current_text();
|
||
assert_eq!(text, "[Image #2]");
|
||
let text_elements = composer.text_elements();
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]"));
|
||
assert_eq!(composer.local_image_paths(), vec![path]);
|
||
assert_eq!(composer.remote_image_urls(), vec![remote_image_url]);
|
||
}
|
||
|
||
#[test]
|
||
fn history_navigation_restores_remote_only_submissions() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
let remote_image_urls = vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
];
|
||
composer.set_remote_image_urls(remote_image_urls.clone());
|
||
|
||
let (submitted_text, submitted_elements) = composer
|
||
.prepare_submission_text(/*record_history*/ true)
|
||
.expect("remote-only submission should be prepared");
|
||
assert_eq!(submitted_text, "");
|
||
assert!(submitted_elements.is_empty());
|
||
|
||
let _ = composer.take_remote_image_urls();
|
||
composer.set_text_content(String::new(), Vec::new(), Vec::new());
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
assert_eq!(composer.current_text(), "");
|
||
assert!(composer.text_elements().is_empty());
|
||
assert_eq!(composer.remote_image_urls(), remote_image_urls);
|
||
}
|
||
|
||
#[test]
|
||
fn history_navigation_leaves_cursor_at_end_of_line() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||
|
||
type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']);
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
assert_eq!(composer.textarea.text(), "second");
|
||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
assert_eq!(composer.textarea.text(), "first");
|
||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
assert_eq!(composer.textarea.text(), "second");
|
||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
assert!(composer.textarea.is_empty());
|
||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||
}
|
||
|
||
#[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.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 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.textarea.set_text_clearing_elements("/unknown ");
|
||
composer.textarea.set_cursor("/unknown ".len());
|
||
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
|
||
composer.handle_paste(large_content.clone());
|
||
let placeholder = composer
|
||
.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.pending_pastes.len(), 1);
|
||
assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}"));
|
||
|
||
composer.textarea.set_cursor(/*pos*/ 0);
|
||
composer.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.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.attached_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.textarea.text().to_string();
|
||
assert!(text.contains("[Image #1]"));
|
||
assert!(text.contains("[Image #2]"));
|
||
assert_eq!(composer.attached_images[0].placeholder, "[Image #1]");
|
||
assert_eq!(composer.attached_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.attached_images[0].placeholder.clone();
|
||
|
||
// Case 1: backspace at end
|
||
composer
|
||
.textarea
|
||
.move_cursor_to_end_of_line(/*move_down_at_eol*/ false);
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
assert!(!composer.textarea.text().contains(&placeholder));
|
||
assert!(composer.attached_images.is_empty());
|
||
|
||
// Re-add and ensure backspace at element start does not delete the placeholder.
|
||
composer.attach_image(path);
|
||
let placeholder2 = composer.attached_images[0].placeholder.clone();
|
||
// Move cursor to roughly middle of placeholder
|
||
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
|
||
let mid_pos = start_pos + (placeholder2.len() / 2);
|
||
composer.textarea.set_cursor(mid_pos);
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
assert!(composer.textarea.text().contains(&placeholder2));
|
||
assert_eq!(composer.attached_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.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.attached_images.len(), 1);
|
||
assert!(composer.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.attached_images[0].placeholder.clone();
|
||
let placeholder2 = composer.attached_images[1].placeholder.clone();
|
||
let text = composer.textarea.text().to_string();
|
||
let start1 = text.find(&placeholder1).expect("first placeholder present");
|
||
let end1 = start1 + placeholder1.len();
|
||
composer.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.textarea.text().to_string();
|
||
assert_eq!(
|
||
1,
|
||
new_text.matches(&placeholder1).count(),
|
||
"one placeholder remains after deletion"
|
||
);
|
||
assert_eq!(
|
||
0,
|
||
new_text.matches(&placeholder2).count(),
|
||
"second placeholder was relabeled"
|
||
);
|
||
assert_eq!(
|
||
1,
|
||
new_text.matches("[Image #1]").count(),
|
||
"remaining placeholder relabeled to #1"
|
||
);
|
||
assert_eq!(
|
||
vec![AttachedImage {
|
||
path: path2,
|
||
placeholder: "[Image #1]".to_string()
|
||
}],
|
||
composer.attached_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.textarea.set_cursor(end1);
|
||
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||
|
||
assert_eq!(
|
||
composer.textarea.text(),
|
||
format!("Test {placeholder1} test ")
|
||
);
|
||
assert_eq!(
|
||
vec![AttachedImage {
|
||
path: path2,
|
||
placeholder: placeholder1
|
||
}],
|
||
composer.attached_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.textarea.text(), "[Image #1][Image #2]");
|
||
assert_eq!(composer.attached_images.len(), 2);
|
||
|
||
// Delete the first element using normal textarea editing (forward Delete at cursor start).
|
||
composer.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.attached_images.len(), 1);
|
||
assert_eq!(composer.attached_images[0].path, path2);
|
||
assert_eq!(composer.attached_images[0].placeholder, "[Image #1]");
|
||
assert_eq!(composer.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.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
|
||
.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.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
|
||
.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.textarea.is_empty());
|
||
match rx.try_recv() {
|
||
Ok(event) => panic!("unexpected event: {event:?}"),
|
||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||
Err(err) => panic!("unexpected channel state: {err:?}"),
|
||
}
|
||
}
|
||
|
||
/// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst
|
||
/// follows, it should eventually flush as normal typed input (not as a paste).
|
||
#[test]
|
||
fn pending_first_ascii_char_flushes_as_typed() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
|
||
assert!(composer.is_in_paste_burst());
|
||
assert!(composer.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.textarea.text(), "h");
|
||
assert!(!composer.is_in_paste_burst());
|
||
}
|
||
|
||
/// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If
|
||
/// the payload is small, it should insert directly (no placeholder).
|
||
#[test]
|
||
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let count = 32;
|
||
let mut now = Instant::now();
|
||
let step = Duration::from_millis(1);
|
||
for _ in 0..count {
|
||
let _ = composer.handle_input_basic_with_time(
|
||
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
|
||
now,
|
||
);
|
||
assert!(
|
||
composer.is_in_paste_burst(),
|
||
"expected active paste burst during fast typing"
|
||
);
|
||
assert!(
|
||
composer.textarea.text().is_empty(),
|
||
"text should not appear during burst"
|
||
);
|
||
now += step;
|
||
}
|
||
|
||
assert!(
|
||
composer.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.textarea.text(), "a".repeat(count));
|
||
assert!(
|
||
composer.pending_pastes.is_empty(),
|
||
"no placeholder for small burst"
|
||
);
|
||
}
|
||
|
||
/// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If
|
||
/// the payload is large, it should insert a placeholder and defer the full text until submit.
|
||
#[test]
|
||
fn burst_paste_fast_large_inserts_placeholder_on_flush() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder
|
||
let mut now = Instant::now();
|
||
let step = Duration::from_millis(1);
|
||
for _ in 0..count {
|
||
let _ = composer.handle_input_basic_with_time(
|
||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
|
||
now,
|
||
);
|
||
now += step;
|
||
}
|
||
|
||
// Nothing should appear until we stop and flush
|
||
assert!(composer.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.textarea.text(), expected_placeholder);
|
||
assert_eq!(composer.pending_pastes.len(), 1);
|
||
assert_eq!(composer.pending_pastes[0].0, expected_placeholder);
|
||
assert_eq!(composer.pending_pastes[0].1.len(), count);
|
||
assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x'));
|
||
}
|
||
|
||
/// Behavior: human-like typing (with delays between chars) should not be classified as a paste
|
||
/// burst. Characters should appear immediately and should not trigger a paste placeholder.
|
||
#[test]
|
||
fn humanlike_typing_1000_chars_appears_live_no_placeholder() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
/*has_input_focus*/ true,
|
||
sender,
|
||
/*enhanced_keys_supported*/ false,
|
||
"Ask Codex to do anything".to_string(),
|
||
/*disable_paste_burst*/ false,
|
||
);
|
||
|
||
let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config
|
||
let chars: Vec<char> = vec!['z'; count];
|
||
type_chars_humanlike(&mut composer, &chars);
|
||
|
||
assert_eq!(composer.textarea.text(), "z".repeat(count));
|
||
assert!(composer.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.active_popup, 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.active_popup, 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.active_popup, 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.active_popup, 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.active_popup, 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.active_popup = 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.textarea.insert_element(&placeholder);
|
||
composer.attached_images.push(AttachedImage {
|
||
placeholder: placeholder.clone(),
|
||
path: PathBuf::from("img.png"),
|
||
});
|
||
composer
|
||
.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.pending_pastes.is_empty());
|
||
assert_eq!(composer.attached_images.len(), 1);
|
||
assert_eq!(composer.attached_images[0].placeholder, placeholder);
|
||
assert_eq!(composer.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.is_bash_mode);
|
||
assert_eq!(composer.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.is_bash_mode);
|
||
assert_eq!(composer.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.is_bash_mode);
|
||
assert_eq!(composer.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.textarea.insert_element(&placeholder);
|
||
composer.attached_images.push(AttachedImage {
|
||
placeholder: placeholder.clone(),
|
||
path: PathBuf::from("img.png"),
|
||
});
|
||
|
||
composer.apply_external_edit("No images here".to_string());
|
||
|
||
assert_eq!(composer.current_text(), "No images here".to_string());
|
||
assert!(composer.attached_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.attached_images.len(), 1);
|
||
assert_eq!(composer.attached_images[0].placeholder, placeholder1);
|
||
assert_eq!(composer.local_image_paths(), vec![second_path]);
|
||
assert_eq!(composer.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.textarea.insert_element(&placeholder);
|
||
composer
|
||
.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 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.textarea.insert_element(&placeholder);
|
||
composer.attached_images.push(AttachedImage {
|
||
placeholder: placeholder.clone(),
|
||
path: PathBuf::from("img.png"),
|
||
});
|
||
|
||
composer.apply_external_edit(format!("{placeholder} extra {placeholder}"));
|
||
|
||
assert_eq!(
|
||
composer.current_text(),
|
||
format!("{placeholder} extra {placeholder}")
|
||
);
|
||
assert_eq!(composer.attached_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.attached_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.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.attached_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.attached_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);
|
||
}
|
||
}
|