diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eaf352a091..fdb11e5b5a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2875,6 +2875,7 @@ version = "0.0.0" dependencies = [ "codex-protocol", "codex-utils-string", + "pretty_assertions", ] [[package]] @@ -4621,6 +4622,7 @@ dependencies = [ "codex-cloud-config", "codex-config", "codex-connectors", + "codex-context-fragments", "codex-core-plugins", "codex-exec-server", "codex-features", diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index ecf963fe32..1de9bdd5d9 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -135,8 +135,12 @@ pub struct TuiChatKeymap { pub previous_permission_mode: Option, /// Switch to the next available permission mode. pub next_permission_mode: Option, - /// Edit the most recently queued message. + /// Move up through pending async questions, then edit the most recently queued message. pub edit_queued_message: Option, + /// Move back through pending async questions toward the composer. + pub prompt_stack_back: Option, + /// Skip the focused question. + pub skip_question: Option, } /// Composer context keybindings. These override corresponding `global` actions. diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index c668ee4e48..821824f52e 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -757,6 +757,10 @@ pub struct Tui { #[serde(default)] pub vim_mode_default: bool, + /// Escape returns from async questions to the composer, preserving the answer draft. + #[serde(default = "default_true")] + pub question_esc_back: bool, + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. /// Defaults to `false`. #[serde(default)] diff --git a/codex-rs/context-fragments/Cargo.toml b/codex-rs/context-fragments/Cargo.toml index ca84916ce9..936bd17e78 100644 --- a/codex-rs/context-fragments/Cargo.toml +++ b/codex-rs/context-fragments/Cargo.toml @@ -7,7 +7,6 @@ version.workspace = true [lib] name = "codex_context_fragments" path = "src/lib.rs" -test = false doctest = false [lints] @@ -16,3 +15,6 @@ workspace = true [dependencies] codex-protocol = { workspace = true } codex-utils-string = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/context-fragments/src/answered_question.rs b/codex-rs/context-fragments/src/answered_question.rs new file mode 100644 index 0000000000..5f9ef8a6db --- /dev/null +++ b/codex-rs/context-fragments/src/answered_question.rs @@ -0,0 +1,40 @@ +//! Bounded question framing accompanying an explicitly submitted user answer. + +use super::ContextualUserFragment; +use codex_protocol::models::ContentItemKind; + +/// Identifies an answered question without repeating an unbounded model-authored prompt. +pub struct AnsweredQuestion { + question: String, +} + +impl AnsweredQuestion { + pub fn new(question: &str) -> Self { + let end = question.floor_char_boundary(question.len().min(512)); + Self { + question: question[..end].to_string(), + } + } +} + +impl ContextualUserFragment for AnsweredQuestion { + fn content_kind(&self) -> ContentItemKind { + ContentItemKind("user.answered_question".into()) + } + fn role(&self) -> &'static str { + "user" + } + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + fn body(&self) -> String { + format!("> {}\n\n", self.question.replace(['\n', '\r'], " ")) + } +} + +#[cfg(test)] +#[path = "answered_question_tests.rs"] +mod tests; diff --git a/codex-rs/context-fragments/src/answered_question_tests.rs b/codex-rs/context-fragments/src/answered_question_tests.rs new file mode 100644 index 0000000000..34bc159de0 --- /dev/null +++ b/codex-rs/context-fragments/src/answered_question_tests.rs @@ -0,0 +1,18 @@ +//! Bounds and Unicode handling for model-authored question framing. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn question_context_is_bounded_and_keeps_unicode_boundaries() { + let text = "é\n".repeat(1_000); + let rendered = AnsweredQuestion::new(&text).render(); + assert!(rendered.len() <= 516); + assert_eq!( + rendered, + format!( + "> {}\n\n", + text[..text.floor_char_boundary(512)].replace('\n', " ") + ) + ); +} diff --git a/codex-rs/context-fragments/src/lib.rs b/codex-rs/context-fragments/src/lib.rs index ee1f077cb6..388ddf3984 100644 --- a/codex-rs/context-fragments/src/lib.rs +++ b/codex-rs/context-fragments/src/lib.rs @@ -1,3 +1,5 @@ +mod answered_question; +pub use answered_question::AnsweredQuestion; mod additional_context; mod annotated_content; mod fragment; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8be0a2f62a..d66fa6a104 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3889,6 +3889,8 @@ "interrupt_turn": null, "next_permission_mode": null, "previous_permission_mode": null, + "prompt_stack_back": null, + "skip_question": null, "toggle_voice_mute": null }, "composer": { @@ -4085,6 +4087,11 @@ "default": "composer", "description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport." }, + "question_esc_back": { + "default": true, + "description": "Escape returns from async questions to the composer, preserving the answer draft.", + "type": "boolean" + }, "raw_output_mode": { "default": false, "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", @@ -4303,7 +4310,7 @@ "$ref": "#/definitions/KeybindingsSpec" } ], - "description": "Edit the most recently queued message." + "description": "Move up through pending async questions, then edit the most recently queued message." }, "increase_reasoning_effort": { "allOf": [ @@ -4337,6 +4344,22 @@ ], "description": "Switch to the previous available permission mode." }, + "prompt_stack_back": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move back through pending async questions toward the composer." + }, + "skip_question": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Skip the focused question." + }, "toggle_voice_mute": { "allOf": [ { @@ -4690,6 +4713,8 @@ "interrupt_turn": null, "next_permission_mode": null, "previous_permission_mode": null, + "prompt_stack_back": null, + "skip_question": null, "toggle_voice_mute": null } }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index d84d47f1a2..a093aaf28f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1261,6 +1261,7 @@ fn config_toml_deserializes_model_availability_nux() { auto_recap: true, disable_paste_burst: None, vim_mode_default: false, + question_esc_back: true, raw_output_mode: false, alternate_screen: AltScreenMode::default(), status_line: None, @@ -4275,6 +4276,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { auto_recap: true, disable_paste_burst: None, vim_mode_default: false, + question_esc_back: true, raw_output_mode: false, alternate_screen: AltScreenMode::Auto, status_line: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a19fcc86d8..eadfc56cea 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -754,6 +754,7 @@ pub struct Config { /// Start the composer in Vim mode (`Normal`) by default. pub tui_vim_mode_default: bool, + pub tui_question_esc_back: bool, /// Start the TUI in raw scrollback mode for copy-friendly transcript output. pub tui_raw_output_mode: bool, @@ -4354,6 +4355,7 @@ impl Config { .as_ref() .map(|t| t.model_availability_nux.clone()) .unwrap_or_default(), + tui_question_esc_back: cfg.tui.as_ref().map(|t| t.question_esc_back).unwrap_or(true), tui_vim_mode_default: cfg .tui .as_ref() diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 65cbefe5fc..dac2accfe8 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -54,6 +54,7 @@ pub(crate) use available_plugins_instructions::AvailablePluginsInstructions; pub(crate) use base_instructions::BaseInstructionsFragment; pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment; pub(crate) use codex_context_fragments::AdditionalContextUserFragment; +pub use codex_context_fragments::AnsweredQuestion; pub use codex_context_fragments::ContextualUserFragment; pub(crate) use compaction_summary::CompactionSummary; pub(crate) use contextual_user_message::is_contextual_user_fragment; diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 461c2a4759..d13b3379d5 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -242,6 +242,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_session_picker_view: SessionPickerViewMode::Dense, tui_resume_cwd: None, tui_vim_mode_default: false, + tui_question_esc_back: true, cwd: cwd.clone(), workspace_roots: vec![cwd], workspace_roots_explicit: false, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 9dfded194b..455f2af6c1 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -34,6 +34,7 @@ codex-arg0 = { workspace = true } codex-install-context = { workspace = true } codex-cloud-config = { workspace = true } codex-config = { workspace = true } +codex-context-fragments = { workspace = true } codex-connectors = { workspace = true } codex-core-plugins = { workspace = true } codex-history = { workspace = true } diff --git a/codex-rs/tui/src/bottom_pane/async_questions/input.rs b/codex-rs/tui/src/bottom_pane/async_questions/input.rs new file mode 100644 index 0000000000..1b81cdf4be --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/input.rs @@ -0,0 +1,56 @@ +//! Route question actions through the shared composer, preserving drafts until delivery accepts them. + +use super::*; + +impl AsyncQuestions { + pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool { + self.composer.handles_key_as_editing(key) + } + + pub(super) fn edit(&mut self, key: KeyEvent) { + if key.kind == KeyEventKind::Repeat && self.keymap.composer.queue.is_pressed(key) { + return; + } + + if self.keymap.composer.submit.is_pressed(key) && !self.composer.is_in_paste_burst() { + self.composer.flush_pending_input(); + } + let before = self.composer.snapshot_draft(); + let (result, _) = self.composer.handle_key_event(key); + let queued = matches!(result, InputResult::Queued { .. }); + if matches!( + result, + InputResult::Submitted { .. } | InputResult::Queued { .. } + ) { + self.composer.restore_draft(before); + self.go_next_or_submit(); + if queued && let Some(QuestionSubmission::Submit(text)) = self.submission.take() { + self.submission = Some(QuestionSubmission::Queue(text)); + } + } + } + + pub(crate) fn set_keymap(&mut self, keymap: &RuntimeKeymap) { + self.keymap = keymap.clone(); + self.composer.set_keymap_bindings(keymap); + } + + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.composer.set_vim_enabled(enabled); + } +} + +impl BottomPaneView for AsyncQuestions { + fn is_complete(&self) -> bool { + self.unanswered_count() == 0 + } + fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + fn next_frame_delay(&self) -> Option { + self.composer.footer_flash_delay() + } + fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() + } +} diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs new file mode 100644 index 0000000000..ebfb42c251 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -0,0 +1,118 @@ +//! Inline editing for asynchronous questions. Legacy request_user_input keeps its own overlay. +//! Only locally accepted submissions remove questions; arrival and expiry never steal focus. + +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::ChatComposerConfig; +use crate::bottom_pane::InputResult; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::chat_composer::ComposerDraft; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::RuntimeKeymap; +use codex_protocol::items::AsyncUserInputQuestion; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use std::collections::HashSet; + +mod input; +mod render; +mod state; + +#[derive(Debug, Clone, PartialEq)] +struct PendingQuestion { + question: AsyncUserInputQuestion, + draft: ComposerDraft, +} + +/// Locally retained async questions; replayed message IDs cannot resurrect handled answers. +#[derive(Debug, Clone, Default, PartialEq)] +struct QuestionState { + pending: Vec, + current_idx: usize, + seen_ids: HashSet, +} + +pub(crate) enum QuestionSubmission { + Submit(String), + Queue(String), +} + +pub(crate) struct AsyncQuestions { + app_event_tx: AppEventSender, + state: QuestionState, + pub(crate) expanded: bool, + pub(crate) delivery_enabled: bool, + pub(crate) submission: Option, + pub(crate) next_hint: Option, + keymap: RuntimeKeymap, + pub(super) composer: ChatComposer, +} + +impl AsyncQuestions { + pub(crate) fn new( + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + keymap: RuntimeKeymap, + ) -> Self { + let mut composer = ChatComposer::new_with_config( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + "Type your answer".into(), + disable_paste_burst, + ChatComposerConfig::plain_text(), + ); + composer.set_keymap_bindings(&keymap); + composer.set_footer_hint_override(Some(Vec::new())); + Self { + app_event_tx, + state: QuestionState::default(), + expanded: false, + delivery_enabled: true, + submission: None, + next_hint: None, + keymap, + composer, + } + } + + fn current_question(&self) -> Option<&AsyncUserInputQuestion> { + self.current_answer().map(|answer| &answer.question) + } + + fn current_answer_mut(&mut self) -> Option<&mut PendingQuestion> { + self.state.pending.get_mut(self.state.current_idx) + } + + fn current_answer(&self) -> Option<&PendingQuestion> { + self.state.pending.get(self.state.current_idx) + } + + pub(super) fn progress_prefix_text(&self) -> String { + let current = self.state.current_idx + 1; + let total = self.unanswered_count(); + format!("{current} of {total}") + } + + fn save_current_draft(&mut self) { + self.composer.flush_pending_input(); + let draft = self.composer.snapshot_draft(); + if let Some(answer) = self.current_answer_mut() { + answer.draft = draft; + } + } + + fn restore_current_draft(&mut self) { + let draft = self + .current_answer() + .map(|answer| answer.draft.clone()) + .unwrap_or_default(); + self.composer.restore_inline_draft(draft); + } + + pub(crate) fn unanswered_count(&self) -> usize { + self.state.pending.len() + } +} diff --git a/codex-rs/tui/src/bottom_pane/async_questions/render.rs b/codex-rs/tui/src/bottom_pane/async_questions/render.rs new file mode 100644 index 0000000000..4994a440cc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/render.rs @@ -0,0 +1,79 @@ +//! Render freeform questions inline using the existing modal editing engine. +use super::AsyncQuestions; +use crate::bottom_pane::selection_popup_common::menu_surface_inset; +use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; +use crate::bottom_pane::selection_popup_common::render_menu_surface; +use crate::render::renderable::Renderable; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +impl AsyncQuestions { + fn question_lines(&self, width: u16) -> Vec> { + self.current_question() + .map(|q| { + textwrap::wrap(&q.title, usize::from(width.max(1))) + .into_iter() + .map(Line::from) + .collect() + }) + .unwrap_or_default() + } + fn input_area(&self, area: Rect) -> Rect { + let area = menu_surface_inset(area); + let input_height = self + .composer + .inline_input_height(area.width) + .min(area.height); + Rect::new( + area.x, + area.bottom().saturating_sub(input_height), + area.width, + input_height, + ) + } +} +impl Renderable for AsyncQuestions { + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.composer.cursor_style(area) + } + fn desired_height(&self, width: u16) -> u16 { + let width = menu_surface_inset(Rect::new(/*x*/ 0, /*y*/ 0, width, u16::MAX)).width; + self.question_lines(width).len() as u16 + + self.composer.inline_input_height(width) + + 1 + + u16::from(self.unanswered_count() > 1) + + u16::from(self.unanswered_count() > 1) + + menu_surface_padding_height() + } + fn render(&self, area: Rect, buf: &mut Buffer) { + let input = self.input_area(area); + let mut prompt = render_menu_surface(area, buf); + prompt.height = input.y.saturating_sub(prompt.y); + if self.unanswered_count() > 1 && prompt.height > 0 { + Paragraph::new(self.progress_prefix_text().dim()).render( + Rect::new(prompt.x, prompt.y, prompt.width, /*height*/ 1), + buf, + ); + prompt.y += 1; + prompt.height -= 1; + } + if self.unanswered_count() > 1 + && prompt.height > self.question_lines(prompt.width).len() as u16 + { + prompt.y += 1; + prompt.height -= 1; + } + Paragraph::new(self.question_lines(prompt.width)) + .style(crate::style::accent_style()) + .bold() + .render(prompt, buf); + self.composer.render_inline_input(input, buf); + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.composer.inline_cursor_pos(self.input_area(area)) + } +} diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state.rs b/codex-rs/tui/src/bottom_pane/async_questions/state.rs new file mode 100644 index 0000000000..da68d6c1f1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/state.rs @@ -0,0 +1,81 @@ +//! Pending async questions are local drafts; handling one removes it immediately. +//! Message IDs survive removal so replay cannot reopen an answered or skipped question. + +use super::*; +use codex_context_fragments::AnsweredQuestion; +use codex_context_fragments::ContextualUserFragment; + +impl AsyncQuestions { + pub(crate) fn append(&mut self, message_id: &str, questions: &[AsyncUserInputQuestion]) { + if questions.is_empty() || !self.state.seen_ids.insert(message_id.to_string()) { + return; + } + let was_empty = self.state.pending.is_empty(); + self.state + .pending + .extend(questions.iter().map(|question| PendingQuestion { + question: question.clone(), + draft: ComposerDraft::default(), + })); + if was_empty { + self.state.current_idx = 0; + self.restore_current_draft(); + } + } + + pub(crate) fn set_expanded(&mut self, expanded: bool) { + self.save_current_draft(); + self.expanded = expanded && !self.state.pending.is_empty(); + } + + pub(crate) fn navigate(&mut self, forward: bool) -> bool { + let next = if forward { + self.state.current_idx.checked_add(1) + } else { + self.state.current_idx.checked_sub(1) + }; + let Some(next) = next.filter(|&index| index < self.state.pending.len()) else { + return false; + }; + self.save_current_draft(); + self.state.current_idx = next; + self.restore_current_draft(); + true + } + + pub(super) fn go_next_or_submit(&mut self) { + self.save_current_draft(); + if !self.delivery_enabled { + return; + } + let Some(answer) = self.current_answer() else { + return; + }; + let text = answer.draft.text_with_pending(); + let text = text.trim(); + let framing = AnsweredQuestion::new(&answer.question.title).render(); + let limit = codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS - framing.chars().count(); + if text.chars().count() > limit { + self.composer.show_footer_flash( + format!("Answer too long; limit {limit} characters").into(), + std::time::Duration::from_secs(5), + ); + } else if !text.is_empty() { + self.submission = Some(QuestionSubmission::Submit(format!("{framing}{text}"))); + } + } + + pub(crate) fn accept_answer(&mut self) { + if self.state.pending.is_empty() { + return; + } + self.composer.flush_pending_input(); + self.state.pending.remove(self.state.current_idx); + if self.state.current_idx >= self.state.pending.len() { + self.state.current_idx = 0; + } + self.expanded &= !self.state.pending.is_empty(); + self.restore_current_draft(); + self.composer.reset_vim_mode(); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 620cc161eb..29c2145e23 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,14 +1,9 @@ //! 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/task mentions). -//! - Promoting typed slash commands into atomic elements when the command name is completed. -//! - Handling submit vs newline on Enter. -//! - Showing a single yellow prompt arrow while the active model is Luna Reserve. -//! - Turning raw key streams into explicit paste operations on platforms where terminals -//! don't provide reliable bracketed paste (notably Windows). +//! It edits the [`TextArea`] buffer and attachment elements, routes popup keys, promotes +//! completed slash commands to atomic elements, and handles Enter submission/newlines. +//! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts +//! from raw key streams, particularly on Windows. //! //! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab //! submit ordinary text without enabling shell mode. @@ -178,6 +173,8 @@ //! Shift, or Windows AltGr) into //! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them //! through [`ChatComposer::handle_paste`]. +//! Parent views must keep flushing editors that lose focus while input is buffered; a hidden +//! editor's pending burst can otherwise keep the shared draw loop waiting indefinitely. //! //! The burst detector intentionally treats ASCII and non-ASCII differently: //! @@ -313,6 +310,7 @@ mod completion_target; mod draft_state; mod footer_state; mod history_search; +mod inline_input; mod popup_state; mod reconnect; mod slash_input; @@ -575,9 +573,9 @@ struct MentionCompletionTarget { prebuilt_mentions: Option>, } -#[derive(Clone, Debug)] -struct ComposerDraft { - text: String, +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) struct ComposerDraft { + pub(super) text: String, text_elements: Vec, local_image_paths: Vec, remote_image_urls: Vec, @@ -1464,7 +1462,7 @@ impl ChatComposer { .should_handle_vim_insert_escape(key_event) } - fn vim_mode_indicator_span(&self) -> Option> { + pub(crate) fn vim_mode_indicator_span(&self) -> Option> { self.draft.textarea.vim_mode_indicator_span() } @@ -1621,7 +1619,7 @@ impl ChatComposer { self.sync_popups(); } - fn current_cursor(&self) -> usize { + pub(crate) fn current_cursor(&self) -> usize { self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } } @@ -1674,7 +1672,7 @@ impl ChatComposer { Some(element.map_range(|_| (start..end).into())) } - fn snapshot_draft(&self) -> ComposerDraft { + pub(super) fn snapshot_draft(&self) -> ComposerDraft { ComposerDraft { text: self.current_text(), text_elements: self.current_text_elements(), @@ -1686,7 +1684,7 @@ impl ChatComposer { } } - fn restore_draft(&mut self, draft: ComposerDraft) { + pub(super) fn restore_draft(&mut self, draft: ComposerDraft) { let ComposerDraft { text, text_elements, @@ -3206,8 +3204,7 @@ impl ChatComposer { | InputResult::ServiceTierCommand(_) | InputResult::CommandWithArgs(_, _, _) ) { - self.vim_history = VimHistory::default(); - self.draft.textarea.enter_vim_insert_mode(); + self.reset_vim_mode(); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs new file mode 100644 index 0000000000..621cc7513b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs @@ -0,0 +1,115 @@ +//! Embed the composer in question rows, resetting history navigation when drafts change. + +use super::*; + +impl ComposerDraft { + pub(in crate::bottom_pane) fn text_with_pending(&self) -> String { + ChatComposer::expand_pending_pastes( + &self.text, + self.text_elements.clone(), + &self.pending_pastes, + ) + .0 + } +} + +impl ChatComposer { + pub(in crate::bottom_pane) fn restore_inline_draft(&mut self, draft: ComposerDraft) { + self.history.reset_navigation(); + self.restore_draft(draft); + } + + pub(crate) fn reset_vim_mode(&mut self) { + self.vim_history = VimHistory::default(); + self.draft.textarea.enter_vim_insert_mode(); + } + + /// Give modal editing precedence over a containing view's submit/navigation keys. + pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool { + self.history_search.is_some() + || self.should_handle_vim_insert_escape(key) + || self.draft.textarea.is_vim_operator_pending() + || self.draft.textarea.wants_vim_search_key(key) + || (self.draft.textarea.is_vim_normal_mode() + && !self.submit_keys.is_pressed(key) + && !self.queue_keys.is_pressed(key) + && (self.vim_normal_keymap.move_up.is_pressed(key) + || self.vim_normal_keymap.move_down.is_pressed(key))) + } + + /// Finish buffered typing before another answer takes over this editor. + pub(crate) fn flush_pending_input(&mut self) { + if let Some(text) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(text); + } + self.draft.paste_burst.clear_after_explicit_paste(); + } + + pub(crate) fn inline_input_height(&self, width: u16) -> u16 { + self.draft.textarea.desired_height(width.max(1)) + + u16::from(self.history_search.is_some() || self.draft.textarea.vim_query().is_some()) + + u16::from(self.footer.flash_visible()) + } + + pub(crate) fn inline_cursor_pos(&self, mut area: Rect) -> Option<(u16, u16)> { + area.height = area + .height + .saturating_sub(u16::from(self.footer.flash_visible())); + let query_area = Rect::new( + area.x, + area.bottom().saturating_sub(1), + area.width, + area.height.min(1), + ); + if let Some(query) = self.draft.textarea.vim_query() { + return query.cursor_pos(query_area); + } + self.draft + .textarea + .cursor_pos_with_state(area, *self.draft.textarea_state.borrow()) + } + + pub(crate) fn render_inline_input(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + ratatui::widgets::Clear.render(area, buf); + Block::default() + .style(Style::reset().patch(user_message_style())) + .render(area, buf); + let mut area = area; + if self.footer.flash_visible() + && let Some(flash) = &self.footer.flash + { + area.height = area.height.saturating_sub(1); + flash.line.clone().render( + Rect::new(area.x, area.bottom(), area.width, /*height*/ 1), + buf, + ); + } + if let Some(query) = self.draft.textarea.vim_query() { + area.height = area.height.saturating_sub(1); + query.render( + Rect::new(area.x, area.bottom(), area.width, /*height*/ 1), + buf, + ); + } + let mut state = self.draft.textarea_state.borrow_mut(); + let highlights: Vec<_> = self + .history_search_highlight_ranges() + .into_iter() + .chain(self.draft.textarea.vim_search_highlights()) + .map(|range| (range, Style::new().reversed().bold())) + .collect(); + self.draft.textarea.render_ref_styled_with_highlights( + area, + buf, + &mut state, + Style::default(), + &highlights, + ); + if self.draft.textarea.is_empty() { + Line::from(self.placeholder_text.as_str().dim()).render(area, buf); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index bc91723a18..836a014d36 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -127,6 +127,7 @@ impl HistoryEntry { /// State machine that manages shell-style history navigation (Up/Down) inside /// the chat composer. This struct is intentionally decoupled from the /// rendering widget so the logic remains isolated and easier to test. +#[derive(Clone)] pub(crate) struct ChatComposerHistory { /// Thread that owns persistent lookup responses for this metadata snapshot. thread_id: Option, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 1a297a1dbc..3feb64519c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -61,6 +61,8 @@ mod actionable_banner; mod app_link_view; mod apply_patch_header; mod approval_overlay; +#[expect(dead_code)] +mod async_questions; mod hook_status; mod mcp_server_elicitation; mod multi_select_picker; @@ -1792,14 +1794,12 @@ impl BottomPane { } pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { - // Give the active view the first chance to flush paste-burst state so - // overlays that reuse the composer behave consistently. - if let Some(view) = self.view_stack.last_mut() - && view.flush_paste_burst_if_due() - { - return true; + // A covering view and the main composer can both retain buffered typing. + let mut flushed = self.composer.flush_paste_burst_if_due(); + if let Some(view) = self.view_stack.last_mut() { + flushed |= view.flush_paste_burst_if_due(); } - self.composer.flush_paste_burst_if_due() + flushed } pub(crate) fn is_in_paste_burst(&self) -> bool { @@ -2133,6 +2133,49 @@ mod tests { }) } + #[test] + fn flush_buffered_typing_in_background_and_covering_editors() { + let (tx, _rx) = unbounded_channel(); + let tx = AppEventSender::new(tx); + let mut pane = test_pane(tx.clone()); + for ch in "main draft".chars() { + pane.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert!(pane.composer.is_in_paste_burst()); + let request = ToolRequestUserInputParams { + thread_id: "thread".into(), + turn_id: "turn".into(), + item_id: "call".into(), + is_blocking: true, + auto_resolution_ms: None, + questions: vec![codex_app_server_protocol::ToolRequestUserInputQuestion { + id: "question".into(), + header: String::new(), + question: "Which way?".into(), + is_other: false, + is_secret: false, + options: None, + }], + }; + let mut covering = RequestUserInputOverlay::new_with_keymap( + request, + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + RuntimeKeymap::defaults(), + ); + for ch in "modal".chars() { + covering.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert!(covering.is_in_paste_burst()); + pane.push_view(Box::new(covering)); + std::thread::sleep(paste_burst::PasteBurst::recommended_active_flush_delay()); + assert!(pane.flush_paste_burst_if_due()); + assert!(!pane.is_in_paste_burst()); + assert_eq!(pane.composer_text(), "main draft"); + } + #[test] fn inline_banner_snapshot() { let (tx, _rx) = unbounded_channel(); diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 8700323cf6..a973172c40 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -26,7 +26,7 @@ pub(crate) struct PendingInputPreview { pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. - edit_binding: Option, + pub(super) edit_binding: Option, /// Key combination rendered for immediately interrupting and sending steers. interrupt_binding: Option, } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 7d6e043090..6b3dcda5ba 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -18,7 +18,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; mod layout; -mod render; +pub(super) mod render; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index 973e3feef3..a585d89e95 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -427,7 +427,7 @@ impl RequestUserInputOverlay { /// /// This keeps footer spacing stable by anchoring the options block to the /// bottom of its allocated region. -fn render_rows_bottom_aligned( +pub(in crate::bottom_pane) fn render_rows_bottom_aligned( area: Rect, buf: &mut Buffer, rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow], @@ -471,7 +471,7 @@ fn render_rows_bottom_aligned( /// overflows, it truncates at the last word boundary when possible (falling back to the last /// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the /// last visible span (or the line style if nothing was kept). -fn truncate_line_word_boundary_with_ellipsis( +pub(in crate::bottom_pane) fn truncate_line_word_boundary_with_ellipsis( line: Line<'static>, max_width: usize, ) -> Line<'static> { diff --git a/codex-rs/tui/src/bottom_pane/scroll_state.rs b/codex-rs/tui/src/bottom_pane/scroll_state.rs index a76f3821b1..ce208c7952 100644 --- a/codex-rs/tui/src/bottom_pane/scroll_state.rs +++ b/codex-rs/tui/src/bottom_pane/scroll_state.rs @@ -11,7 +11,7 @@ /// knowing about their data model. Passing a stale length after filtering would /// leave selection pointing at the wrong row, so callers should clamp or move /// through this type immediately after changing their visible row set. -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, PartialEq)] pub(crate) struct ScrollState { pub selected_idx: Option, pub scroll_top: usize, diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 27a6e7eb51..6e408aab3c 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -107,7 +107,7 @@ impl ChatWidget { .1 } - fn submit_user_message_with_history_and_shell_escape_policy( + pub(super) fn submit_user_message_with_history_and_shell_escape_policy( &mut self, user_message: UserMessage, history_record: UserMessageHistoryRecord, diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index c0da2b8afb..1862d8e64e 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -96,6 +96,14 @@ impl KeyBinding { (self.key, self.modifiers) } + /// Legacy Ctrl+] is reported as Ctrl+5; keep matching and conflicts consistent. + pub(crate) fn normalized_parts(&self) -> (KeyCode, KeyModifiers) { + match self.parts() { + (KeyCode::Char('5'), KeyModifiers::CONTROL) => ctrl(KeyCode::Char(']')).parts(), + parts => parts, + } + } + pub(crate) fn display_label(&self) -> String { let modifiers = modifiers_to_string(self.modifiers); let key = match self.key { @@ -123,13 +131,13 @@ pub(crate) fn normalize_key_parts( if modifiers.is_empty() && let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch) { - return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers); + return normalize_key_parts(KeyCode::Char(ctrl_char), KeyModifiers::CONTROL); } if ch.is_ascii_uppercase() { modifiers.insert(KeyModifiers::SHIFT); return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers); } - (key, modifiers) + KeyBinding::new(key, modifiers).normalized_parts() } fn c0_control_char_to_ctrl_char(ch: char) -> Option { @@ -350,6 +358,7 @@ mod tests { ('z', '\u{001a}'), ('4', '\u{001c}'), ('5', '\u{001d}'), + (']', '\u{001d}'), ('6', '\u{001e}'), ('7', '\u{001f}'), ]; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 6b8fda7007..377a9165ee 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -121,8 +121,12 @@ pub(crate) struct ChatKeymap { pub(crate) previous_permission_mode: Vec, /// Switch to the next available permission mode. pub(crate) next_permission_mode: Vec, - /// Edit the most recently queued message. + /// Move up through async questions, then edit the most recently queued message. pub(crate) edit_queued_message: Vec, + /// Move back through async questions toward the composer. + pub(crate) prompt_stack_back: Vec, + /// Skip the focused question. + pub(crate) skip_question: Vec, } /// Composer-level keybindings validated in the second app-scope conflict pass. @@ -684,6 +688,8 @@ impl RuntimeKeymap { &defaults.chat.edit_queued_message, "tui.keymap.chat.edit_queued_message", )?, + prompt_stack_back: resolve_local!(keymap, defaults, chat, prompt_stack_back), + skip_question: resolve_local!(keymap, defaults, chat, skip_question), }; let composer = ComposerKeymap { @@ -1170,6 +1176,37 @@ impl RuntimeKeymap { cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), }; + // New question shortcuts yield individually to existing explicit bindings. + for (configured, bindings, aliases) in [ + ( + keymap.chat.prompt_stack_back.as_ref(), + &mut chat.prompt_stack_back, + vec![ + ("alt-down", key_hint::alt(KeyCode::Down)), + ("shift-right", key_hint::shift(KeyCode::Right)), + ], + ), + ( + keymap.chat.skip_question.as_ref(), + &mut chat.skip_question, + vec![("ctrl-]", key_hint::ctrl(KeyCode::Char(']')))], + ), + ] { + if configured.is_none() { + bindings.retain(|binding| { + !aliases.iter().any(|(alias, candidate)| { + binding == candidate + && (configured_main_surface_alias_is_used(keymap, alias) + || configured_context_alias_is_used(&keymap.list, alias) + || configured_context_alias_is_used(&keymap.vim_search, alias)) + }) && !chords.bindings.iter().any(|chord| { + chord.action.context.overlaps(KeymapContext::Chat) + && binding.normalized_parts() == chord.chord.prefix.normalized_parts() + }) + }); + } + } + // Reasoning arrow aliases are fallback defaults: existing explicit // bindings on the same input path keep the keys, while explicit // reasoning bindings remain authoritative. @@ -1474,6 +1511,8 @@ impl RuntimeKeymap { previous_permission_mode: default_bindings![], next_permission_mode: default_bindings![], edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)], + prompt_stack_back: default_bindings![alt(KeyCode::Down), shift(KeyCode::Right)], + skip_question: default_bindings![ctrl(KeyCode::Char(']'))], }, composer: ComposerKeymap { submit: default_bindings![plain(KeyCode::Enter)], @@ -1753,6 +1792,8 @@ impl RuntimeKeymap { &self.chat.previous_permission_mode, ), ("next_permission_mode", &self.chat.next_permission_mode), + ("prompt_stack_back", &self.chat.prompt_stack_back), + ("skip_question", &self.chat.skip_question), ] { if bindings.iter().any(|binding| { let (code, modifiers) = binding.parts(); @@ -1826,6 +1867,11 @@ impl RuntimeKeymap { "chat.edit_queued_message", self.chat.edit_queued_message.as_slice(), ), + ( + "chat.prompt_stack_back", + self.chat.prompt_stack_back.as_slice(), + ), + ("chat.skip_question", self.chat.skip_question.as_slice()), ("composer.submit", self.composer.submit.as_slice()), ("composer.queue", self.composer.queue.as_slice()), ( @@ -1919,6 +1965,24 @@ impl RuntimeKeymap { [], )?; + // Async question shortcuts run before option selection and text editing. + validate_no_shadow_with_allowed_overlaps( + "async_questions", + [ + ( + "chat.prompt_stack_back", + self.chat.prompt_stack_back.as_slice(), + ), + ("chat.skip_question", self.chat.skip_question.as_slice()), + ], + [ + ("list.move_up", self.list.move_up.as_slice()), + ("list.move_down", self.list.move_down.as_slice()), + ("list.accept", self.list.accept.as_slice()), + ], + [], + )?; + // While the composer is focused, these main-surface handlers always // consume matching keys before the event reaches the textarea editor. validate_no_shadow_with_allowed_overlaps( @@ -1949,6 +2013,11 @@ impl RuntimeKeymap { "chat.next_permission_mode", self.chat.next_permission_mode.as_slice(), ), + ( + "chat.prompt_stack_back", + self.chat.prompt_stack_back.as_slice(), + ), + ("chat.skip_question", self.chat.skip_question.as_slice()), ("composer.submit", self.composer.submit.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), @@ -2071,7 +2140,7 @@ impl RuntimeKeymap { let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); for (action, bindings) in approval_overlay_bindings { for binding in bindings { - let key = binding.parts(); + let key = binding.normalized_parts(); if let Some(previous) = seen.insert(key, action) { // Approval overlays intentionally reserve Esc as a stable // cancellation path even though decline options may also @@ -2106,7 +2175,7 @@ fn validate_unique<'a>( let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); for (action, bindings) in pairs { for binding in bindings { - let key = binding.parts(); + let key = binding.normalized_parts(); if let Some(previous) = seen.insert(key, action) { return Err(format!( "Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \ @@ -2128,18 +2197,18 @@ fn validate_no_shadow_with_allowed_overlaps = HashMap::new(); for (action, bindings) in primary { for binding in bindings { - seen.insert(binding.parts(), action); + seen.insert(binding.normalized_parts(), action); } } for (action, bindings) in shadowed { for binding in bindings { - let key = binding.parts(); + let key = binding.normalized_parts(); if let Some(previous) = seen.get(&key) { if allowed_overlaps.iter().any( |(allowed_primary, allowed_shadowed, allowed_binding)| { *allowed_primary == *previous && *allowed_shadowed == action - && allowed_binding.parts() == key + && allowed_binding.normalized_parts() == key }, ) { continue; @@ -2319,7 +2388,9 @@ fn configured_context_alias_is_used(context: &impl Serialize, alias: &str) -> bo fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool { match value { - serde_json::Value::String(value) => value == alias, + serde_json::Value::String(value) => parse_keybinding(value) + .zip(parse_keybinding(alias)) + .is_some_and(|(a, b)| a.normalized_parts() == b.normalized_parts()), serde_json::Value::Array(values) => values .iter() .any(|value| keymap_value_contains_alias(value, alias)), @@ -2366,7 +2437,10 @@ See the Codex keymap documentation for supported actions and examples.", ) })?; - if !parsed.contains(&binding) { + if !parsed + .iter() + .any(|previous: &KeyBinding| previous.normalized_parts() == binding.normalized_parts()) + { parsed.push(binding); } } @@ -3561,6 +3635,28 @@ mod tests { expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right"); } + #[test] + fn interrupt_turn_rejects_question_back_and_skip_bindings() { + for (key, action) in [ + ("alt-down", "chat.prompt_stack_back"), + ("ctrl-]", "chat.skip_question"), + ("ctrl-5", "chat.skip_question"), + ("ctrl-] x", "chat.skip_question"), + ("ctrl-5 x", "chat.skip_question"), + ] { + let mut keymap = TuiKeymap::default(); + keymap.chat.interrupt_turn = Some(one(key)); + if key == "alt-down" { + keymap.chat.prompt_stack_back = Some(one(key)); + } else { + let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); + assert!(runtime.chat.skip_question.is_empty()); + keymap.chat.skip_question = Some(one("ctrl-]")); + } + expect_conflict(&keymap, "chat.interrupt_turn", action); + } + } + #[test] fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index d6062db1e6..4ba502305d 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -259,6 +259,8 @@ define_runtime_action_bindings! { previous_permission_mode, next_permission_mode, edit_queued_message, + prompt_stack_back, + skip_question, ], "composer" => Composer, composer, composer [ submit, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index f4895b5d1d..fbb350c3a0 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -397,6 +397,8 @@ fn effective_configured_binding( } pub(crate) fn normalize_chord_binding(binding: KeyBinding) -> KeyBinding { + let (key, modifiers) = binding.normalized_parts(); + let binding = KeyBinding::new(key, modifiers); if binding.parts() == crate::key_hint::ctrl(KeyCode::Char('7')).parts() { crate::key_hint::ctrl(KeyCode::Char('/')) } else { diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 0a03f5a4e8..31d9a246a8 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -273,6 +273,8 @@ pub(super) fn binding_slot<'a>( ("chat", "previous_permission_mode") => Some(&mut keymap.chat.previous_permission_mode), ("chat", "next_permission_mode") => Some(&mut keymap.chat.next_permission_mode), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), + ("chat", "prompt_stack_back") => Some(&mut keymap.chat.prompt_stack_back), + ("chat", "skip_question") => Some(&mut keymap.chat.skip_question), ("composer", "submit") => Some(&mut keymap.composer.submit), ("composer", "queue") => Some(&mut keymap.composer.queue), ("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts), diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index 41c6099650..db12daa6f1 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -32,6 +32,7 @@ impl From<&Config> for LocalSettings { auto_recap: config.tui_auto_recap, disable_paste_burst: Some(config.disable_paste_burst), vim_mode_default: config.tui_vim_mode_default, + question_esc_back: config.tui_question_esc_back, raw_output_mode: config.tui_raw_output_mode, alternate_screen: config.tui_alternate_screen, status_line: config.tui_status_line.clone(),