mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
## Problem
The app-server TUI (`tui_app_server`) lacked composer history support.
Pressing Up/Down to recall previous prompts hit a stub that logged a
warning and displayed "Not available in app-server TUI yet." New
submissions were silently dropped from the shared history file, so
nothing persisted for future sessions.
## Mental model
Codex maintains a single, append-only history file
(`$CODEX_HOME/history.jsonl`) shared across all TUI processes on the
same machine. The legacy (in-process) TUI already reads/writes this file
through `codex_core::message_history`. The app-server TUI delegates most
operations to a separate process over RPC, but history is intentionally
*not* an RPC concern — it's a client-local file.
This PR makes the app-server TUI access the same history file directly,
bypassing the app-server process entirely. The composer's Up/Down
navigation and submit-time persistence now follow the same code paths as
the legacy TUI, with the only difference being *where* the call is
dispatched (locally in `App`, rather than inside `CodexThread`).
The branch is rebuilt directly on top of `upstream/main`, so it keeps
the
existing app-server restore architecture intact.
`AppServerStartedThread`
still restores transcript history from the server `Thread` snapshot via
`thread_snapshot_events`; this PR only adds composer-history support.
## Non-goals
- Adding history support to the app-server protocol. History remains
client-local.
- Changing the on-disk format or location of `history.jsonl`.
- Surfacing history I/O errors to the user (failures are logged and
silently swallowed, matching the legacy TUI).
## Tradeoffs
| Decision | Why | Risk |
|----------|-----|------|
| Widen `message_history` from `pub(crate)` to `pub` | Avoids
duplicating file I/O logic; the module already has a clean, minimal API
surface. | Other workspace crates can now call these functions — the
contract is no longer crate-private. However, this is consistent with
recent precedent: `590cfa617` exposed `mention_syntax` for TUI
consumption, `752402c4f` exposed plugin APIs (`PluginsManager`), and
`14fcb6645`/`edacbf7b6` widened internal core APIs for other crates.
These were all narrow, intentional exposures of specific APIs — not
broad "make internals public" moves. `1af2a37ad` even went the other
direction, reducing broad re-exports to tighten boundaries. This change
follows the same pattern: a small, deliberate API surface (3 functions)
rather than a wholesale visibility change. |
| Intercept `AddToHistory` / `GetHistoryEntryRequest` in `App` before
RPC fallback | Keeps history ops out of the "unsupported op" error path
without changing app-server protocol. | This now routes through a single
`submit_thread_op` entry point, which is safer than the original
duplicated dispatch. The remaining risk is organizational: future
thread-op submission paths need to keep using that shared entry point. |
| `session_configured_from_thread_response` is now `async` | Needs
`await` on `history_metadata()` to populate real `history_log_id` /
`history_entry_count`. | Adds an async file-stat + full-file newline
scan to the session bootstrap path. The scan is bounded by
`history.max_bytes` and matches the legacy TUI's cost profile, but
startup latency still scales with file size. |
## Architecture
```
User presses Up User submits a prompt
│ │
▼ ▼
ChatComposerHistory ChatWidget::do_submit_turn
navigate_up() encode_history_mentions()
│ │
▼ ▼
AppEvent::CodexOp Op::AddToHistory { text }
(GetHistoryEntryRequest) │
│ ▼
▼ App::try_handle_local_history_op
App::try_handle_local_history_op message_history::append_entry()
spawn_blocking { │
message_history::lookup() ▼
} $CODEX_HOME/history.jsonl
│
▼
AppEvent::ThreadEvent
(GetHistoryEntryResponse)
│
▼
ChatComposerHistory::on_entry_response()
```
## Observability
- `tracing::warn` on `append_entry` failure (includes thread ID).
- `tracing::warn` on `spawn_blocking` lookup join error.
- `tracing::warn` from `message_history` internals on file-open, lock,
or parse failures.
## Tests
- `chat_composer_history::tests::navigation_with_async_fetch` — verifies
that Up emits `Op::GetHistoryEntryRequest` (was: checked for stub error
cell).
- `app::tests::history_lookup_response_is_routed_to_requesting_thread` —
verifies multi-thread composer recall routes the lookup result back to
the originating thread.
-
`app_server_session::tests::resume_response_relies_on_snapshot_replay_not_initial_messages`
— verifies app-server session restore still uses the upstream
thread-snapshot path.
-
`app_server_session::tests::session_configured_populates_history_metadata`
— verifies bootstrap sets nonzero `history_log_id` /
`history_entry_count` from the shared local history file.
9835 lines
368 KiB
Rust
9835 lines
368 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.
|
||
//!
|
||
//! # 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.
|
||
//! - Expands `/prompts:` custom prompts (named or numeric args), preserving text elements.
|
||
//! - 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.
|
||
//!
|
||
//! # Voice Hold-To-Talk Without Key Release
|
||
//!
|
||
//! On terminals that do not report `KeyEventKind::Release`, space hold-to-talk uses repeated
|
||
//! space key events as "still held" evidence:
|
||
//!
|
||
//! - For pending holds (non-empty composer), if timeout elapses without any repeated space event,
|
||
//! we treat the key as a normal typed space.
|
||
//! - If repeated space events are seen before timeout, we proceed with hold-to-talk.
|
||
//! - While recording, repeated space events keep the recording alive; if they stop for a short
|
||
//! window, we stop and transcribe.
|
||
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::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::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::FooterMode;
|
||
use super::footer::FooterProps;
|
||
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::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::expand_custom_prompt;
|
||
use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args;
|
||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||
use crate::bottom_pane::prompt_args::prompt_argument_names;
|
||
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
|
||
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
|
||
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::custom_prompts::CustomPrompt;
|
||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||
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;
|
||
use codex_utils_fuzzy_match::fuzzy_match;
|
||
|
||
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::tui::FrameRequester;
|
||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||
use codex_chatgpt::connectors;
|
||
use codex_chatgpt::connectors::AppInfo;
|
||
use codex_core::plugins::PluginCapabilitySummary;
|
||
use codex_core::skills::model::SkillMetadata;
|
||
use codex_file_search::FileMatch;
|
||
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::sync::Arc;
|
||
#[cfg(not(target_os = "linux"))]
|
||
use std::sync::Mutex;
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::atomic::Ordering;
|
||
#[cfg(not(target_os = "linux"))]
|
||
use std::thread;
|
||
use std::time::Duration;
|
||
use std::time::Instant;
|
||
#[cfg(not(target_os = "linux"))]
|
||
use tokio::runtime::Handle;
|
||
/// 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>,
|
||
},
|
||
Command(SlashCommand),
|
||
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
|
||
None,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
struct AttachedImage {
|
||
placeholder: String,
|
||
path: PathBuf,
|
||
}
|
||
|
||
enum PromptSelectionMode {
|
||
Completion,
|
||
Submit,
|
||
}
|
||
|
||
enum PromptSelectionAction {
|
||
Insert {
|
||
text: String,
|
||
cursor: Option<usize>,
|
||
},
|
||
Submit {
|
||
text: String,
|
||
text_elements: Vec<TextElement>,
|
||
},
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct VoiceState {
|
||
transcription_enabled: bool,
|
||
// Spacebar hold-to-talk state.
|
||
space_hold_started_at: Option<Instant>,
|
||
space_hold_element_id: Option<String>,
|
||
space_hold_trigger: Option<Arc<AtomicBool>>,
|
||
key_release_supported: bool,
|
||
space_hold_repeat_seen: bool,
|
||
#[cfg(not(target_os = "linux"))]
|
||
voice: Option<crate::voice::VoiceCapture>,
|
||
#[cfg(not(target_os = "linux"))]
|
||
recording_placeholder_id: Option<String>,
|
||
#[cfg(not(target_os = "linux"))]
|
||
space_recording_started_at: Option<Instant>,
|
||
#[cfg(not(target_os = "linux"))]
|
||
space_recording_last_repeat_at: Option<Instant>,
|
||
}
|
||
|
||
impl VoiceState {
|
||
fn new(key_release_supported: bool) -> Self {
|
||
Self {
|
||
key_release_supported,
|
||
..Default::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) struct ChatComposer {
|
||
textarea: TextArea,
|
||
textarea_state: RefCell<TextAreaState>,
|
||
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,
|
||
voice_state: VoiceState,
|
||
// Spinner control flags keyed by placeholder id; set to true to stop.
|
||
spinner_stop_flags: HashMap<String, Arc<AtomicBool>>,
|
||
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,
|
||
custom_prompts: Vec<CustomPrompt>,
|
||
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>,
|
||
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>,
|
||
connectors_enabled: bool,
|
||
fast_command_enabled: bool,
|
||
personality_command_enabled: bool,
|
||
realtime_conversation_enabled: bool,
|
||
audio_device_selection_enabled: bool,
|
||
windows_degraded_sandbox_active: bool,
|
||
status_line_value: Option<Line<'static>>,
|
||
status_line_enabled: bool,
|
||
// Agent label injected into the footer's contextual row when multi-agent mode is active.
|
||
active_agent_label: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct FooterFlash {
|
||
line: Line<'static>,
|
||
expires_at: Instant,
|
||
}
|
||
|
||
#[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),
|
||
}
|
||
|
||
const FOOTER_SPACING_HEIGHT: u16 = 0;
|
||
|
||
impl ChatComposer {
|
||
fn builtin_command_flags(&self) -> BuiltinCommandFlags {
|
||
BuiltinCommandFlags {
|
||
collaboration_modes_enabled: self.collaboration_modes_enabled,
|
||
connectors_enabled: self.connectors_enabled,
|
||
fast_command_enabled: self.fast_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,
|
||
}
|
||
}
|
||
|
||
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 mut this = Self {
|
||
textarea: TextArea::new(),
|
||
textarea_state: RefCell::new(TextAreaState::default()),
|
||
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,
|
||
voice_state: VoiceState::new(enhanced_keys_supported),
|
||
spinner_stop_flags: HashMap::new(),
|
||
is_task_running: false,
|
||
input_enabled: true,
|
||
input_disabled_placeholder: None,
|
||
paste_burst: PasteBurst::default(),
|
||
disable_paste_burst: false,
|
||
custom_prompts: Vec::new(),
|
||
footer_mode: FooterMode::ComposerEmpty,
|
||
footer_hint_override: None,
|
||
remote_image_urls: Vec::new(),
|
||
selected_remote_image_index: 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,
|
||
connectors_enabled: false,
|
||
fast_command_enabled: false,
|
||
personality_command_enabled: false,
|
||
realtime_conversation_enabled: false,
|
||
audio_device_selection_enabled: false,
|
||
windows_degraded_sandbox_active: false,
|
||
status_line_value: None,
|
||
status_line_enabled: false,
|
||
active_agent_label: None,
|
||
};
|
||
// 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;
|
||
}
|
||
|
||
pub fn set_plugin_mentions(&mut self, plugins: Option<Vec<PluginCapabilitySummary>>) {
|
||
self.plugins = plugins;
|
||
self.sync_popups();
|
||
}
|
||
|
||
/// Toggle composer-side image paste handling.
|
||
///
|
||
/// This only affects whether image-like paste content is converted into attachments; the
|
||
/// `ChatWidget` layer still performs capability checks before images are submitted.
|
||
pub fn set_image_paste_enabled(&mut self, enabled: bool) {
|
||
self.config.image_paste_enabled = enabled;
|
||
}
|
||
|
||
pub fn set_connector_mentions(&mut self, connectors_snapshot: Option<ConnectorsSnapshot>) {
|
||
self.connectors_snapshot = connectors_snapshot;
|
||
self.sync_popups();
|
||
}
|
||
|
||
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
|
||
let elements = self.current_mention_elements();
|
||
let mut ordered = Vec::new();
|
||
for (id, mention) in elements {
|
||
if let Some(binding) = self.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_collaboration_mode_indicator(
|
||
&mut self,
|
||
indicator: Option<CollaborationModeIndicator>,
|
||
) {
|
||
self.collaboration_mode_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;
|
||
}
|
||
|
||
/// Compatibility shim for tests that still toggle the removed steer mode flag.
|
||
#[cfg(test)]
|
||
pub fn set_steer_enabled(&mut self, _enabled: bool) {}
|
||
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
|
||
self.voice_state.transcription_enabled = enabled;
|
||
if !enabled {
|
||
self.voice_state.space_hold_started_at = None;
|
||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||
let _ = self.textarea.replace_element_by_id(&id, " ");
|
||
}
|
||
self.voice_state.space_hold_trigger = None;
|
||
self.voice_state.space_hold_repeat_seen = false;
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
fn voice_transcription_enabled(&self) -> bool {
|
||
self.voice_state.transcription_enabled && cfg!(not(target_os = "linux"))
|
||
}
|
||
/// 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;
|
||
}
|
||
|
||
// Hide the cursor while recording voice input.
|
||
#[cfg(not(target_os = "linux"))]
|
||
if self.voice_state.voice.is_some() {
|
||
return None;
|
||
}
|
||
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.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 {
|
||
let Some(entry) = self.history.on_entry_response(log_id, offset, entry) else {
|
||
return false;
|
||
};
|
||
// 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
|
||
}
|
||
|
||
/// 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 {
|
||
#[cfg(not(target_os = "linux"))]
|
||
if self.voice_state.voice.is_some() {
|
||
return false;
|
||
}
|
||
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();
|
||
|
||
// 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.textarea.text().to_string();
|
||
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.textarea.text().to_string();
|
||
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>,
|
||
) {
|
||
#[cfg(not(target_os = "linux"))]
|
||
self.stop_all_transcription_spinners();
|
||
|
||
// Clear any existing content, placeholders, and attachments first.
|
||
self.textarea.set_text_clearing_elements("");
|
||
self.pending_pastes.clear();
|
||
self.attached_images.clear();
|
||
self.mention_bindings.clear();
|
||
|
||
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();
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
|
||
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.textarea.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 {
|
||
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.textarea.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)
|
||
}
|
||
|
||
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_popups();
|
||
}
|
||
|
||
/// Handle a key event coming from the main UI.
|
||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||
if matches!(key_event.kind, KeyEventKind::Release) {
|
||
self.voice_state.key_release_supported = true;
|
||
}
|
||
|
||
// Timer-based conversion is handled in the pre-draw tick.
|
||
// If recording, stop on Space release when supported. On terminals without key-release
|
||
// events, Space repeat events are handled as "still held" and stop is driven by timeout
|
||
// in `process_space_hold_trigger`.
|
||
if let Some(result) = self.handle_key_event_while_recording(key_event) {
|
||
return result;
|
||
}
|
||
|
||
if !self.input_enabled {
|
||
return (InputResult::None, false);
|
||
}
|
||
|
||
// Outside of recording, ignore all key releases globally except for Space,
|
||
// which is handled explicitly for hold-to-talk behavior below.
|
||
if matches!(key_event.kind, KeyEventKind::Release)
|
||
&& !matches!(key_event.code, KeyCode::Char(' '))
|
||
{
|
||
return (InputResult::None, false);
|
||
}
|
||
|
||
// If a space hold is pending and another non-space key is pressed, cancel the hold
|
||
// and convert the element into a plain space.
|
||
if self.voice_state.space_hold_started_at.is_some()
|
||
&& !matches!(key_event.code, KeyCode::Char(' '))
|
||
{
|
||
self.voice_state.space_hold_started_at = None;
|
||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||
let _ = self.textarea.replace_element_by_id(&id, " ");
|
||
}
|
||
self.voice_state.space_hold_trigger = None;
|
||
self.voice_state.space_hold_repeat_seen = false;
|
||
// fall through to normal handling of this other key
|
||
}
|
||
|
||
if let Some(result) = self.handle_voice_space_key_event(&key_event) {
|
||
return result;
|
||
}
|
||
|
||
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 {
|
||
!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());
|
||
if let Some(sel) = popup.selected_item() {
|
||
let mut cursor_target: Option<usize> = None;
|
||
match sel {
|
||
CommandItem::Builtin(cmd) => {
|
||
if cmd == SlashCommand::Skills {
|
||
self.textarea.set_text_clearing_elements("");
|
||
return (InputResult::Command(cmd), true);
|
||
}
|
||
|
||
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()));
|
||
}
|
||
if !self.textarea.text().is_empty() {
|
||
cursor_target = Some(self.textarea.text().len());
|
||
}
|
||
}
|
||
CommandItem::UserPrompt(idx) => {
|
||
if let Some(prompt) = popup.prompt(idx) {
|
||
match prompt_selection_action(
|
||
prompt,
|
||
first_line,
|
||
PromptSelectionMode::Completion,
|
||
&self.textarea.text_elements(),
|
||
) {
|
||
PromptSelectionAction::Insert { text, cursor } => {
|
||
let target = cursor.unwrap_or(text.len());
|
||
// Inserted prompt text is plain input; discard any elements.
|
||
self.textarea.set_text_clearing_elements(&text);
|
||
cursor_target = Some(target);
|
||
}
|
||
PromptSelectionAction::Submit { .. } => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(pos) = cursor_target {
|
||
self.textarea.set_cursor(pos);
|
||
}
|
||
}
|
||
(InputResult::None, true)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Enter,
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
// If the current line starts with a custom prompt name and includes
|
||
// positional args for a numeric-style template, expand and submit
|
||
// immediately regardless of the popup selection.
|
||
let mut text = self.textarea.text().to_string();
|
||
let mut text_elements = self.textarea.text_elements();
|
||
if !self.pending_pastes.is_empty() {
|
||
let (expanded, expanded_elements) =
|
||
Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes);
|
||
text = expanded;
|
||
text_elements = expanded_elements;
|
||
}
|
||
let first_line = text.lines().next().unwrap_or("");
|
||
if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line)
|
||
&& let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:"))
|
||
&& let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name)
|
||
&& let Some(expanded) =
|
||
expand_if_numeric_with_positional_args(prompt, first_line, &text_elements)
|
||
{
|
||
self.prune_attached_images_for_submission(
|
||
&expanded.text,
|
||
&expanded.text_elements,
|
||
);
|
||
self.pending_pastes.clear();
|
||
self.textarea.set_text_clearing_elements("");
|
||
return (
|
||
InputResult::Submitted {
|
||
text: expanded.text,
|
||
text_elements: expanded.text_elements,
|
||
},
|
||
true,
|
||
);
|
||
}
|
||
|
||
if let Some(sel) = popup.selected_item() {
|
||
match sel {
|
||
CommandItem::Builtin(cmd) => {
|
||
self.textarea.set_text_clearing_elements("");
|
||
return (InputResult::Command(cmd), true);
|
||
}
|
||
CommandItem::UserPrompt(idx) => {
|
||
if let Some(prompt) = popup.prompt(idx) {
|
||
match prompt_selection_action(
|
||
prompt,
|
||
first_line,
|
||
PromptSelectionMode::Submit,
|
||
&self.textarea.text_elements(),
|
||
) {
|
||
PromptSelectionAction::Submit {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
self.prune_attached_images_for_submission(
|
||
&text,
|
||
&text_elements,
|
||
);
|
||
self.textarea.set_text_clearing_elements("");
|
||
return (
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
},
|
||
true,
|
||
);
|
||
}
|
||
PromptSelectionAction::Insert { text, cursor } => {
|
||
let target = cursor.unwrap_or(text.len());
|
||
// Inserted prompt text is plain input; discard any elements.
|
||
self.textarea.set_text_clearing_elements(&text);
|
||
self.textarea.set_cursor(target);
|
||
return (InputResult::None, true);
|
||
}
|
||
}
|
||
}
|
||
return (InputResult::None, 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(input);
|
||
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(input);
|
||
|
||
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>)> {
|
||
let mut text = self.textarea.text().to_string();
|
||
let original_input = text.clone();
|
||
let original_text_elements = self.textarea.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("");
|
||
|
||
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 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();
|
||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||
let is_custom_prompt_command = name.starts_with(&prompt_prefix);
|
||
let is_known_prompt = name
|
||
.strip_prefix(&prompt_prefix)
|
||
.map(|prompt_name| {
|
||
self.custom_prompts
|
||
.iter()
|
||
.any(|prompt| prompt.name == prompt_name)
|
||
})
|
||
.unwrap_or(false);
|
||
if !is_builtin && !is_known_prompt {
|
||
let message = if is_custom_prompt_command && self.custom_prompts.is_empty() {
|
||
tracing::warn!(
|
||
"custom prompt listing/picker is not available in app-server TUI yet"
|
||
);
|
||
"Not available in app-server TUI yet.".to_string()
|
||
} else {
|
||
format!(
|
||
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
||
)
|
||
};
|
||
if is_custom_prompt_command && self.custom_prompts.is_empty() {
|
||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||
history_cell::new_error_event(message),
|
||
)));
|
||
} else {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
if self.slash_commands_enabled() {
|
||
let expanded_prompt =
|
||
match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) {
|
||
Ok(expanded) => expanded,
|
||
Err(err) => {
|
||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||
history_cell::new_error_event(err.user_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;
|
||
}
|
||
};
|
||
if let Some(expanded) = expanded_prompt {
|
||
text = expanded.text;
|
||
text_elements = expanded.text_elements;
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
// Custom prompt expansion can remove or rewrite image placeholders, so prune any
|
||
// attachments that no longer have a corresponding placeholder in the expanded text.
|
||
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 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()
|
||
&& (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.textarea.text().to_string();
|
||
let original_text_elements = self.textarea.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,
|
||
},
|
||
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() {
|
||
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) {
|
||
return Some(InputResult::None);
|
||
}
|
||
self.textarea.set_text_clearing_elements("");
|
||
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() {
|
||
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) {
|
||
return Some(InputResult::None);
|
||
}
|
||
|
||
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 is accepted, this helper performs the usual
|
||
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
|
||
/// full-text offsets to command-arg offsets.
|
||
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
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
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.textarea.text(), self.textarea.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)
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Tab,
|
||
modifiers: KeyModifiers::NONE,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} if !self.is_bang_shell_command() => self.handle_submission(self.is_task_running),
|
||
KeyEvent {
|
||
code: KeyCode::Enter,
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => self.handle_submission(/*should_queue*/ false),
|
||
input => self.handle_input_basic(input),
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
fn handle_voice_space_key_event(
|
||
&mut self,
|
||
_key_event: &KeyEvent,
|
||
) -> Option<(InputResult, bool)> {
|
||
None
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
fn handle_voice_space_key_event(
|
||
&mut self,
|
||
key_event: &KeyEvent,
|
||
) -> Option<(InputResult, bool)> {
|
||
if !self.voice_transcription_enabled() || !matches!(key_event.code, KeyCode::Char(' ')) {
|
||
return None;
|
||
}
|
||
match key_event.kind {
|
||
KeyEventKind::Press => {
|
||
if self.paste_burst.is_active() {
|
||
return None;
|
||
}
|
||
|
||
// If textarea is empty, start recording immediately without inserting a space.
|
||
if self.textarea.text().is_empty() {
|
||
if self.start_recording_with_placeholder() {
|
||
return Some((InputResult::None, true));
|
||
}
|
||
return None;
|
||
}
|
||
|
||
// If a hold is already pending, swallow further press events to
|
||
// avoid inserting multiple spaces and resetting the timer on key repeat.
|
||
if self.voice_state.space_hold_started_at.is_some() {
|
||
if !self.voice_state.key_release_supported {
|
||
self.voice_state.space_hold_repeat_seen = true;
|
||
}
|
||
return Some((InputResult::None, false));
|
||
}
|
||
|
||
// Insert a named element that renders as a space so we can later
|
||
// remove it on timeout or convert it to a plain space on release.
|
||
let elem_id = self.next_id();
|
||
self.textarea.insert_named_element(" ", elem_id.clone());
|
||
|
||
// Record pending hold metadata.
|
||
self.voice_state.space_hold_started_at = Some(Instant::now());
|
||
self.voice_state.space_hold_element_id = Some(elem_id);
|
||
self.voice_state.space_hold_repeat_seen = false;
|
||
|
||
// Spawn a delayed task to flip an atomic flag; we check it on next key event.
|
||
let flag = Arc::new(AtomicBool::new(false));
|
||
let frame = self.frame_requester.clone();
|
||
Self::schedule_space_hold_timer(flag.clone(), frame);
|
||
self.voice_state.space_hold_trigger = Some(flag);
|
||
|
||
Some((InputResult::None, true))
|
||
}
|
||
// If we see a repeat before release, handling occurs in the top-level pending block.
|
||
KeyEventKind::Repeat => {
|
||
// Swallow repeats while a hold is pending to avoid extra spaces.
|
||
if self.voice_state.space_hold_started_at.is_some() {
|
||
if !self.voice_state.key_release_supported {
|
||
self.voice_state.space_hold_repeat_seen = true;
|
||
}
|
||
return Some((InputResult::None, false));
|
||
}
|
||
// Fallback: if no pending hold, treat as normal input.
|
||
None
|
||
}
|
||
// Space release without pending (fallback): treat as normal input.
|
||
KeyEventKind::Release => {
|
||
// If a hold is pending, convert the element to a plain space and clear state.
|
||
self.voice_state.space_hold_started_at = None;
|
||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||
let _ = self.textarea.replace_element_by_id(&id, " ");
|
||
}
|
||
self.voice_state.space_hold_trigger = None;
|
||
self.voice_state.space_hold_repeat_seen = false;
|
||
Some((InputResult::None, true))
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
fn handle_key_event_while_recording(
|
||
&mut self,
|
||
_key_event: KeyEvent,
|
||
) -> Option<(InputResult, bool)> {
|
||
None
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
fn handle_key_event_while_recording(
|
||
&mut self,
|
||
key_event: KeyEvent,
|
||
) -> Option<(InputResult, bool)> {
|
||
if self.voice_state.voice.is_some() {
|
||
let should_stop = if self.voice_state.key_release_supported {
|
||
match key_event.kind {
|
||
KeyEventKind::Release => matches!(key_event.code, KeyCode::Char(' ')),
|
||
KeyEventKind::Press | KeyEventKind::Repeat => {
|
||
!matches!(key_event.code, KeyCode::Char(' '))
|
||
}
|
||
}
|
||
} else {
|
||
match key_event.kind {
|
||
KeyEventKind::Release => matches!(key_event.code, KeyCode::Char(' ')),
|
||
KeyEventKind::Press | KeyEventKind::Repeat => {
|
||
if matches!(key_event.code, KeyCode::Char(' ')) {
|
||
self.voice_state.space_recording_last_repeat_at = Some(Instant::now());
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
if should_stop {
|
||
let needs_redraw = self.stop_recording_and_start_transcription();
|
||
return Some((InputResult::None, needs_redraw));
|
||
}
|
||
|
||
// Swallow non-stopping keys while recording.
|
||
return Some((InputResult::None, false));
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn is_bang_shell_command(&self) -> bool {
|
||
self.textarea.text().trim_start().starts_with('!')
|
||
}
|
||
|
||
/// 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.textarea.insert_str(ch.to_string().as_str());
|
||
self.sync_popups();
|
||
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())
|
||
};
|
||
|
||
self.textarea.input(input);
|
||
|
||
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 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);
|
||
}
|
||
}
|
||
|
||
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
|
||
if key_event.kind != KeyEventKind::Press {
|
||
return false;
|
||
}
|
||
|
||
let toggles = matches!(key_event.code, KeyCode::Char('?'))
|
||
&& !has_ctrl_or_alt(key_event.modifiers)
|
||
&& 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,
|
||
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 {
|
||
let base_mode = if self.is_empty() {
|
||
FooterMode::ComposerEmpty
|
||
} else {
|
||
FooterMode::ComposerHasDraft
|
||
};
|
||
|
||
match self.footer_mode {
|
||
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.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.textarea.text(), self.textarea.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() && 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>> {
|
||
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 {
|
||
let is_builtin =
|
||
slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some();
|
||
if is_builtin {
|
||
return true;
|
||
}
|
||
if let Some(rest) = name.strip_prefix(PROMPTS_CMD_PREFIX)
|
||
&& let Some(prompt_name) = rest.strip_prefix(':')
|
||
{
|
||
return self
|
||
.custom_prompts
|
||
.iter()
|
||
.any(|prompt| prompt.name == prompt_name);
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 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 command (built-in or custom prompt).
|
||
/// 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();
|
||
}
|
||
|
||
if slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) {
|
||
return true;
|
||
}
|
||
|
||
self.custom_prompts.iter().any(|prompt| {
|
||
fuzzy_match(&format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name), name).is_some()
|
||
})
|
||
}
|
||
|
||
/// 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 fast_command_enabled = self.fast_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(
|
||
self.custom_prompts.clone(),
|
||
CommandPopupFlags {
|
||
collaboration_modes_enabled,
|
||
connectors_enabled,
|
||
fast_command_enabled,
|
||
personality_command_enabled,
|
||
realtime_conversation_enabled,
|
||
audio_device_selection_enabled,
|
||
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
|
||
},
|
||
);
|
||
command_popup.on_composer_text_change(first_line.to_string());
|
||
self.active_popup = ActivePopup::Command(command_popup);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
|
||
self.custom_prompts = prompts.clone();
|
||
if let ActivePopup::Command(popup) = &mut self.active_popup {
|
||
popup.set_prompts(prompts);
|
||
}
|
||
}
|
||
|
||
/// 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).to_string();
|
||
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 = connectors::connector_display_label(connector);
|
||
let description = Some(Self::connector_brief_description(connector));
|
||
let slug = codex_core::connectors::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;
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
pub(crate) fn is_recording(&self) -> bool {
|
||
self.voice_state.voice.is_some()
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
fn schedule_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
|
||
const HOLD_DELAY_MILLIS: u64 = 500;
|
||
if let Ok(handle) = Handle::try_current() {
|
||
let flag_clone = flag;
|
||
let frame_clone = frame;
|
||
handle.spawn(async move {
|
||
tokio::time::sleep(Duration::from_millis(HOLD_DELAY_MILLIS)).await;
|
||
Self::complete_space_hold_timer(flag_clone, frame_clone);
|
||
});
|
||
} else {
|
||
thread::spawn(move || {
|
||
thread::sleep(Duration::from_millis(HOLD_DELAY_MILLIS));
|
||
Self::complete_space_hold_timer(flag, frame);
|
||
});
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
fn complete_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
|
||
flag.store(true, Ordering::Relaxed);
|
||
if let Some(frame) = frame {
|
||
frame.schedule_frame();
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
impl ChatComposer {
|
||
pub(crate) fn process_space_hold_trigger(&mut self) {
|
||
if self.voice_transcription_enabled()
|
||
&& let Some(flag) = self.voice_state.space_hold_trigger.as_ref()
|
||
&& flag.load(Ordering::Relaxed)
|
||
&& self.voice_state.space_hold_started_at.is_some()
|
||
&& self.voice_state.voice.is_none()
|
||
{
|
||
let _ = self.on_space_hold_timeout();
|
||
}
|
||
|
||
const SPACE_REPEAT_INITIAL_GRACE_MILLIS: u64 = 700;
|
||
const SPACE_REPEAT_IDLE_TIMEOUT_MILLIS: u64 = 250;
|
||
if !self.voice_state.key_release_supported && self.voice_state.voice.is_some() {
|
||
let now = Instant::now();
|
||
let initial_grace = Duration::from_millis(SPACE_REPEAT_INITIAL_GRACE_MILLIS);
|
||
let repeat_idle_timeout = Duration::from_millis(SPACE_REPEAT_IDLE_TIMEOUT_MILLIS);
|
||
if let Some(started_at) = self.voice_state.space_recording_started_at
|
||
&& now.saturating_duration_since(started_at) >= initial_grace
|
||
{
|
||
let should_stop = match self.voice_state.space_recording_last_repeat_at {
|
||
Some(last_repeat_at) => {
|
||
now.saturating_duration_since(last_repeat_at) >= repeat_idle_timeout
|
||
}
|
||
None => true,
|
||
};
|
||
if should_stop {
|
||
let _ = self.stop_recording_and_start_transcription();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Called when the 500ms space hold timeout elapses.
|
||
///
|
||
/// On terminals without key-release reporting, this only transitions into voice capture if we
|
||
/// observed repeated Space events while pending; otherwise the keypress is treated as a typed
|
||
/// space.
|
||
pub(crate) fn on_space_hold_timeout(&mut self) -> bool {
|
||
if !self.voice_transcription_enabled() {
|
||
return false;
|
||
}
|
||
if self.voice_state.voice.is_some() {
|
||
return false;
|
||
}
|
||
if self.voice_state.space_hold_started_at.is_some() {
|
||
if !self.voice_state.key_release_supported && !self.voice_state.space_hold_repeat_seen {
|
||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||
let _ = self.textarea.replace_element_by_id(&id, " ");
|
||
}
|
||
self.voice_state.space_hold_started_at = None;
|
||
self.voice_state.space_hold_trigger = None;
|
||
self.voice_state.space_hold_repeat_seen = false;
|
||
return true;
|
||
}
|
||
|
||
// Preserve the typed space when transitioning into voice capture, but
|
||
// avoid duplicating an existing trailing space. In either case,
|
||
// convert/remove the temporary named element before inserting the
|
||
// recording/transcribing placeholder.
|
||
if let Some(id) = self.voice_state.space_hold_element_id.take() {
|
||
let replacement = if self
|
||
.textarea
|
||
.named_element_range(&id)
|
||
.and_then(|range| self.textarea.text()[..range.start].chars().next_back())
|
||
.is_some_and(|ch| ch == ' ')
|
||
{
|
||
""
|
||
} else {
|
||
" "
|
||
};
|
||
let _ = self.textarea.replace_element_by_id(&id, replacement);
|
||
}
|
||
// Clear pending state before starting capture
|
||
self.voice_state.space_hold_started_at = None;
|
||
self.voice_state.space_hold_trigger = None;
|
||
self.voice_state.space_hold_repeat_seen = false;
|
||
|
||
// Start voice capture
|
||
self.start_recording_with_placeholder()
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
/// Stop recording if active, update the placeholder, and spawn background transcription.
|
||
/// Returns true if the UI should redraw.
|
||
fn stop_recording_and_start_transcription(&mut self) -> bool {
|
||
let Some(vc) = self.voice_state.voice.take() else {
|
||
return false;
|
||
};
|
||
self.voice_state.space_recording_started_at = None;
|
||
self.voice_state.space_recording_last_repeat_at = None;
|
||
match vc.stop() {
|
||
Ok(audio) => {
|
||
// If the recording is too short, remove the placeholder immediately
|
||
// and skip the transcribing state entirely.
|
||
let total_samples = audio.data.len() as f32;
|
||
let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32);
|
||
let duration_seconds = if samples_per_second > 0.0 {
|
||
total_samples / samples_per_second
|
||
} else {
|
||
0.0
|
||
};
|
||
const MIN_DURATION_SECONDS: f32 = 1.0;
|
||
if duration_seconds < MIN_DURATION_SECONDS {
|
||
if let Some(id) = self.voice_state.recording_placeholder_id.take() {
|
||
let _ = self.textarea.replace_element_by_id(&id, "");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Otherwise, update the placeholder to show a spinner and proceed.
|
||
let id = match self.voice_state.recording_placeholder_id.take() {
|
||
Some(id) => id,
|
||
None => self.next_id(),
|
||
};
|
||
|
||
let placeholder_range = self.textarea.named_element_range(&id);
|
||
let prompt_source = if let Some(range) = &placeholder_range {
|
||
self.textarea.text()[..range.start].to_string()
|
||
} else {
|
||
self.textarea.text().to_string()
|
||
};
|
||
|
||
// Initialize with first spinner frame immediately.
|
||
let _ = self.textarea.update_named_element_by_id(&id, "⠋");
|
||
// Spawn animated braille spinner until transcription finishes (or times out).
|
||
self.spawn_transcribing_spinner(id.clone());
|
||
let tx = self.app_event_tx.clone();
|
||
crate::voice::transcribe_async(id, audio, Some(prompt_source), tx);
|
||
true
|
||
}
|
||
Err(e) => {
|
||
tracing::error!("failed to stop voice capture: {e}");
|
||
true
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Start voice capture and insert a placeholder element for the live meter.
|
||
/// Returns true if recording began and UI should redraw; false on failure.
|
||
fn start_recording_with_placeholder(&mut self) -> bool {
|
||
match crate::voice::VoiceCapture::start() {
|
||
Ok(vc) => {
|
||
self.voice_state.voice = Some(vc);
|
||
if self.voice_state.key_release_supported {
|
||
self.voice_state.space_recording_started_at = None;
|
||
} else {
|
||
self.voice_state.space_recording_started_at = Some(Instant::now());
|
||
}
|
||
self.voice_state.space_recording_last_repeat_at = None;
|
||
// Insert visible placeholder for the meter (no label)
|
||
let id = self.next_id();
|
||
self.textarea.insert_named_element("", id.clone());
|
||
self.voice_state.recording_placeholder_id = Some(id);
|
||
// Spawn metering animation
|
||
if let Some(v) = &self.voice_state.voice {
|
||
let data = v.data_arc();
|
||
let stop = v.stopped_flag();
|
||
let sr = v.sample_rate();
|
||
let ch = v.channels();
|
||
let peak = v.last_peak_arc();
|
||
if let Some(idref) = &self.voice_state.recording_placeholder_id {
|
||
self.spawn_recording_meter(idref.clone(), sr, ch, data, peak, stop);
|
||
}
|
||
}
|
||
true
|
||
}
|
||
Err(e) => {
|
||
self.voice_state.space_recording_started_at = None;
|
||
self.voice_state.space_recording_last_repeat_at = None;
|
||
tracing::error!("failed to start voice capture: {e}");
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
fn spawn_recording_meter(
|
||
&self,
|
||
id: String,
|
||
_sample_rate: u32,
|
||
_channels: u16,
|
||
_data: Arc<Mutex<Vec<i16>>>,
|
||
last_peak: Arc<std::sync::atomic::AtomicU16>,
|
||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||
) {
|
||
let tx = self.app_event_tx.clone();
|
||
let task = move || {
|
||
use std::time::Duration;
|
||
let mut meter = crate::voice::RecordingMeterState::new();
|
||
loop {
|
||
if stop.load(Ordering::Relaxed) {
|
||
break;
|
||
}
|
||
let text = meter.next_text(last_peak.load(Ordering::Relaxed));
|
||
tx.send(crate::app_event::AppEvent::UpdateRecordingMeter {
|
||
id: id.clone(),
|
||
text,
|
||
});
|
||
|
||
thread::sleep(Duration::from_millis(100));
|
||
}
|
||
};
|
||
|
||
if let Ok(handle) = Handle::try_current() {
|
||
handle.spawn_blocking(task);
|
||
} else {
|
||
thread::spawn(task);
|
||
}
|
||
}
|
||
|
||
fn spawn_transcribing_spinner(&mut self, id: String) {
|
||
self.stop_transcription_spinner(&id);
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
self.spinner_stop_flags
|
||
.insert(id.clone(), Arc::clone(&stop));
|
||
|
||
let tx = self.app_event_tx.clone();
|
||
let task = move || {
|
||
use std::time::Duration;
|
||
let frames: Vec<&'static str> = vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||
let mut i: usize = 0;
|
||
// Safety stop after ~60s to avoid a runaway task if events are lost.
|
||
let max_ticks = 600usize; // 600 * 100ms = 60s
|
||
for _ in 0..max_ticks {
|
||
if stop.load(Ordering::Relaxed) {
|
||
break;
|
||
}
|
||
let text = frames[i % frames.len()].to_string();
|
||
tx.send(crate::app_event::AppEvent::UpdateRecordingMeter {
|
||
id: id.clone(),
|
||
text,
|
||
});
|
||
i = i.wrapping_add(1);
|
||
thread::sleep(Duration::from_millis(100));
|
||
}
|
||
};
|
||
|
||
if let Ok(handle) = Handle::try_current() {
|
||
handle.spawn_blocking(task);
|
||
} else {
|
||
thread::spawn(task);
|
||
}
|
||
}
|
||
|
||
fn stop_transcription_spinner(&mut self, id: &str) {
|
||
if let Some(flag) = self.spinner_stop_flags.remove(id) {
|
||
flag.store(true, Ordering::Relaxed);
|
||
}
|
||
}
|
||
|
||
fn stop_all_transcription_spinners(&mut self) {
|
||
for (_id, flag) in self.spinner_stop_flags.drain() {
|
||
flag.store(true, Ordering::Relaxed);
|
||
}
|
||
}
|
||
|
||
pub fn replace_transcription(&mut self, id: &str, text: &str) {
|
||
self.stop_transcription_spinner(id);
|
||
let _ = self.textarea.replace_element_by_id(id, text);
|
||
}
|
||
|
||
pub fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
|
||
self.textarea.update_named_element_by_id(id, text)
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
pub fn insert_transcription_placeholder(&mut self, text: &str) -> String {
|
||
let id = self.next_id();
|
||
self.textarea.insert_named_element(text, id.clone());
|
||
id
|
||
}
|
||
|
||
pub fn remove_transcription_placeholder(&mut self, id: &str) {
|
||
self.stop_transcription_spinner(id);
|
||
let _ = self.textarea.replace_element_by_id(id, "");
|
||
}
|
||
}
|
||
|
||
fn skill_display_name(skill: &SkillMetadata) -> &str {
|
||
skill
|
||
.interface
|
||
.as_ref()
|
||
.and_then(|interface| interface.display_name.as_deref())
|
||
.unwrap_or(&skill.name)
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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::QuitShortcutReminder
|
||
| FooterMode::ShortcutOverlay
|
||
| FooterMode::EscHint => false,
|
||
};
|
||
let show_queue_hint = match footer_props.mode {
|
||
FooterMode::ComposerHasDraft => footer_props.is_task_running,
|
||
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
|
||
};
|
||
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 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) = self.footer_hint_override.as_ref() {
|
||
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 status_line_active {
|
||
let full =
|
||
mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint);
|
||
let compact = mode_indicator_line(
|
||
self.collaboration_mode_indicator,
|
||
/*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() || self.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,
|
||
))
|
||
}
|
||
FooterMode::EscHint
|
||
| FooterMode::QuitShortcutReminder
|
||
| FooterMode::ShortcutOverlay => None,
|
||
}
|
||
};
|
||
let show_right = if matches!(
|
||
footer_props.mode,
|
||
FooterMode::EscHint
|
||
| 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) = self.footer_hint_override.as_ref() {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
let style = user_message_style();
|
||
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 !textarea_rect.is_empty() {
|
||
let prompt = if self.input_enabled {
|
||
"›".bold()
|
||
} 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();
|
||
if let Some(mask_char) = mask_char {
|
||
self.textarea
|
||
.render_ref_masked(textarea_rect, buf, &mut state, mask_char);
|
||
} else {
|
||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||
}
|
||
if self.textarea.text().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() {
|
||
let placeholder = Span::from(text).dim();
|
||
Line::from(vec![placeholder])
|
||
.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn prompt_selection_action(
|
||
prompt: &CustomPrompt,
|
||
first_line: &str,
|
||
mode: PromptSelectionMode,
|
||
text_elements: &[TextElement],
|
||
) -> PromptSelectionAction {
|
||
let named_args = prompt_argument_names(&prompt.content);
|
||
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
|
||
|
||
match mode {
|
||
PromptSelectionMode::Completion => {
|
||
if !named_args.is_empty() {
|
||
let (text, cursor) =
|
||
prompt_command_with_arg_placeholders(&prompt.name, &named_args);
|
||
return PromptSelectionAction::Insert {
|
||
text,
|
||
cursor: Some(cursor),
|
||
};
|
||
}
|
||
if has_numeric {
|
||
let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name);
|
||
return PromptSelectionAction::Insert { text, cursor: None };
|
||
}
|
||
let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name);
|
||
PromptSelectionAction::Insert { text, cursor: None }
|
||
}
|
||
PromptSelectionMode::Submit => {
|
||
if !named_args.is_empty() {
|
||
let (text, cursor) =
|
||
prompt_command_with_arg_placeholders(&prompt.name, &named_args);
|
||
return PromptSelectionAction::Insert {
|
||
text,
|
||
cursor: Some(cursor),
|
||
};
|
||
}
|
||
if has_numeric {
|
||
if let Some(expanded) =
|
||
expand_if_numeric_with_positional_args(prompt, first_line, text_elements)
|
||
{
|
||
return PromptSelectionAction::Submit {
|
||
text: expanded.text,
|
||
text_elements: expanded.text_elements,
|
||
};
|
||
}
|
||
let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name);
|
||
return PromptSelectionAction::Insert { text, cursor: None };
|
||
}
|
||
PromptSelectionAction::Submit {
|
||
text: prompt.content.clone(),
|
||
// By now we know this custom prompt has no args, so no text elements to preserve.
|
||
text_elements: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for ChatComposer {
|
||
fn drop(&mut self) {
|
||
// Stop any running spinner tasks.
|
||
for (_id, flag) in self.spinner_stop_flags.drain() {
|
||
flag.store(true, Ordering::Relaxed);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
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::prompt_args::PromptArg;
|
||
use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line;
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let area = Rect::new(0, 0, 40, 6);
|
||
let mut buf = Buffer::empty(area);
|
||
composer.render(area, &mut buf);
|
||
|
||
let row_to_string = |y: u16| {
|
||
let mut row = String::new();
|
||
for x in 0..area.width {
|
||
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||
}
|
||
row
|
||
};
|
||
|
||
let mut hint_row: Option<(u16, String)> = None;
|
||
for y in 0..area.height {
|
||
let row = row_to_string(y);
|
||
if row.contains("? for shortcuts") {
|
||
hint_row = Some((y, row));
|
||
break;
|
||
}
|
||
}
|
||
|
||
let (hint_row_idx, hint_row_contents) =
|
||
hint_row.expect("expected footer hint row to be rendered");
|
||
assert_eq!(
|
||
hint_row_idx,
|
||
area.height - 1,
|
||
"hint row should occupy the bottom line: {hint_row_contents:?}",
|
||
);
|
||
|
||
assert!(
|
||
hint_row_idx > 0,
|
||
"expected a spacing row above the footer hints",
|
||
);
|
||
|
||
let spacing_row = row_to_string(hint_row_idx - 1);
|
||
assert_eq!(
|
||
spacing_row.trim(),
|
||
"",
|
||
"expected blank spacing row above hints but saw: {spacing_row:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn footer_flash_overrides_footer_hint_override() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())]));
|
||
composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10));
|
||
|
||
let area = Rect::new(0, 0, 60, 6);
|
||
let mut buf = Buffer::empty(area);
|
||
composer.render(area, &mut buf);
|
||
|
||
let mut bottom_row = String::new();
|
||
for x in 0..area.width {
|
||
bottom_row.push(
|
||
buf[(x, area.height - 1)]
|
||
.symbol()
|
||
.chars()
|
||
.next()
|
||
.unwrap_or(' '),
|
||
);
|
||
}
|
||
assert!(
|
||
bottom_row.contains("FLASH"),
|
||
"expected flash content to render in footer row, saw: {bottom_row:?}",
|
||
);
|
||
assert!(
|
||
!bottom_row.contains("K label"),
|
||
"expected flash to override hint override, saw: {bottom_row:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn footer_flash_expires_and_falls_back_to_hint_override() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())]));
|
||
composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10));
|
||
composer.footer_flash.as_mut().unwrap().expires_at =
|
||
Instant::now() - Duration::from_secs(1);
|
||
|
||
let area = Rect::new(0, 0, 60, 6);
|
||
let mut buf = Buffer::empty(area);
|
||
composer.render(area, &mut buf);
|
||
|
||
let mut bottom_row = String::new();
|
||
for x in 0..area.width {
|
||
bottom_row.push(
|
||
buf[(x, area.height - 1)]
|
||
.symbol()
|
||
.chars()
|
||
.next()
|
||
.unwrap_or(' '),
|
||
);
|
||
}
|
||
assert!(
|
||
bottom_row.contains("K label"),
|
||
"expected hint override to render after flash expired, saw: {bottom_row:?}",
|
||
);
|
||
assert!(
|
||
!bottom_row.contains("FLASH"),
|
||
"expected expired flash to be hidden, saw: {bottom_row:?}",
|
||
);
|
||
}
|
||
|
||
fn snapshot_composer_state_with_width<F>(
|
||
name: &str,
|
||
width: u16,
|
||
enhanced_keys_supported: bool,
|
||
setup: F,
|
||
) where
|
||
F: FnOnce(&mut ChatComposer),
|
||
{
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TestBackend;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
enhanced_keys_supported,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
setup(&mut composer);
|
||
let footer_props = composer.footer_props();
|
||
let footer_lines = footer_height(&footer_props);
|
||
let footer_spacing = ChatComposer::footer_spacing(footer_lines);
|
||
let height = footer_lines + footer_spacing + 8;
|
||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||
terminal
|
||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||
.unwrap();
|
||
insta::assert_snapshot!(name, terminal.backend());
|
||
}
|
||
|
||
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
|
||
where
|
||
F: FnOnce(&mut ChatComposer),
|
||
{
|
||
snapshot_composer_state_with_width(name, 100, enhanced_keys_supported, setup);
|
||
}
|
||
|
||
#[test]
|
||
fn footer_mode_snapshots() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| {
|
||
composer.set_esc_backtrack_hint(true);
|
||
let _ =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
});
|
||
|
||
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
|
||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||
});
|
||
|
||
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
|
||
composer.set_task_running(true);
|
||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||
});
|
||
|
||
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
|
||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
});
|
||
|
||
snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| {
|
||
let _ =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
});
|
||
|
||
snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| {
|
||
composer.set_esc_backtrack_hint(true);
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
});
|
||
|
||
snapshot_composer_state(
|
||
"footer_mode_overlay_then_external_esc_hint",
|
||
true,
|
||
|composer| {
|
||
let _ = composer
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
composer.set_esc_backtrack_hint(true);
|
||
},
|
||
);
|
||
|
||
snapshot_composer_state("footer_mode_hidden_while_typing", true, |composer| {
|
||
type_chars_humanlike(composer, &['h']);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn footer_collapse_snapshots() {
|
||
fn setup_collab_footer(
|
||
composer: &mut ChatComposer,
|
||
context_percent: i64,
|
||
indicator: Option<CollaborationModeIndicator>,
|
||
) {
|
||
composer.set_collaboration_modes_enabled(true);
|
||
composer.set_collaboration_mode_indicator(indicator);
|
||
composer.set_context_window(Some(context_percent), None);
|
||
}
|
||
|
||
// Empty textarea, agent idle: shortcuts hint can show, and cycle hint is hidden.
|
||
snapshot_composer_state_with_width("footer_collapse_empty_full", 120, true, |composer| {
|
||
setup_collab_footer(composer, 100, None);
|
||
});
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_empty_mode_cycle_with_context",
|
||
60,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 100, None);
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_empty_mode_cycle_without_context",
|
||
44,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 100, None);
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_empty_mode_only",
|
||
26,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 100, None);
|
||
},
|
||
);
|
||
|
||
// Empty textarea, plan mode idle: shortcuts hint and cycle hint are available.
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_empty_full",
|
||
120,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 100, Some(CollaborationModeIndicator::Plan));
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_empty_mode_cycle_with_context",
|
||
60,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 100, Some(CollaborationModeIndicator::Plan));
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_empty_mode_cycle_without_context",
|
||
44,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 100, Some(CollaborationModeIndicator::Plan));
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_empty_mode_only",
|
||
26,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 100, Some(CollaborationModeIndicator::Plan));
|
||
},
|
||
);
|
||
|
||
// Textarea has content, agent running: queue hint is shown.
|
||
snapshot_composer_state_with_width("footer_collapse_queue_full", 120, true, |composer| {
|
||
setup_collab_footer(composer, 98, None);
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
});
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_queue_short_with_context",
|
||
50,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, None);
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_queue_message_without_context",
|
||
40,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, None);
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_queue_short_without_context",
|
||
30,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, None);
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_queue_mode_only",
|
||
20,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, None);
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
|
||
// Textarea has content, plan mode active, agent running: queue hint + mode.
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_queue_full",
|
||
120,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan));
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_queue_short_with_context",
|
||
50,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan));
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_queue_message_without_context",
|
||
40,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan));
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_queue_short_without_context",
|
||
30,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan));
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
snapshot_composer_state_with_width(
|
||
"footer_collapse_plan_queue_mode_only",
|
||
20,
|
||
true,
|
||
|composer| {
|
||
setup_collab_footer(composer, 98, Some(CollaborationModeIndicator::Plan));
|
||
composer.set_task_running(true);
|
||
composer.set_text_content("Test".to_string(), Vec::new(), Vec::new());
|
||
},
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn esc_hint_stays_hidden_with_draft_content() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
true,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['d']);
|
||
|
||
assert!(!composer.is_empty());
|
||
assert_eq!(composer.current_text(), "d");
|
||
assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty);
|
||
assert!(matches!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['d']);
|
||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||
composer.quit_shortcut_expires_at =
|
||
Some(Instant::now() - std::time::Duration::from_secs(1));
|
||
|
||
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
|
||
|
||
composer.set_text_content(String::new(), Vec::new(), Vec::new());
|
||
assert_eq!(composer.footer_mode(), FooterMode::ComposerEmpty);
|
||
}
|
||
|
||
#[test]
|
||
fn clear_for_ctrl_c_records_cleared_draft() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new());
|
||
assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string()));
|
||
assert!(composer.is_empty());
|
||
|
||
assert_eq!(
|
||
composer.history.navigate_up(&composer.app_event_tx),
|
||
Some(HistoryEntry::new("draft text".to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clear_for_ctrl_c_preserves_pending_paste_history_entry() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
|
||
composer.handle_paste(large.clone());
|
||
let char_count = large.chars().count();
|
||
let placeholder = format!("[Pasted Content {char_count} chars]");
|
||
assert_eq!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let path = PathBuf::from("example.png");
|
||
composer.attach_image(path.clone());
|
||
let placeholder = local_image_label_text(1);
|
||
|
||
composer.clear_for_ctrl_c();
|
||
assert!(composer.is_empty());
|
||
|
||
let history_entry = composer
|
||
.history
|
||
.navigate_up(&composer.app_event_tx)
|
||
.expect("expected history entry");
|
||
let text_elements = vec![TextElement::new(
|
||
(0..placeholder.len()).into(),
|
||
Some(placeholder.clone()),
|
||
)];
|
||
assert_eq!(
|
||
history_entry,
|
||
HistoryEntry::with_pending(
|
||
placeholder.clone(),
|
||
text_elements,
|
||
vec![path.clone()],
|
||
Vec::new()
|
||
)
|
||
);
|
||
|
||
composer.apply_history_entry(history_entry);
|
||
assert_eq!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
let remote_image_url = "https://example.com/one.png".to_string();
|
||
composer.set_remote_image_urls(vec![remote_image_url.clone()]);
|
||
let text = "[Image #2] draft".to_string();
|
||
let text_elements = vec![TextElement::new(
|
||
(0.."[Image #2]".len()).into(),
|
||
Some("[Image #2]".to_string()),
|
||
)];
|
||
let local_image_path = PathBuf::from("/tmp/local-draft.png");
|
||
composer.set_text_content(text, text_elements, vec![local_image_path.clone()]);
|
||
let expected_text = composer.current_text();
|
||
let expected_elements = composer.text_elements();
|
||
assert_eq!(expected_text, "[Image #2] draft");
|
||
assert_eq!(
|
||
expected_elements[0].placeholder(&expected_text),
|
||
Some("[Image #2]")
|
||
);
|
||
|
||
assert_eq!(composer.clear_for_ctrl_c(), Some(expected_text.clone()));
|
||
|
||
assert_eq!(
|
||
composer.history.navigate_up(&composer.app_event_tx),
|
||
Some(HistoryEntry::with_pending_and_remote(
|
||
expected_text,
|
||
expected_elements,
|
||
vec![local_image_path],
|
||
Vec::new(),
|
||
vec![remote_image_url],
|
||
))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn apply_history_entry_preserves_local_placeholders_after_remote_prefix() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let remote_image_url = "https://example.com/one.png".to_string();
|
||
let local_image_path = PathBuf::from("/tmp/local-draft.png");
|
||
composer.apply_history_entry(HistoryEntry::with_pending_and_remote(
|
||
"[Image #2] draft".to_string(),
|
||
vec![TextElement::new(
|
||
(0.."[Image #2]".len()).into(),
|
||
Some("[Image #2]".to_string()),
|
||
)],
|
||
vec![local_image_path.clone()],
|
||
Vec::new(),
|
||
vec![remote_image_url.clone()],
|
||
));
|
||
|
||
let restored_text = composer.current_text();
|
||
assert_eq!(restored_text, "[Image #2] draft");
|
||
let restored_elements = composer.text_elements();
|
||
assert_eq!(restored_elements.len(), 1);
|
||
assert_eq!(
|
||
restored_elements[0].placeholder(&restored_text),
|
||
Some("[Image #2]")
|
||
);
|
||
assert_eq!(composer.local_image_paths(), vec![local_image_path]);
|
||
assert_eq!(composer.remote_image_urls(), vec![remote_image_url]);
|
||
}
|
||
|
||
/// 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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let (result, needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
assert_eq!(result, InputResult::None);
|
||
assert!(needs_redraw, "toggling overlay should request redraw");
|
||
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
|
||
|
||
// 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);
|
||
}
|
||
|
||
/// 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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_connectors_enabled(true);
|
||
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
|
||
assert!(matches!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_connectors_enabled(true);
|
||
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
|
||
assert!(matches!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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 plugin_mention_popup_snapshot() {
|
||
snapshot_composer_state("plugin_mention_popup", false, |composer| {
|
||
composer.set_text_content("$sa".to_string(), Vec::new(), Vec::new());
|
||
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
|
||
config_name: "sample@test".to_string(),
|
||
display_name: "Sample Plugin".to_string(),
|
||
description: Some(
|
||
"Plugin that includes the Figma MCP server and Skills for common workflows"
|
||
.to_string(),
|
||
),
|
||
has_skills: true,
|
||
mcp_server_names: vec!["sample".to_string()],
|
||
app_connector_ids: vec![codex_core::plugins::AppConnectorId(
|
||
"calendar".to_string(),
|
||
)],
|
||
}]));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn mention_popup_type_prefixes_snapshot() {
|
||
snapshot_composer_state_with_width("mention_popup_type_prefixes", 72, false, |composer| {
|
||
composer.set_connectors_enabled(true);
|
||
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
|
||
composer.set_skill_mentions(Some(vec![SkillMetadata {
|
||
name: "google-calendar-skill".to_string(),
|
||
description: "Find availability and plan event changes".to_string(),
|
||
short_description: None,
|
||
interface: Some(codex_core::skills::model::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,
|
||
permission_profile: None,
|
||
managed_network_override: None,
|
||
path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_connectors_enabled(true);
|
||
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
|
||
|
||
let connectors = vec![AppInfo {
|
||
id: "connector_1".to_string(),
|
||
name: "Notion".to_string(),
|
||
description: Some("Workspace docs".to_string()),
|
||
logo_url: None,
|
||
logo_url_dark: None,
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
|
||
|
||
composer.set_task_running(true);
|
||
|
||
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
|
||
assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay);
|
||
}
|
||
|
||
#[test]
|
||
fn test_current_at_token_basic_cases() {
|
||
let test_cases = vec![
|
||
// 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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||
assert!(composer.is_in_paste_burst());
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
|
||
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"),
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
}
|
||
|
||
/// 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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
|
||
|
||
assert_eq!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
||
let needs_redraw = composer.handle_paste(large.clone());
|
||
assert!(needs_redraw);
|
||
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
|
||
assert_eq!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_steer_enabled(true);
|
||
let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS);
|
||
composer.textarea.set_text_clearing_elements(&input);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. } if text == input
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn oversized_submit_reports_error_and_restores_draft() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_steer_enabled(true);
|
||
let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1);
|
||
composer.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(80)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(message.contains(&user_input_too_large_message(input.chars().count())));
|
||
found_error = true;
|
||
break;
|
||
}
|
||
}
|
||
assert!(found_error, "expected oversized-input error history cell");
|
||
}
|
||
|
||
#[test]
|
||
fn oversized_queued_submission_reports_error_and_restores_draft() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_steer_enabled(false);
|
||
let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1);
|
||
composer.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(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender.clone(),
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
if let Some(text) = input {
|
||
composer.handle_paste(text);
|
||
} else if name == "multiple_pastes" {
|
||
// 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", false, |composer| {
|
||
composer.attach_image(PathBuf::from("/tmp/image1.png"));
|
||
});
|
||
|
||
snapshot_composer_state("image_placeholder_multiple", false, |composer| {
|
||
composer.attach_image(PathBuf::from("/tmp/image1.png"));
|
||
composer.attach_image(PathBuf::from("/tmp/image2.png"));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn remote_image_rows_snapshots() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
snapshot_composer_state("remote_image_rows", false, |composer| {
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
|
||
});
|
||
|
||
snapshot_composer_state("remote_image_rows_selected", false, |composer| {
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
|
||
composer.textarea.set_cursor(0);
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
});
|
||
|
||
snapshot_composer_state("remote_image_rows_after_delete_first", false, |composer| {
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new());
|
||
composer.textarea.set_cursor(0);
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn slash_popup_model_first_for_mo_ui() {
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TestBackend;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
// Type "/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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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")
|
||
}
|
||
Some(CommandItem::UserPrompt(_)) => {
|
||
panic!("unexpected prompt selected for '/mo'")
|
||
}
|
||
None => panic!("no selected command for '/mo'"),
|
||
},
|
||
_ => panic!("slash popup not active after typing '/mo'"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn slash_popup_resume_for_res_ui() {
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TestBackend;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
// Type "/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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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")
|
||
}
|
||
Some(CommandItem::UserPrompt(_)) => {
|
||
panic!("unexpected prompt selected for '/res'")
|
||
}
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_steer_enabled(true);
|
||
composer.textarea.insert_str("restore me");
|
||
composer.textarea.set_cursor(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.textarea.insert_str("restore me");
|
||
composer.textarea.set_cursor(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_task_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(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 voice_transcription_disabled_treats_space_as_normal_input() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
true,
|
||
);
|
||
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
|
||
composer.move_cursor_to_end();
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||
let _ = composer.handle_key_event(KeyEvent::new_with_kind(
|
||
KeyCode::Char(' '),
|
||
KeyModifiers::NONE,
|
||
KeyEventKind::Release,
|
||
));
|
||
|
||
assert_eq!("x ", composer.textarea.text());
|
||
assert!(composer.voice_state.space_hold_started_at.is_none());
|
||
assert!(composer.voice_state.space_hold_element_id.is_none());
|
||
assert!(composer.voice_state.space_hold_trigger.is_none());
|
||
assert!(!composer.voice_state.space_hold_repeat_seen);
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[test]
|
||
fn space_hold_timeout_without_release_or_repeat_keeps_typed_space() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_voice_transcription_enabled(true);
|
||
|
||
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
|
||
composer.move_cursor_to_end();
|
||
let elem_id = "space-hold".to_string();
|
||
composer.textarea.insert_named_element(" ", elem_id.clone());
|
||
composer.voice_state.space_hold_started_at = Some(Instant::now());
|
||
composer.voice_state.space_hold_element_id = Some(elem_id);
|
||
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
|
||
composer.voice_state.key_release_supported = false;
|
||
composer.voice_state.space_hold_repeat_seen = false;
|
||
assert_eq!("x ", composer.textarea.text());
|
||
|
||
composer.process_space_hold_trigger();
|
||
|
||
assert_eq!("x ", composer.textarea.text());
|
||
assert!(composer.voice_state.space_hold_started_at.is_none());
|
||
assert!(!composer.voice_state.space_hold_repeat_seen);
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[test]
|
||
fn space_hold_timeout_with_repeat_uses_hold_path_without_release() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_voice_transcription_enabled(true);
|
||
|
||
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
|
||
composer.move_cursor_to_end();
|
||
let elem_id = "space-hold".to_string();
|
||
composer.textarea.insert_named_element(" ", elem_id.clone());
|
||
composer.voice_state.space_hold_started_at = Some(Instant::now());
|
||
composer.voice_state.space_hold_element_id = Some(elem_id);
|
||
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
|
||
composer.voice_state.key_release_supported = false;
|
||
composer.voice_state.space_hold_repeat_seen = true;
|
||
|
||
composer.process_space_hold_trigger();
|
||
|
||
assert_eq!("x ", composer.textarea.text());
|
||
assert!(composer.voice_state.space_hold_started_at.is_none());
|
||
assert!(!composer.voice_state.space_hold_repeat_seen);
|
||
if composer.is_recording() {
|
||
let _ = composer.stop_recording_and_start_transcription();
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[test]
|
||
fn space_hold_timeout_with_repeat_does_not_duplicate_existing_space() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_voice_transcription_enabled(true);
|
||
|
||
composer.set_text_content("x ".to_string(), Vec::new(), Vec::new());
|
||
composer.move_cursor_to_end();
|
||
let elem_id = "space-hold".to_string();
|
||
composer.textarea.insert_named_element(" ", elem_id.clone());
|
||
composer.voice_state.space_hold_started_at = Some(Instant::now());
|
||
composer.voice_state.space_hold_element_id = Some(elem_id);
|
||
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
|
||
composer.voice_state.key_release_supported = false;
|
||
composer.voice_state.space_hold_repeat_seen = true;
|
||
|
||
composer.process_space_hold_trigger();
|
||
|
||
assert_eq!("x ", composer.textarea.text());
|
||
assert!(composer.voice_state.space_hold_started_at.is_none());
|
||
assert!(!composer.voice_state.space_hold_repeat_seen);
|
||
if composer.is_recording() {
|
||
let _ = composer.stop_recording_and_start_transcription();
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[test]
|
||
fn replace_transcription_stops_spinner_for_placeholder() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let id = "voice-placeholder".to_string();
|
||
composer.textarea.insert_named_element("", id.clone());
|
||
let flag = Arc::new(AtomicBool::new(false));
|
||
composer
|
||
.spinner_stop_flags
|
||
.insert(id.clone(), Arc::clone(&flag));
|
||
|
||
composer.replace_transcription(&id, "transcribed text");
|
||
|
||
assert!(flag.load(Ordering::Relaxed));
|
||
assert!(!composer.spinner_stop_flags.contains_key(&id));
|
||
assert_eq!(composer.textarea.text(), "transcribed text");
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[test]
|
||
fn set_text_content_stops_all_transcription_spinners() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let flag_one = Arc::new(AtomicBool::new(false));
|
||
let flag_two = Arc::new(AtomicBool::new(false));
|
||
composer
|
||
.spinner_stop_flags
|
||
.insert("voice-1".to_string(), Arc::clone(&flag_one));
|
||
composer
|
||
.spinner_stop_flags
|
||
.insert("voice-2".to_string(), Arc::clone(&flag_two));
|
||
|
||
composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());
|
||
|
||
assert!(flag_one.load(Ordering::Relaxed));
|
||
assert!(flag_two.load(Ordering::Relaxed));
|
||
assert!(composer.spinner_stop_flags.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn extract_args_supports_quoted_paths_single_arg() {
|
||
let args = extract_positional_args_for_prompt_line(
|
||
"/prompts:review \"docs/My File.md\"",
|
||
"review",
|
||
&[],
|
||
);
|
||
assert_eq!(
|
||
args,
|
||
vec![PromptArg {
|
||
text: "docs/My File.md".to_string(),
|
||
text_elements: Vec::new(),
|
||
}]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_args_supports_mixed_quoted_and_unquoted() {
|
||
let args = extract_positional_args_for_prompt_line(
|
||
"/prompts:cmd \"with spaces\" simple",
|
||
"cmd",
|
||
&[],
|
||
);
|
||
assert_eq!(
|
||
args,
|
||
vec![
|
||
PromptArg {
|
||
text: "with spaces".to_string(),
|
||
text_elements: Vec::new(),
|
||
},
|
||
PromptArg {
|
||
text: "simple".to_string(),
|
||
text_elements: Vec::new(),
|
||
}
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn slash_tab_completion_moves_cursor_to_end() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['/', 'c']);
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
|
||
assert_eq!(composer.textarea.text(), "/compact ");
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_collaboration_modes_enabled(true);
|
||
|
||
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
|
||
|
||
let text = composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_collaboration_modes_enabled(true);
|
||
|
||
type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']);
|
||
|
||
let text = composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
|
||
|
||
let text = composer.textarea.text().to_string();
|
||
let elements = composer.textarea.text_elements();
|
||
assert_eq!(text, "/review ");
|
||
assert_eq!(elements.len(), 1);
|
||
|
||
composer.textarea.set_cursor(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['h', 'i']);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { ref text, .. } if text == "hi"
|
||
));
|
||
assert!(composer.textarea.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn tab_does_not_submit_for_bang_shell_command() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_task_running(false);
|
||
|
||
type_chars_humanlike(&mut composer, &['!', 'l', 's']);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(result, InputResult::None));
|
||
assert!(
|
||
composer.textarea.text().starts_with("!ls"),
|
||
"expected Tab not to submit or clear a `!` command"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn slash_mention_dispatches_command_and_inserts_at() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
match result {
|
||
InputResult::Command(cmd) => {
|
||
assert_eq!(cmd.command(), "mention");
|
||
}
|
||
InputResult::CommandWithArgs(_, _, _) => {
|
||
panic!("expected command dispatch without args for '/mention'")
|
||
}
|
||
InputResult::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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_collaboration_modes_enabled(true);
|
||
|
||
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
|
||
let placeholder = local_image_label_text(1);
|
||
composer.attach_image(PathBuf::from("/tmp/plan.png"));
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
match result {
|
||
InputResult::CommandWithArgs(cmd, args, text_elements) => {
|
||
assert_eq!(cmd.command(), "plan");
|
||
assert_eq!(args, placeholder);
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(
|
||
text_elements[0].placeholder(&args),
|
||
Some(placeholder.as_str())
|
||
);
|
||
}
|
||
_ => panic!("expected CommandWithArgs for /plan with args"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn file_completion_preserves_large_paste_placeholder_elements() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
|
||
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
|
||
|
||
composer.handle_paste(large.clone());
|
||
composer.insert_str(" @ma");
|
||
composer.on_file_search_result(
|
||
"ma".to_string(),
|
||
vec![FileMatch {
|
||
score: 1,
|
||
path: PathBuf::from("src/main.rs"),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
|
||
let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count());
|
||
let placeholder_second = format!("{placeholder_base} #2");
|
||
|
||
composer.handle_paste(paste.clone());
|
||
composer.handle_paste(paste.clone());
|
||
assert_eq!(
|
||
composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
|
||
let base = format!("[Pasted Content {} chars]", paste.chars().count());
|
||
let second = format!("{base} #2");
|
||
let third = format!("{base} #3");
|
||
|
||
composer.handle_paste(paste.clone());
|
||
composer.handle_paste(paste.clone());
|
||
assert_eq!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
let path = PathBuf::from("/tmp/image1.png");
|
||
composer.attach_image(path.clone());
|
||
composer.handle_paste(" hi".into());
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
assert_eq!(text, "[Image #1] hi");
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||
assert_eq!(
|
||
text_elements[0].byte_range,
|
||
ByteRange {
|
||
start: 0,
|
||
end: "[Image #1]".len()
|
||
}
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
let imgs = composer.take_recent_submission_images();
|
||
assert_eq!(vec![path], imgs);
|
||
}
|
||
|
||
#[test]
|
||
fn submit_captures_recent_mention_bindings_before_clearing_textarea() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let mention_bindings = vec![MentionBinding {
|
||
mention: "figma".to_string(),
|
||
path: "/tmp/user/figma/SKILL.md".to_string(),
|
||
}];
|
||
composer.set_text_content_with_mention_bindings(
|
||
"$figma please".to_string(),
|
||
Vec::new(),
|
||
Vec::new(),
|
||
mention_bindings.clone(),
|
||
);
|
||
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||
assert_eq!(
|
||
composer.take_recent_submission_mention_bindings(),
|
||
mention_bindings
|
||
);
|
||
assert!(composer.take_mention_bindings().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn history_navigation_restores_remote_and_local_image_attachments() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
let remote_image_url = "https://example.com/remote.png".to_string();
|
||
composer.set_remote_image_urls(vec![remote_image_url.clone()]);
|
||
let path = PathBuf::from("/tmp/image1.png");
|
||
composer.attach_image(path.clone());
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||
|
||
let _ = composer.take_remote_image_urls();
|
||
composer.set_text_content(String::new(), Vec::new(), Vec::new());
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
|
||
let text = composer.current_text();
|
||
assert_eq!(text, "[Image #2]");
|
||
let text_elements = composer.text_elements();
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]"));
|
||
assert_eq!(composer.local_image_paths(), vec![path]);
|
||
assert_eq!(composer.remote_image_urls(), vec![remote_image_url]);
|
||
}
|
||
|
||
#[test]
|
||
fn history_navigation_restores_remote_only_submissions() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
let remote_image_urls = vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
];
|
||
composer.set_remote_image_urls(remote_image_urls.clone());
|
||
|
||
let (submitted_text, submitted_elements) = composer
|
||
.prepare_submission_text(true)
|
||
.expect("remote-only submission should be prepared");
|
||
assert_eq!(submitted_text, "");
|
||
assert!(submitted_elements.is_empty());
|
||
|
||
let _ = composer.take_remote_image_urls();
|
||
composer.set_text_content(String::new(), Vec::new(), Vec::new());
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
assert_eq!(composer.current_text(), "");
|
||
assert!(composer.text_elements().is_empty());
|
||
assert_eq!(composer.remote_image_urls(), remote_image_urls);
|
||
}
|
||
|
||
#[test]
|
||
fn history_navigation_leaves_cursor_at_end_of_line() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']);
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||
|
||
type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']);
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(matches!(result, InputResult::Submitted { .. }));
|
||
|
||
let (_result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
assert_eq!(composer.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 set_text_content_reattaches_images_without_placeholder_metadata() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let placeholder = local_image_label_text(1);
|
||
let text = format!("{placeholder} restored");
|
||
let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)];
|
||
let path = PathBuf::from("/tmp/image1.png");
|
||
|
||
composer.set_text_content(text, text_elements, vec![path.clone()]);
|
||
|
||
assert_eq!(composer.local_image_paths(), vec![path]);
|
||
}
|
||
|
||
#[test]
|
||
fn large_paste_preserves_image_text_elements_on_submit() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
|
||
composer.handle_paste(large_content.clone());
|
||
composer.handle_paste(" ".into());
|
||
let path = PathBuf::from("/tmp/image_with_paste.png");
|
||
composer.attach_image(path.clone());
|
||
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
let expected = format!("{large_content} [Image #1]");
|
||
assert_eq!(text, expected);
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||
assert_eq!(
|
||
text_elements[0].byte_range,
|
||
ByteRange {
|
||
start: large_content.len() + 1,
|
||
end: large_content.len() + 1 + "[Image #1]".len(),
|
||
}
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
let imgs = composer.take_recent_submission_images();
|
||
assert_eq!(vec![path], imgs);
|
||
}
|
||
|
||
#[test]
|
||
fn large_paste_with_leading_whitespace_trims_and_shifts_elements() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5));
|
||
composer.handle_paste(large_content.clone());
|
||
composer.handle_paste(" ".into());
|
||
let path = PathBuf::from("/tmp/image_with_trim.png");
|
||
composer.attach_image(path.clone());
|
||
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
let trimmed = large_content.trim().to_string();
|
||
assert_eq!(text, format!("{trimmed} [Image #1]"));
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||
assert_eq!(
|
||
text_elements[0].byte_range,
|
||
ByteRange {
|
||
start: trimmed.len() + 1,
|
||
end: trimmed.len() + 1 + "[Image #1]".len(),
|
||
}
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
let imgs = composer.take_recent_submission_images();
|
||
assert_eq!(vec![path], imgs);
|
||
}
|
||
|
||
#[test]
|
||
fn pasted_crlf_normalizes_newlines_for_elements() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let pasted = "line1\r\nline2\r\n".to_string();
|
||
composer.handle_paste(pasted);
|
||
composer.handle_paste(" ".into());
|
||
let path = PathBuf::from("/tmp/image_crlf.png");
|
||
composer.attach_image(path.clone());
|
||
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
assert_eq!(text, "line1\nline2\n [Image #1]");
|
||
assert!(!text.contains('\r'));
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||
assert_eq!(
|
||
text_elements[0].byte_range,
|
||
ByteRange {
|
||
start: "line1\nline2\n ".len(),
|
||
end: "line1\nline2\n [Image #1]".len(),
|
||
}
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
let imgs = composer.take_recent_submission_images();
|
||
assert_eq!(vec![path], imgs);
|
||
}
|
||
|
||
#[test]
|
||
fn suppressed_submission_restores_pending_paste_payload() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.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(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
let path = PathBuf::from("/tmp/image2.png");
|
||
composer.attach_image(path.clone());
|
||
let (result, _) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
assert_eq!(text, "[Image #1]");
|
||
assert_eq!(text_elements.len(), 1);
|
||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||
assert_eq!(
|
||
text_elements[0].byte_range,
|
||
ByteRange {
|
||
start: 0,
|
||
end: "[Image #1]".len()
|
||
}
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
let imgs = composer.take_recent_submission_images();
|
||
assert_eq!(imgs.len(), 1);
|
||
assert_eq!(imgs[0], path);
|
||
assert!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
let path = PathBuf::from("/tmp/image_dup.png");
|
||
composer.attach_image(path.clone());
|
||
composer.handle_paste(" ".into());
|
||
composer.attach_image(path);
|
||
|
||
let text = composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
let path = PathBuf::from("/tmp/image3.png");
|
||
composer.attach_image(path.clone());
|
||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||
|
||
// Case 1: backspace at end
|
||
composer.textarea.move_cursor_to_end_of_line(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
||
|
||
composer.attach_image(path1);
|
||
// 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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let path1 = PathBuf::from("/tmp/image_first.png");
|
||
let path2 = PathBuf::from("/tmp/image_second.png");
|
||
let placeholder1 = local_image_label_text(1);
|
||
let placeholder2 = local_image_label_text(2);
|
||
|
||
// 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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let path1 = PathBuf::from("/tmp/image_first.png");
|
||
let path2 = PathBuf::from("/tmp/image_second.png");
|
||
|
||
// 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(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
|
||
assert!(needs_redraw);
|
||
assert!(composer.textarea.text().starts_with("[Image #1] "));
|
||
|
||
let imgs = composer.take_recent_submission_images();
|
||
assert_eq!(imgs, vec![tmp_path]);
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_custom_prompt_without_args_submits_content() {
|
||
let prompt_text = "Hello from saved prompt";
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
// Inject prompts as if received via event.
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: prompt_text.to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
type_chars_humanlike(
|
||
&mut composer,
|
||
&[
|
||
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm',
|
||
'p', 't',
|
||
],
|
||
);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. } if text == prompt_text
|
||
));
|
||
assert!(composer.textarea.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_submission_expands_arguments() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Review $USER changes on $BRANCH".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt USER=Alice BRANCH=main");
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. }
|
||
if text == "Review Alice changes on main"
|
||
));
|
||
assert!(composer.textarea.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_submission_accepts_quoted_values() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Pair $USER with $BRANCH".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main");
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. }
|
||
if text == "Pair Alice Smith with dev-main"
|
||
));
|
||
assert!(composer.textarea.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_submission_preserves_image_placeholder_unquoted() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Review $IMG".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt IMG=");
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
let path = PathBuf::from("/tmp/image_prompt.png");
|
||
composer.attach_image(path);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
let placeholder = local_image_label_text(1);
|
||
assert_eq!(text, format!("Review {placeholder}"));
|
||
assert_eq!(
|
||
text_elements,
|
||
vec![TextElement::new(
|
||
ByteRange {
|
||
start: "Review ".len(),
|
||
end: "Review ".len() + placeholder.len(),
|
||
},
|
||
Some(placeholder),
|
||
)]
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_submission_preserves_image_placeholder_quoted() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Review $IMG".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt IMG=\"");
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
let path = PathBuf::from("/tmp/image_prompt_quoted.png");
|
||
composer.attach_image(path);
|
||
composer.handle_paste("\"".to_string());
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
let placeholder = local_image_label_text(1);
|
||
assert_eq!(text, format!("Review {placeholder}"));
|
||
assert_eq!(
|
||
text_elements,
|
||
vec![TextElement::new(
|
||
ByteRange {
|
||
start: "Review ".len(),
|
||
end: "Review ".len() + placeholder.len(),
|
||
},
|
||
Some(placeholder),
|
||
)]
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_submission_drops_unused_image_arg() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Review changes".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt IMG=");
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
let path = PathBuf::from("/tmp/unused_image.png");
|
||
composer.attach_image(path);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
assert_eq!(text, "Review changes");
|
||
assert!(text_elements.is_empty());
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
assert!(composer.take_recent_submission_images().is_empty());
|
||
}
|
||
|
||
/// Behavior: selecting a custom prompt that includes a large paste placeholder should expand
|
||
/// to the full pasted content before submission.
|
||
#[test]
|
||
fn custom_prompt_with_large_paste_expands_correctly() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
// Create a custom prompt with positional args (no named args like $USER)
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "code-review".to_string(),
|
||
path: "/tmp/code-review.md".to_string().into(),
|
||
content: "Please review the following code:\n\n$1".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
// Type the slash command
|
||
let command_text = "/prompts:code-review ";
|
||
composer.textarea.set_text_clearing_elements(command_text);
|
||
composer.textarea.set_cursor(command_text.len());
|
||
|
||
// Paste large content (>3000 chars) to trigger placeholder
|
||
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3000);
|
||
composer.handle_paste(large_content.clone());
|
||
|
||
// Verify placeholder was created
|
||
let placeholder = format!("[Pasted Content {} chars]", large_content.chars().count());
|
||
assert_eq!(
|
||
composer.textarea.text(),
|
||
format!("/prompts:code-review {}", placeholder)
|
||
);
|
||
assert_eq!(composer.pending_pastes.len(), 1);
|
||
assert_eq!(composer.pending_pastes[0].0, placeholder);
|
||
assert_eq!(composer.pending_pastes[0].1, large_content);
|
||
|
||
// Submit by pressing Enter
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
// Verify the custom prompt was expanded with the large content as positional arg
|
||
match result {
|
||
InputResult::Submitted { text, .. } => {
|
||
// The prompt should be expanded, with the large content replacing $1
|
||
assert_eq!(
|
||
text,
|
||
format!("Please review the following code:\n\n{}", large_content),
|
||
"Expected prompt expansion with large content as $1"
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted, got: {result:?}"),
|
||
}
|
||
assert!(composer.textarea.is_empty());
|
||
assert!(composer.pending_pastes.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_with_large_paste_and_image_preserves_elements() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Review $IMG\n\n$CODE".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt IMG=");
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
let path = PathBuf::from("/tmp/image_prompt_combo.png");
|
||
composer.attach_image(path);
|
||
composer.handle_paste(" CODE=".to_string());
|
||
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
|
||
composer.handle_paste(large_content.clone());
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match result {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
let placeholder = local_image_label_text(1);
|
||
assert_eq!(text, format!("Review {placeholder}\n\n{large_content}"));
|
||
assert_eq!(
|
||
text_elements,
|
||
vec![TextElement::new(
|
||
ByteRange {
|
||
start: "Review ".len(),
|
||
end: "Review ".len() + placeholder.len(),
|
||
},
|
||
Some(placeholder),
|
||
)]
|
||
);
|
||
}
|
||
_ => panic!("expected Submitted"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn slash_path_input_submits_without_command_error() {
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer
|
||
.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_invalid_args_reports_error() {
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Review $USER changes".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt USER=Alice stray");
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_eq!(InputResult::None, result);
|
||
assert_eq!(
|
||
"/prompts:my-prompt USER=Alice stray",
|
||
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(80)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(message.contains("expected key=value"));
|
||
found_error = true;
|
||
break;
|
||
}
|
||
}
|
||
assert!(found_error, "expected error history cell to be sent");
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_command_is_stubbed_when_prompt_listing_is_unavailable() {
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt");
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_eq!(InputResult::None, result);
|
||
assert_eq!("/prompts:my-prompt", composer.textarea.text());
|
||
|
||
let AppEvent::InsertHistoryCell(cell) = rx.try_recv().expect("expected stub history cell")
|
||
else {
|
||
panic!("expected stub history cell");
|
||
};
|
||
let message = cell
|
||
.display_lines(80)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(message.contains("Not available in app-server TUI yet."));
|
||
}
|
||
|
||
#[test]
|
||
fn custom_prompt_missing_required_args_reports_error() {
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Review $USER changes on $BRANCH".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
// Provide only one of the required args
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt USER=Alice");
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_eq!(InputResult::None, result);
|
||
assert_eq!("/prompts:my-prompt USER=Alice", 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(80)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(message.to_lowercase().contains("missing required args"));
|
||
assert!(message.contains("BRANCH"));
|
||
found_error = true;
|
||
break;
|
||
}
|
||
}
|
||
assert!(
|
||
found_error,
|
||
"expected missing args error history cell to be sent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_custom_prompt_with_args_expands_placeholders() {
|
||
// Support $1..$9 and $ARGUMENTS in prompt content.
|
||
let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n";
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: prompt_text.to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
// Type the slash command with two args and hit Enter to submit.
|
||
type_chars_humanlike(
|
||
&mut composer,
|
||
&[
|
||
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm',
|
||
'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r',
|
||
],
|
||
);
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string();
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. } if text == expected
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn popup_prompt_submission_prunes_unused_image_attachments() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Hello".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer.attach_image(PathBuf::from("/tmp/unused.png"));
|
||
composer.textarea.set_cursor(0);
|
||
composer.handle_paste(format!("/{PROMPTS_CMD_PREFIX}:my-prompt "));
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. } if text == "Hello"
|
||
));
|
||
assert!(
|
||
composer
|
||
.take_recent_submission_images_with_placeholders()
|
||
.is_empty()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn numeric_prompt_auto_submit_prunes_unused_image_attachments() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Hello $1".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
type_chars_humanlike(
|
||
&mut composer,
|
||
&[
|
||
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm',
|
||
'p', 't', ' ', 'f', 'o', 'o', ' ',
|
||
],
|
||
);
|
||
composer.attach_image(PathBuf::from("/tmp/unused.png"));
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. } if text == "Hello foo"
|
||
));
|
||
assert!(
|
||
composer
|
||
.take_recent_submission_images_with_placeholders()
|
||
.is_empty()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn numeric_prompt_auto_submit_expands_pending_pastes() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Echo: $1".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt ");
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
|
||
composer.handle_paste(large_content.clone());
|
||
|
||
assert_eq!(composer.pending_pastes.len(), 1);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
let expected = format!("Echo: {large_content}");
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. } if text == expected
|
||
));
|
||
assert!(composer.pending_pastes.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn queued_prompt_submission_prunes_unused_image_attachments() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Hello $1".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:my-prompt foo ");
|
||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||
composer.attach_image(PathBuf::from("/tmp/unused.png"));
|
||
composer.set_task_running(true);
|
||
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Queued { text, .. } if text == "Hello foo"
|
||
));
|
||
assert!(
|
||
composer
|
||
.take_recent_submission_images_with_placeholders()
|
||
.is_empty()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_expansion_over_character_limit_reports_error_and_restores_draft() {
|
||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
composer.set_steer_enabled(true);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: "Echo: $1".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
let oversized_arg = "x".repeat(MAX_USER_INPUT_TEXT_CHARS);
|
||
let original_input = format!("/prompts:my-prompt {oversized_arg}");
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements(&original_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(), original_input);
|
||
|
||
let actual_chars = format!("Echo: {oversized_arg}").chars().count();
|
||
let mut found_error = false;
|
||
while let Ok(event) = rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
let message = cell
|
||
.display_lines(80)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(message.contains(&user_input_too_large_message(actual_chars)));
|
||
found_error = true;
|
||
break;
|
||
}
|
||
}
|
||
assert!(found_error, "expected oversized-input error history cell");
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_custom_prompt_with_positional_args_submits_numeric_expansion() {
|
||
let prompt_text = "Header: $1\nArgs: $ARGUMENTS\n";
|
||
|
||
let prompt = CustomPrompt {
|
||
name: "my-prompt".to_string(),
|
||
path: "/tmp/my-prompt.md".to_string().into(),
|
||
content: prompt_text.to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
};
|
||
|
||
let action = prompt_selection_action(
|
||
&prompt,
|
||
"/prompts:my-prompt foo bar",
|
||
PromptSelectionMode::Submit,
|
||
&[],
|
||
);
|
||
match action {
|
||
PromptSelectionAction::Submit {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
assert_eq!(text, "Header: foo\nArgs: foo bar\n");
|
||
assert!(text_elements.is_empty());
|
||
}
|
||
_ => panic!("expected Submit action"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn numeric_prompt_positional_args_does_not_error() {
|
||
// Ensure that a prompt with only numeric placeholders does not trigger
|
||
// key=value parsing errors when given positional arguments.
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "elegant".to_string(),
|
||
path: "/tmp/elegant.md".to_string().into(),
|
||
content: "Echo: $ARGUMENTS".to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
// Type positional args; should submit with numeric expansion, no errors.
|
||
composer
|
||
.textarea
|
||
.set_text_clearing_elements("/prompts:elegant hi");
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. } if text == "Echo: hi"
|
||
));
|
||
assert!(composer.textarea.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_custom_prompt_with_no_args_inserts_template() {
|
||
let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]";
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "p".to_string(),
|
||
path: "/tmp/p.md".to_string().into(),
|
||
content: prompt_text.to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
type_chars_humanlike(
|
||
&mut composer,
|
||
&['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'],
|
||
);
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
// With no args typed, selecting the prompt inserts the command template
|
||
// and does not submit immediately.
|
||
assert_eq!(InputResult::None, result);
|
||
assert_eq!("/prompts:p ", composer.textarea.text());
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_custom_prompt_preserves_literal_dollar_dollar() {
|
||
// '$$' should remain untouched.
|
||
let prompt_text = "Cost: $$ and first: $1";
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "price".to_string(),
|
||
path: "/tmp/price.md".to_string().into(),
|
||
content: prompt_text.to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
type_chars_humanlike(
|
||
&mut composer,
|
||
&[
|
||
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x',
|
||
],
|
||
);
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. }
|
||
if text == "Cost: $$ and first: x"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_custom_prompt_reuses_cached_arguments_join() {
|
||
let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS";
|
||
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_custom_prompts(vec![CustomPrompt {
|
||
name: "repeat".to_string(),
|
||
path: "/tmp/repeat.md".to_string().into(),
|
||
content: prompt_text.to_string(),
|
||
description: None,
|
||
argument_hint: None,
|
||
}]);
|
||
|
||
type_chars_humanlike(
|
||
&mut composer,
|
||
&[
|
||
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ',
|
||
'o', 'n', 'e', ' ', 't', 'w', 'o',
|
||
],
|
||
);
|
||
let (result, _needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
let expected = "First: one two\nSecond: one two".to_string();
|
||
assert!(matches!(
|
||
result,
|
||
InputResult::Submitted { text, .. } if text == expected
|
||
));
|
||
}
|
||
|
||
/// 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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
|
||
assert!(composer.is_in_paste_burst());
|
||
assert!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let count = 32;
|
||
let mut now = Instant::now();
|
||
let step = Duration::from_millis(1);
|
||
for _ in 0..count {
|
||
let _ = composer.handle_input_basic_with_time(
|
||
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
|
||
now,
|
||
);
|
||
assert!(
|
||
composer.is_in_paste_burst(),
|
||
"expected active paste burst during fast typing"
|
||
);
|
||
assert!(
|
||
composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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 apply_external_edit_rebuilds_text_and_attachments() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let placeholder = local_image_label_text(1);
|
||
composer.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_drops_missing_attachments() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let placeholder = local_image_label_text(1);
|
||
composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let first_path = PathBuf::from("img1.png");
|
||
let second_path = PathBuf::from("img2.png");
|
||
composer.attach_image(first_path);
|
||
composer.attach_image(second_path.clone());
|
||
|
||
let placeholder2 = local_image_label_text(2);
|
||
composer.apply_external_edit(format!("Keep {placeholder2}"));
|
||
|
||
let placeholder1 = local_image_label_text(1);
|
||
assert_eq!(composer.current_text(), format!("Keep {placeholder1}"));
|
||
assert_eq!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
let placeholder = local_image_label_text(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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
|
||
assert_eq!(composer.current_text(), "");
|
||
assert_eq!(composer.text_elements(), Vec::<TextElement>::new());
|
||
}
|
||
|
||
#[test]
|
||
fn attach_image_after_remote_prefix_uses_offset_label() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
composer.attach_image(PathBuf::from("/tmp/local.png"));
|
||
|
||
assert_eq!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
|
||
let base_text = "[Image #2] hello".to_string();
|
||
let base_elements = vec![TextElement::new(
|
||
(0.."[Image #2]".len()).into(),
|
||
Some("[Image #2]".to_string()),
|
||
)];
|
||
composer.set_text_content(
|
||
base_text,
|
||
base_elements,
|
||
vec![PathBuf::from("/tmp/local.png")],
|
||
);
|
||
|
||
let (submitted_text, submitted_elements) = composer
|
||
.prepare_submission_text(true)
|
||
.expect("remote+local submission should be generated");
|
||
assert_eq!(submitted_text, "[Image #2] hello");
|
||
assert_eq!(
|
||
submitted_elements,
|
||
vec![TextElement::new(
|
||
(0.."[Image #2]".len()).into(),
|
||
Some("[Image #2]".to_string())
|
||
)]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn prepare_submission_with_only_remote_images_returns_empty_text() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
|
||
let (submitted_text, submitted_elements) = composer
|
||
.prepare_submission_text(true)
|
||
.expect("remote-only submission should be generated");
|
||
assert_eq!(submitted_text, "");
|
||
assert!(submitted_elements.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn delete_selected_remote_image_relabels_local_placeholders() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let sender = AppEventSender::new(tx);
|
||
let mut composer = ChatComposer::new(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_remote_image_urls(vec![
|
||
"https://example.com/one.png".to_string(),
|
||
"https://example.com/two.png".to_string(),
|
||
]);
|
||
composer.attach_image(PathBuf::from("/tmp/local.png"));
|
||
composer.textarea.set_cursor(0);
|
||
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
|
||
assert_eq!(
|
||
composer.remote_image_urls(),
|
||
vec!["https://example.com/one.png".to_string()]
|
||
);
|
||
assert_eq!(composer.current_text(), "[Image #2]");
|
||
assert_eq!(composer.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(
|
||
true,
|
||
sender,
|
||
false,
|
||
"Ask Codex to do anything".to_string(),
|
||
false,
|
||
);
|
||
|
||
composer.set_text_content("hello".to_string(), Vec::new(), Vec::new());
|
||
composer.set_input_enabled(false, Some("Input disabled for test.".to_string()));
|
||
|
||
let (result, needs_redraw) =
|
||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||
|
||
assert_eq!(result, InputResult::None);
|
||
assert!(!needs_redraw);
|
||
assert_eq!(composer.current_text(), "hello");
|
||
|
||
let area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 40,
|
||
height: 5,
|
||
};
|
||
assert_eq!(composer.cursor_pos(area), None);
|
||
}
|
||
}
|