mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Stack 1. [1 of 3] Support long raw TUI goal objectives - #27508 2. **[2 of 3] Support long pasted text in TUI goals** - this PR 3. [3 of 3] Support images in TUI goals - #27510 ## Why Large text pasted into the TUI composer is represented as a paste placeholder plus pending paste metadata. For `/goal`, preserving only the visible placeholder is not enough: the agent would see a short placeholder string instead of the actual pasted text, and the long-text support from the first PR would never see the payload. The TUI also needs to avoid writing stale sidecar files when a user pastes a large block and then deletes its placeholder before submitting the goal. ## What Changed - Introduces a TUI `GoalDraft` for goal submissions so `/goal`, `/goal edit`, and queued goal commands can carry objective text plus text elements and pending paste payloads. - Materializes active pasted-text placeholders to `pasted-text-N.txt` files through the app-server filesystem path introduced in #27508. - Rewrites active paste placeholders in the persisted objective to file references, while leaving literal placeholder-looking text alone. - Filters out deleted paste placeholders so otherwise-small goals do not require `$CODEX_HOME` or remote filesystem writes. - Preserves pending paste metadata when a `/goal` command is queued before a thread exists. ## Verification - Added goal materialization tests for active paste placeholders, deleted paste placeholders, and whitespace-only paste payloads. - Added/updated TUI slash-command tests for large pasted text, queued `/goal` commands before thread start, and queued oversized goal behavior. ## Manual Testing - Used real terminal bracketed-paste sequences through a remote TUI session. A 1,228-byte multiline paste became `pasted-text-1.txt`; its first/last lines and byte count matched exactly, and the persisted objective referenced the server-host path. - Pasted a large block, deleted its placeholder, and submitted a small replacement objective. No new directory or sidecar file was created. - Added two same-length large pastes to one goal. The composer disambiguated their visible placeholders, and materialization preserved order and contents in `pasted-text-1.txt` and `pasted-text-2.txt`. - Submitted a whitespace-only large paste and verified the goal was rejected as empty without writing a file. - Submitted a pasted-text replacement while another goal was active, verified no file was written before confirmation, then canceled and confirmed the original goal remained unchanged. - Combined a large paste with enough raw text to exceed 4,000 characters after placeholder rewriting. The paste sidecar and `goal-objective.md` were created in the same remote attachment directory, and `/goal edit` restored the rewritten objective with its sidecar reference.
481 lines
18 KiB
Rust
481 lines
18 KiB
Rust
//! Key routing and composer-adjacent UI interaction for `ChatWidget`.
|
|
|
|
use super::*;
|
|
|
|
impl ChatWidget {
|
|
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
|
if self.bottom_pane.has_active_view()
|
|
&& !matches!(
|
|
key_event,
|
|
KeyEvent {
|
|
code: KeyCode::Char(c),
|
|
modifiers,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c')
|
|
)
|
|
&& !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event)
|
|
&& !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event)
|
|
{
|
|
self.bottom_pane.handle_key_event(key_event);
|
|
if self.bottom_pane.no_modal_or_popup_active() {
|
|
self.maybe_send_next_queued_input();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if self.handle_reasoning_shortcut(key_event) {
|
|
self.bottom_pane.clear_quit_shortcut_hint();
|
|
self.quit_shortcut_expires_at = None;
|
|
self.quit_shortcut_key = None;
|
|
return;
|
|
}
|
|
|
|
if key_event.kind == KeyEventKind::Press
|
|
&& self.copy_last_response_binding.is_pressed(key_event)
|
|
{
|
|
self.bottom_pane.clear_quit_shortcut_hint();
|
|
self.quit_shortcut_expires_at = None;
|
|
self.quit_shortcut_key = None;
|
|
self.copy_last_agent_markdown();
|
|
return;
|
|
}
|
|
|
|
match key_event {
|
|
KeyEvent {
|
|
code: KeyCode::Char(c),
|
|
modifiers,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => {
|
|
self.on_ctrl_c();
|
|
return;
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Char(c),
|
|
modifiers,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
|
|
if self.on_ctrl_d() {
|
|
return;
|
|
}
|
|
self.bottom_pane.clear_quit_shortcut_hint();
|
|
self.quit_shortcut_expires_at = None;
|
|
self.quit_shortcut_key = None;
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Char(c),
|
|
modifiers,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
|
&& c.eq_ignore_ascii_case(&'v') =>
|
|
{
|
|
match paste_image_to_temp_png() {
|
|
Ok((path, info)) => {
|
|
tracing::debug!(
|
|
"pasted image size={}x{} format={}",
|
|
info.width,
|
|
info.height,
|
|
info.encoded_format.label()
|
|
);
|
|
self.attach_image(path);
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!("failed to paste image: {err}");
|
|
self.add_to_history(history_cell::new_error_event(format!(
|
|
"Failed to paste image: {err}",
|
|
)));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
other if other.kind == KeyEventKind::Press => {
|
|
self.bottom_pane.clear_quit_shortcut_hint();
|
|
self.quit_shortcut_expires_at = None;
|
|
self.quit_shortcut_key = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if key_event.kind == KeyEventKind::Press
|
|
&& self.chat_keymap.edit_queued_message.is_pressed(key_event)
|
|
&& self.has_queued_follow_up_messages()
|
|
&& self.bottom_pane.no_modal_or_popup_active()
|
|
{
|
|
if let Some(composer) = self.pop_latest_queued_composer_state() {
|
|
self.restore_composer_state(composer);
|
|
self.refresh_pending_input_preview();
|
|
self.request_redraw();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const REVIEW_STEER_UNAVAILABLE_MESSAGE: &str = "Steer messages aren't supported during /review. Press Ctrl+C now to cancel the review.";
|
|
|
|
if self.chat_keymap.interrupt_turn.is_pressed(key_event)
|
|
&& self.review.is_review_mode
|
|
&& (!self.input_queue.pending_steers.is_empty()
|
|
|| !self.input_queue.rejected_steers_queue.is_empty())
|
|
&& self.bottom_pane.is_task_running()
|
|
&& self.bottom_pane.no_modal_or_popup_active()
|
|
&& !self.should_handle_vim_insert_escape(key_event)
|
|
{
|
|
self.add_warning_message(REVIEW_STEER_UNAVAILABLE_MESSAGE.to_string());
|
|
return;
|
|
}
|
|
|
|
if self.chat_keymap.interrupt_turn.is_pressed(key_event)
|
|
&& !self.input_queue.pending_steers.is_empty()
|
|
&& self.bottom_pane.is_task_running()
|
|
&& self.bottom_pane.no_modal_or_popup_active()
|
|
&& !self.should_handle_vim_insert_escape(key_event)
|
|
{
|
|
self.input_queue.submit_pending_steers_after_interrupt = true;
|
|
if !self.submit_op(AppCommand::interrupt()) {
|
|
self.input_queue.submit_pending_steers_after_interrupt = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if matches!(key_event.code, KeyCode::Esc)
|
|
&& key_event.kind == KeyEventKind::Press
|
|
&& self.should_show_plan_mode_nudge()
|
|
{
|
|
self.dismiss_plan_mode_nudge();
|
|
return;
|
|
}
|
|
|
|
if self.handle_plugins_popup_key_event(key_event) {
|
|
return;
|
|
}
|
|
|
|
match key_event {
|
|
KeyEvent {
|
|
code: KeyCode::BackTab,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} if self.collaboration_modes_enabled()
|
|
&& !self.bottom_pane.is_task_running()
|
|
&& self.bottom_pane.no_modal_or_popup_active() =>
|
|
{
|
|
self.cycle_collaboration_mode();
|
|
self.refresh_plan_mode_nudge();
|
|
}
|
|
_ => {
|
|
let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active();
|
|
let input_result = self.bottom_pane.handle_key_event(key_event);
|
|
self.handle_composer_input_result(input_result, had_modal_or_popup);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Attach a local image to the composer when the active model supports image inputs.
|
|
///
|
|
/// When the model does not advertise image support, we keep the draft unchanged and surface a
|
|
/// warning event so users can switch models or remove attachments.
|
|
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
|
if !self.current_model_supports_images() {
|
|
self.add_to_history(history_cell::new_warning_event(
|
|
self.image_inputs_not_supported_message(),
|
|
));
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
tracing::info!("attach_image path={path:?}");
|
|
self.bottom_pane.attach_image(path);
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn composer_text_with_pending(&self) -> String {
|
|
self.bottom_pane.composer_text_with_pending()
|
|
}
|
|
|
|
pub(crate) fn apply_external_edit(&mut self, text: String) {
|
|
self.bottom_pane.apply_external_edit(text);
|
|
self.refresh_plan_mode_nudge();
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn external_editor_state(&self) -> ExternalEditorState {
|
|
self.external_editor_state
|
|
}
|
|
|
|
pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) {
|
|
self.external_editor_state = state;
|
|
}
|
|
|
|
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
|
self.bottom_pane.set_footer_hint_override(items);
|
|
}
|
|
|
|
pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) {
|
|
self.bottom_pane.show_selection_view(params);
|
|
self.refresh_plan_mode_nudge();
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
|
|
self.bottom_pane.no_modal_or_popup_active()
|
|
}
|
|
|
|
pub(crate) fn can_launch_external_editor(&self) -> bool {
|
|
self.bottom_pane.can_launch_external_editor()
|
|
}
|
|
|
|
pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool {
|
|
// Ctrl+L is not a slash command, but it follows /clear's current rule:
|
|
// block while a task is running.
|
|
if !self.bottom_pane.is_task_running() {
|
|
return true;
|
|
}
|
|
|
|
let message = "Ctrl+L is disabled while a task is in progress.".to_string();
|
|
self.add_to_history(history_cell::new_error_event(message));
|
|
self.request_redraw();
|
|
false
|
|
}
|
|
|
|
/// Copy the last agent response (raw markdown) to the system clipboard.
|
|
pub(crate) fn copy_last_agent_markdown(&mut self) {
|
|
self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard);
|
|
}
|
|
|
|
pub(crate) fn truncate_agent_copy_history_to_user_turn_count(
|
|
&mut self,
|
|
user_turn_count: usize,
|
|
) {
|
|
self.transcript
|
|
.truncate_copy_history_to_user_turn_count(user_turn_count);
|
|
}
|
|
|
|
/// Inner implementation with an injectable clipboard backend for testing.
|
|
pub(super) fn copy_last_agent_markdown_with(
|
|
&mut self,
|
|
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
|
|
) {
|
|
match self.transcript.last_agent_markdown.clone() {
|
|
Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) {
|
|
Ok(lease) => {
|
|
self.clipboard_lease = lease;
|
|
self.add_to_history(history_cell::new_info_event(
|
|
"Copied last message to clipboard".into(),
|
|
/*hint*/ None,
|
|
));
|
|
}
|
|
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
|
|
"Copy failed: {error}"
|
|
))),
|
|
},
|
|
_ if self.transcript.copy_history_evicted_by_rollback => {
|
|
self.add_to_history(history_cell::new_error_event(format!(
|
|
"Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy."
|
|
)));
|
|
}
|
|
_ => self.add_to_history(history_cell::new_error_event(
|
|
"No agent response to copy".into(),
|
|
)),
|
|
}
|
|
self.request_redraw();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
|
|
self.transcript.last_agent_markdown.as_deref()
|
|
}
|
|
|
|
pub(super) fn show_rename_prompt(&mut self) {
|
|
if !self.ensure_thread_rename_allowed() {
|
|
return;
|
|
}
|
|
let tx = self.app_event_tx.clone();
|
|
let existing_name = self.thread_name.as_deref().filter(|name| !name.is_empty());
|
|
let title = if existing_name.is_some() {
|
|
"Rename thread"
|
|
} else {
|
|
"Name thread"
|
|
};
|
|
let view = CustomPromptView::new(
|
|
title.to_string(),
|
|
"Type a name and press Enter".to_string(),
|
|
/*initial_text*/ existing_name.unwrap_or_default().to_string(),
|
|
/*context_label*/ None,
|
|
Box::new(move |name: String| {
|
|
let Some(name) = normalize_thread_name(&name) else {
|
|
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
history_cell::new_error_event("Thread name cannot be empty.".to_string()),
|
|
)));
|
|
return;
|
|
};
|
|
tx.set_thread_name(name);
|
|
}),
|
|
);
|
|
|
|
self.bottom_pane.show_view(Box::new(view));
|
|
}
|
|
|
|
pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool {
|
|
match self.thread_rename_block_message.clone() {
|
|
Some(message) => {
|
|
self.add_error_message(message);
|
|
false
|
|
}
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn handle_paste(&mut self, text: String) {
|
|
self.bottom_pane.handle_paste(text);
|
|
self.refresh_plan_mode_nudge();
|
|
}
|
|
|
|
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
|
|
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
|
|
if self.bottom_pane.flush_paste_burst_if_due() {
|
|
self.refresh_plan_mode_nudge();
|
|
// A paste just flushed; request an immediate redraw and skip this frame.
|
|
self.request_redraw();
|
|
true
|
|
} else if self.bottom_pane.is_in_paste_burst() {
|
|
// While capturing a burst, schedule a follow-up tick and skip this frame
|
|
// to avoid redundant renders between ticks.
|
|
frame_requester.schedule_frame_in(
|
|
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(),
|
|
);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Handles a Ctrl+C press at the chat-widget layer.
|
|
///
|
|
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
|
|
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
|
|
/// is armed.
|
|
///
|
|
/// When the double-press quit shortcut is enabled, pressing the same shortcut again before
|
|
/// expiry requests a shutdown-first quit.
|
|
fn on_ctrl_c(&mut self) {
|
|
let key = key_hint::ctrl(KeyCode::Char('c'));
|
|
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
|
|
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
|
|
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
|
if modal_or_popup_active {
|
|
self.quit_shortcut_expires_at = None;
|
|
self.quit_shortcut_key = None;
|
|
self.bottom_pane.clear_quit_shortcut_hint();
|
|
} else {
|
|
self.arm_quit_shortcut(key);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
|
if self.is_cancellable_work_active() {
|
|
self.quit_shortcut_expires_at = None;
|
|
self.quit_shortcut_key = None;
|
|
self.bottom_pane.clear_quit_shortcut_hint();
|
|
self.pause_active_goal_for_interrupt();
|
|
self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
|
|
} else {
|
|
self.request_quit_without_confirmation();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if self.quit_shortcut_active_for(key) {
|
|
self.quit_shortcut_expires_at = None;
|
|
self.quit_shortcut_key = None;
|
|
self.request_quit_without_confirmation();
|
|
return;
|
|
}
|
|
|
|
self.arm_quit_shortcut(key);
|
|
|
|
if self.is_cancellable_work_active() {
|
|
self.pause_active_goal_for_interrupt();
|
|
self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
|
|
}
|
|
}
|
|
|
|
/// Handles a Ctrl+D press at the chat-widget layer.
|
|
///
|
|
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
|
|
/// Otherwise it should be routed to the active view and not attempt to quit.
|
|
fn on_ctrl_d(&mut self) -> bool {
|
|
let key = key_hint::ctrl(KeyCode::Char('d'));
|
|
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
|
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
self.request_quit_without_confirmation();
|
|
return true;
|
|
}
|
|
|
|
if self.quit_shortcut_active_for(key) {
|
|
self.quit_shortcut_expires_at = None;
|
|
self.quit_shortcut_key = None;
|
|
self.request_quit_without_confirmation();
|
|
return true;
|
|
}
|
|
|
|
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
|
|
return false;
|
|
}
|
|
|
|
self.arm_quit_shortcut(key);
|
|
true
|
|
}
|
|
|
|
/// True if `key` matches the armed quit shortcut and the window has not expired.
|
|
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
|
|
self.quit_shortcut_key == Some(key)
|
|
&& self
|
|
.quit_shortcut_expires_at
|
|
.is_some_and(|expires_at| Instant::now() < expires_at)
|
|
}
|
|
|
|
/// Arm the double-press quit shortcut and show the footer hint.
|
|
///
|
|
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
|
|
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
|
|
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
|
|
pub(super) fn arm_quit_shortcut(&mut self, key: KeyBinding) {
|
|
self.quit_shortcut_expires_at = Instant::now()
|
|
.checked_add(QUIT_SHORTCUT_TIMEOUT)
|
|
.or_else(|| Some(Instant::now()));
|
|
self.quit_shortcut_key = Some(key);
|
|
self.bottom_pane.show_quit_shortcut_hint(key);
|
|
}
|
|
|
|
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
|
|
fn is_cancellable_work_active(&self) -> bool {
|
|
self.bottom_pane.is_task_running() || self.review.is_review_mode
|
|
}
|
|
|
|
fn pause_active_goal_for_interrupt(&self) {
|
|
if !self.turn_lifecycle.agent_turn_running {
|
|
return;
|
|
}
|
|
if !self
|
|
.current_goal_status
|
|
.as_ref()
|
|
.is_some_and(GoalStatusState::is_active)
|
|
{
|
|
return;
|
|
}
|
|
let Some(thread_id) = self.thread_id else {
|
|
return;
|
|
};
|
|
self.app_event_tx.send(AppEvent::SetThreadGoalStatus {
|
|
thread_id,
|
|
status: AppThreadGoalStatus::Paused,
|
|
});
|
|
}
|
|
}
|