Add TUI building blocks for inline async question editing (#42889)

## What changed

- Introduce an async-question editor component with per-question drafts, navigation, replay deduplication, and submit or queue handling using the shared composer.
- Add bounded `AnsweredQuestion` framing that truncates question text at a UTF-8 boundary and flattens line breaks before prepending it to an answer.
- Add `prompt_stack_back` and `skip_question` keybinding actions and the `tui.question_esc_back` setting. Normalize `Ctrl+]` and `Ctrl+5` for key matching and conflict detection, and let default question shortcuts yield to explicit bindings.
- Flush buffered typing in both the main composer and the covering view so background input cannot keep the draw loop waiting.

The async-question component is not yet connected to the TUI event flow.

## Testing

Add regression coverage for bounded Unicode question framing, question-shortcut conflicts, and paste-buffer flushing in background and covering editors.

GitOrigin-RevId: 4098043cf588ddafc05c27505645495edd690cb9
This commit is contained in:
Ian MacLeod authored and copyberry committed 2026-09-04 23:56:25 +00:00
1 parent 574a36ff99
commit 6ae8dcf6e1
32 files changed
+750 -44

No files matched your search

+2
View File
@@ -2875,6 +2875,7 @@ version = "0.0.0"
dependencies = [ dependencies = [
"codex-protocol", "codex-protocol",
"codex-utils-string", "codex-utils-string",
"pretty_assertions",
] ]
[[package]] [[package]]
@@ -4621,6 +4622,7 @@ dependencies = [
"codex-cloud-config", "codex-cloud-config",
"codex-config", "codex-config",
"codex-connectors", "codex-connectors",
"codex-context-fragments",
"codex-core-plugins", "codex-core-plugins",
"codex-exec-server", "codex-exec-server",
"codex-features", "codex-features",
+5 -1
View File
@@ -135,8 +135,12 @@ pub struct TuiChatKeymap {
pub previous_permission_mode: Option<KeybindingsSpec>, pub previous_permission_mode: Option<KeybindingsSpec>,
/// Switch to the next available permission mode. /// Switch to the next available permission mode.
pub next_permission_mode: Option<KeybindingsSpec>, pub next_permission_mode: Option<KeybindingsSpec>,
/// Edit the most recently queued message. /// Move up through pending async questions, then edit the most recently queued message.
pub edit_queued_message: Option<KeybindingsSpec>, pub edit_queued_message: Option<KeybindingsSpec>,
/// Move back through pending async questions toward the composer.
pub prompt_stack_back: Option<KeybindingsSpec>,
/// Skip the focused question.
pub skip_question: Option<KeybindingsSpec>,
} }
/// Composer context keybindings. These override corresponding `global` actions. /// Composer context keybindings. These override corresponding `global` actions.
+4
View File
@@ -757,6 +757,10 @@ pub struct Tui {
#[serde(default)] #[serde(default)]
pub vim_mode_default: bool, 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. /// Start the TUI in raw scrollback mode for copy-friendly transcript output.
/// Defaults to `false`. /// Defaults to `false`.
#[serde(default)] #[serde(default)]
+3 -1
View File
@@ -7,7 +7,6 @@ version.workspace = true
[lib] [lib]
name = "codex_context_fragments" name = "codex_context_fragments"
path = "src/lib.rs" path = "src/lib.rs"
test = false
doctest = false doctest = false
[lints] [lints]
@@ -16,3 +15,6 @@ workspace = true
[dependencies] [dependencies]
codex-protocol = { workspace = true } codex-protocol = { workspace = true }
codex-utils-string = { workspace = true } codex-utils-string = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
@@ -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;
@@ -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', " ")
)
);
}
+2
View File
@@ -1,3 +1,5 @@
mod answered_question;
pub use answered_question::AnsweredQuestion;
mod additional_context; mod additional_context;
mod annotated_content; mod annotated_content;
mod fragment; mod fragment;
+26 -1
View File
@@ -3889,6 +3889,8 @@
"interrupt_turn": null, "interrupt_turn": null,
"next_permission_mode": null, "next_permission_mode": null,
"previous_permission_mode": null, "previous_permission_mode": null,
"prompt_stack_back": null,
"skip_question": null,
"toggle_voice_mute": null "toggle_voice_mute": null
}, },
"composer": { "composer": {
@@ -4085,6 +4087,11 @@
"default": "composer", "default": "composer",
"description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport." "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": { "raw_output_mode": {
"default": false, "default": false,
"description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.",
@@ -4303,7 +4310,7 @@
"$ref": "#/definitions/KeybindingsSpec" "$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": { "increase_reasoning_effort": {
"allOf": [ "allOf": [
@@ -4337,6 +4344,22 @@
], ],
"description": "Switch to the previous available permission mode." "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": { "toggle_voice_mute": {
"allOf": [ "allOf": [
{ {
@@ -4690,6 +4713,8 @@
"interrupt_turn": null, "interrupt_turn": null,
"next_permission_mode": null, "next_permission_mode": null,
"previous_permission_mode": null, "previous_permission_mode": null,
"prompt_stack_back": null,
"skip_question": null,
"toggle_voice_mute": null "toggle_voice_mute": null
} }
}, },
+2
View File
@@ -1261,6 +1261,7 @@ fn config_toml_deserializes_model_availability_nux() {
auto_recap: true, auto_recap: true,
disable_paste_burst: None, disable_paste_burst: None,
vim_mode_default: false, vim_mode_default: false,
question_esc_back: true,
raw_output_mode: false, raw_output_mode: false,
alternate_screen: AltScreenMode::default(), alternate_screen: AltScreenMode::default(),
status_line: None, status_line: None,
@@ -4275,6 +4276,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
auto_recap: true, auto_recap: true,
disable_paste_burst: None, disable_paste_burst: None,
vim_mode_default: false, vim_mode_default: false,
question_esc_back: true,
raw_output_mode: false, raw_output_mode: false,
alternate_screen: AltScreenMode::Auto, alternate_screen: AltScreenMode::Auto,
status_line: None, status_line: None,
+2
View File
@@ -754,6 +754,7 @@ pub struct Config {
/// Start the composer in Vim mode (`Normal`) by default. /// Start the composer in Vim mode (`Normal`) by default.
pub tui_vim_mode_default: bool, pub tui_vim_mode_default: bool,
pub tui_question_esc_back: bool,
/// Start the TUI in raw scrollback mode for copy-friendly transcript output. /// Start the TUI in raw scrollback mode for copy-friendly transcript output.
pub tui_raw_output_mode: bool, pub tui_raw_output_mode: bool,
@@ -4354,6 +4355,7 @@ impl Config {
.as_ref() .as_ref()
.map(|t| t.model_availability_nux.clone()) .map(|t| t.model_availability_nux.clone())
.unwrap_or_default(), .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_vim_mode_default: cfg
.tui .tui
.as_ref() .as_ref()
+1
View File
@@ -54,6 +54,7 @@ pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
pub(crate) use base_instructions::BaseInstructionsFragment; pub(crate) use base_instructions::BaseInstructionsFragment;
pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment; pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment;
pub(crate) use codex_context_fragments::AdditionalContextUserFragment; pub(crate) use codex_context_fragments::AdditionalContextUserFragment;
pub use codex_context_fragments::AnsweredQuestion;
pub use codex_context_fragments::ContextualUserFragment; pub use codex_context_fragments::ContextualUserFragment;
pub(crate) use compaction_summary::CompactionSummary; pub(crate) use compaction_summary::CompactionSummary;
pub(crate) use contextual_user_message::is_contextual_user_fragment; pub(crate) use contextual_user_message::is_contextual_user_fragment;
@@ -242,6 +242,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
tui_session_picker_view: SessionPickerViewMode::Dense, tui_session_picker_view: SessionPickerViewMode::Dense,
tui_resume_cwd: None, tui_resume_cwd: None,
tui_vim_mode_default: false, tui_vim_mode_default: false,
tui_question_esc_back: true,
cwd: cwd.clone(), cwd: cwd.clone(),
workspace_roots: vec![cwd], workspace_roots: vec![cwd],
workspace_roots_explicit: false, workspace_roots_explicit: false,
+1
View File
@@ -34,6 +34,7 @@ codex-arg0 = { workspace = true }
codex-install-context = { workspace = true } codex-install-context = { workspace = true }
codex-cloud-config = { workspace = true } codex-cloud-config = { workspace = true }
codex-config = { workspace = true } codex-config = { workspace = true }
codex-context-fragments = { workspace = true }
codex-connectors = { workspace = true } codex-connectors = { workspace = true }
codex-core-plugins = { workspace = true } codex-core-plugins = { workspace = true }
codex-history = { workspace = true } codex-history = { workspace = true }
@@ -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<std::time::Duration> {
self.composer.footer_flash_delay()
}
fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
}
}
@@ -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<PendingQuestion>,
current_idx: usize,
seen_ids: HashSet<String>,
}
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<QuestionSubmission>,
pub(crate) next_hint: Option<crate::key_hint::ShortcutHint>,
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()
}
}
@@ -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<Line<'_>> {
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))
}
}
@@ -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();
}
}
+15 -18
View File
@@ -1,14 +1,9 @@
//! The chat composer is the bottom-pane text input state machine. //! The chat composer is the bottom-pane text input state machine.
//! //!
//! It is responsible for: //! It edits the [`TextArea`] buffer and attachment elements, routes popup keys, promotes
//! //! completed slash commands to atomic elements, and handles Enter submission/newlines.
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments. //! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts
//! - Routing keys to the active popup (slash commands, file search, skill/apps/task mentions). //! from raw key streams, particularly on Windows.
//! - 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).
//! //!
//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab //! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab
//! submit ordinary text without enabling shell mode. //! submit ordinary text without enabling shell mode.
@@ -178,6 +173,8 @@
//! Shift, or Windows AltGr) into //! Shift, or Windows AltGr) into
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them //! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
//! through [`ChatComposer::handle_paste`]. //! 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: //! The burst detector intentionally treats ASCII and non-ASCII differently:
//! //!
@@ -313,6 +310,7 @@ mod completion_target;
mod draft_state; mod draft_state;
mod footer_state; mod footer_state;
mod history_search; mod history_search;
mod inline_input;
mod popup_state; mod popup_state;
mod reconnect; mod reconnect;
mod slash_input; mod slash_input;
@@ -575,9 +573,9 @@ struct MentionCompletionTarget {
prebuilt_mentions: Option<Vec<MentionItem>>, prebuilt_mentions: Option<Vec<MentionItem>>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, Default, PartialEq)]
struct ComposerDraft { pub(super) struct ComposerDraft {
text: String, pub(super) text: String,
text_elements: Vec<TextElement>, text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>, local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>, remote_image_urls: Vec<String>,
@@ -1464,7 +1462,7 @@ impl ChatComposer {
.should_handle_vim_insert_escape(key_event) .should_handle_vim_insert_escape(key_event)
} }
fn vim_mode_indicator_span(&self) -> Option<Span<'static>> { pub(crate) fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
self.draft.textarea.vim_mode_indicator_span() self.draft.textarea.vim_mode_indicator_span()
} }
@@ -1621,7 +1619,7 @@ impl ChatComposer {
self.sync_popups(); 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 } 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())) Some(element.map_range(|_| (start..end).into()))
} }
fn snapshot_draft(&self) -> ComposerDraft { pub(super) fn snapshot_draft(&self) -> ComposerDraft {
ComposerDraft { ComposerDraft {
text: self.current_text(), text: self.current_text(),
text_elements: self.current_text_elements(), 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 { let ComposerDraft {
text, text,
text_elements, text_elements,
@@ -3206,8 +3204,7 @@ impl ChatComposer {
| InputResult::ServiceTierCommand(_) | InputResult::ServiceTierCommand(_)
| InputResult::CommandWithArgs(_, _, _) | InputResult::CommandWithArgs(_, _, _)
) { ) {
self.vim_history = VimHistory::default(); self.reset_vim_mode();
self.draft.textarea.enter_vim_insert_mode();
} }
} }
@@ -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);
}
}
}
@@ -127,6 +127,7 @@ impl HistoryEntry {
/// State machine that manages shell-style history navigation (Up/Down) inside /// State machine that manages shell-style history navigation (Up/Down) inside
/// the chat composer. This struct is intentionally decoupled from the /// the chat composer. This struct is intentionally decoupled from the
/// rendering widget so the logic remains isolated and easier to test. /// rendering widget so the logic remains isolated and easier to test.
#[derive(Clone)]
pub(crate) struct ChatComposerHistory { pub(crate) struct ChatComposerHistory {
/// Thread that owns persistent lookup responses for this metadata snapshot. /// Thread that owns persistent lookup responses for this metadata snapshot.
thread_id: Option<ThreadId>, thread_id: Option<ThreadId>,
+50 -7
View File
@@ -61,6 +61,8 @@ mod actionable_banner;
mod app_link_view; mod app_link_view;
mod apply_patch_header; mod apply_patch_header;
mod approval_overlay; mod approval_overlay;
#[expect(dead_code)]
mod async_questions;
mod hook_status; mod hook_status;
mod mcp_server_elicitation; mod mcp_server_elicitation;
mod multi_select_picker; mod multi_select_picker;
@@ -1792,14 +1794,12 @@ impl BottomPane {
} }
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
// Give the active view the first chance to flush paste-burst state so // A covering view and the main composer can both retain buffered typing.
// overlays that reuse the composer behave consistently. let mut flushed = self.composer.flush_paste_burst_if_due();
if let Some(view) = self.view_stack.last_mut() if let Some(view) = self.view_stack.last_mut() {
&& view.flush_paste_burst_if_due() flushed |= view.flush_paste_burst_if_due();
{
return true;
} }
self.composer.flush_paste_burst_if_due() flushed
} }
pub(crate) fn is_in_paste_burst(&self) -> bool { 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] #[test]
fn inline_banner_snapshot() { fn inline_banner_snapshot() {
let (tx, _rx) = unbounded_channel(); let (tx, _rx) = unbounded_channel();
@@ -26,7 +26,7 @@ pub(crate) struct PendingInputPreview {
pub queued_messages: Vec<String>, pub queued_messages: Vec<String>,
/// Key combination rendered in the hint line. Defaults to Alt+Up but may /// Key combination rendered in the hint line. Defaults to Alt+Up but may
/// be overridden for terminals where that chord is unavailable. /// be overridden for terminals where that chord is unavailable.
edit_binding: Option<key_hint::ShortcutHint>, pub(super) edit_binding: Option<key_hint::ShortcutHint>,
/// Key combination rendered for immediately interrupting and sending steers. /// Key combination rendered for immediately interrupting and sending steers.
interrupt_binding: Option<key_hint::ShortcutHint>, interrupt_binding: Option<key_hint::ShortcutHint>,
} }
@@ -18,7 +18,7 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
mod layout; mod layout;
mod render; pub(super) mod render;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
@@ -427,7 +427,7 @@ impl RequestUserInputOverlay {
/// ///
/// This keeps footer spacing stable by anchoring the options block to the /// This keeps footer spacing stable by anchoring the options block to the
/// bottom of its allocated region. /// bottom of its allocated region.
fn render_rows_bottom_aligned( pub(in crate::bottom_pane) fn render_rows_bottom_aligned(
area: Rect, area: Rect,
buf: &mut Buffer, buf: &mut Buffer,
rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow], 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 /// 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 /// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the
/// last visible span (or the line style if nothing was kept). /// 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>, line: Line<'static>,
max_width: usize, max_width: usize,
) -> Line<'static> { ) -> Line<'static> {
+1 -1
View File
@@ -11,7 +11,7 @@
/// knowing about their data model. Passing a stale length after filtering would /// 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 /// leave selection pointing at the wrong row, so callers should clamp or move
/// through this type immediately after changing their visible row set. /// 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(crate) struct ScrollState {
pub selected_idx: Option<usize>, pub selected_idx: Option<usize>,
pub scroll_top: usize, pub scroll_top: usize,
@@ -107,7 +107,7 @@ impl ChatWidget {
.1 .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, &mut self,
user_message: UserMessage, user_message: UserMessage,
history_record: UserMessageHistoryRecord, history_record: UserMessageHistoryRecord,
+11 -2
View File
@@ -96,6 +96,14 @@ impl KeyBinding {
(self.key, self.modifiers) (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 { pub(crate) fn display_label(&self) -> String {
let modifiers = modifiers_to_string(self.modifiers); let modifiers = modifiers_to_string(self.modifiers);
let key = match self.key { let key = match self.key {
@@ -123,13 +131,13 @@ pub(crate) fn normalize_key_parts(
if modifiers.is_empty() if modifiers.is_empty()
&& let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch) && 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() { if ch.is_ascii_uppercase() {
modifiers.insert(KeyModifiers::SHIFT); modifiers.insert(KeyModifiers::SHIFT);
return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers); 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<char> { fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
@@ -350,6 +358,7 @@ mod tests {
('z', '\u{001a}'), ('z', '\u{001a}'),
('4', '\u{001c}'), ('4', '\u{001c}'),
('5', '\u{001d}'), ('5', '\u{001d}'),
(']', '\u{001d}'),
('6', '\u{001e}'), ('6', '\u{001e}'),
('7', '\u{001f}'), ('7', '\u{001f}'),
]; ];
+104 -8
View File
@@ -121,8 +121,12 @@ pub(crate) struct ChatKeymap {
pub(crate) previous_permission_mode: Vec<KeyBinding>, pub(crate) previous_permission_mode: Vec<KeyBinding>,
/// Switch to the next available permission mode. /// Switch to the next available permission mode.
pub(crate) next_permission_mode: Vec<KeyBinding>, pub(crate) next_permission_mode: Vec<KeyBinding>,
/// Edit the most recently queued message. /// Move up through async questions, then edit the most recently queued message.
pub(crate) edit_queued_message: Vec<KeyBinding>, pub(crate) edit_queued_message: Vec<KeyBinding>,
/// Move back through async questions toward the composer.
pub(crate) prompt_stack_back: Vec<KeyBinding>,
/// Skip the focused question.
pub(crate) skip_question: Vec<KeyBinding>,
} }
/// Composer-level keybindings validated in the second app-scope conflict pass. /// Composer-level keybindings validated in the second app-scope conflict pass.
@@ -684,6 +688,8 @@ impl RuntimeKeymap {
&defaults.chat.edit_queued_message, &defaults.chat.edit_queued_message,
"tui.keymap.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 { let composer = ComposerKeymap {
@@ -1170,6 +1176,37 @@ impl RuntimeKeymap {
cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), 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 // Reasoning arrow aliases are fallback defaults: existing explicit
// bindings on the same input path keep the keys, while explicit // bindings on the same input path keep the keys, while explicit
// reasoning bindings remain authoritative. // reasoning bindings remain authoritative.
@@ -1474,6 +1511,8 @@ impl RuntimeKeymap {
previous_permission_mode: default_bindings![], previous_permission_mode: default_bindings![],
next_permission_mode: default_bindings![], next_permission_mode: default_bindings![],
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)], 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 { composer: ComposerKeymap {
submit: default_bindings![plain(KeyCode::Enter)], submit: default_bindings![plain(KeyCode::Enter)],
@@ -1753,6 +1792,8 @@ impl RuntimeKeymap {
&self.chat.previous_permission_mode, &self.chat.previous_permission_mode,
), ),
("next_permission_mode", &self.chat.next_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| { if bindings.iter().any(|binding| {
let (code, modifiers) = binding.parts(); let (code, modifiers) = binding.parts();
@@ -1826,6 +1867,11 @@ impl RuntimeKeymap {
"chat.edit_queued_message", "chat.edit_queued_message",
self.chat.edit_queued_message.as_slice(), 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.submit", self.composer.submit.as_slice()),
("composer.queue", self.composer.queue.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 // While the composer is focused, these main-surface handlers always
// consume matching keys before the event reaches the textarea editor. // consume matching keys before the event reaches the textarea editor.
validate_no_shadow_with_allowed_overlaps( validate_no_shadow_with_allowed_overlaps(
@@ -1949,6 +2013,11 @@ impl RuntimeKeymap {
"chat.next_permission_mode", "chat.next_permission_mode",
self.chat.next_permission_mode.as_slice(), 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()), ("composer.submit", self.composer.submit.as_slice()),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
("toggle_fast_mode", self.app.toggle_fast_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(); let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
for (action, bindings) in approval_overlay_bindings { for (action, bindings) in approval_overlay_bindings {
for binding in bindings { for binding in bindings {
let key = binding.parts(); let key = binding.normalized_parts();
if let Some(previous) = seen.insert(key, action) { if let Some(previous) = seen.insert(key, action) {
// Approval overlays intentionally reserve Esc as a stable // Approval overlays intentionally reserve Esc as a stable
// cancellation path even though decline options may also // 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(); let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
for (action, bindings) in pairs { for (action, bindings) in pairs {
for binding in bindings { for binding in bindings {
let key = binding.parts(); let key = binding.normalized_parts();
if let Some(previous) = seen.insert(key, action) { if let Some(previous) = seen.insert(key, action) {
return Err(format!( return Err(format!(
"Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \ "Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \
@@ -2128,18 +2197,18 @@ fn validate_no_shadow_with_allowed_overlaps<const N: usize, const M: usize, cons
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
for (action, bindings) in primary { for (action, bindings) in primary {
for binding in bindings { for binding in bindings {
seen.insert(binding.parts(), action); seen.insert(binding.normalized_parts(), action);
} }
} }
for (action, bindings) in shadowed { for (action, bindings) in shadowed {
for binding in bindings { for binding in bindings {
let key = binding.parts(); let key = binding.normalized_parts();
if let Some(previous) = seen.get(&key) { if let Some(previous) = seen.get(&key) {
if allowed_overlaps.iter().any( if allowed_overlaps.iter().any(
|(allowed_primary, allowed_shadowed, allowed_binding)| { |(allowed_primary, allowed_shadowed, allowed_binding)| {
*allowed_primary == *previous *allowed_primary == *previous
&& *allowed_shadowed == action && *allowed_shadowed == action
&& allowed_binding.parts() == key && allowed_binding.normalized_parts() == key
}, },
) { ) {
continue; 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 { fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool {
match value { 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 serde_json::Value::Array(values) => values
.iter() .iter()
.any(|value| keymap_value_contains_alias(value, alias)), .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); parsed.push(binding);
} }
} }
@@ -3561,6 +3635,28 @@ mod tests {
expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right"); 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] #[test]
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() { fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
let mut keymap = TuiKeymap::default(); let mut keymap = TuiKeymap::default();
+2
View File
@@ -259,6 +259,8 @@ define_runtime_action_bindings! {
previous_permission_mode, previous_permission_mode,
next_permission_mode, next_permission_mode,
edit_queued_message, edit_queued_message,
prompt_stack_back,
skip_question,
], ],
"composer" => Composer, composer, composer [ "composer" => Composer, composer, composer [
submit, submit,
+2
View File
@@ -397,6 +397,8 @@ fn effective_configured_binding(
} }
pub(crate) fn normalize_chord_binding(binding: KeyBinding) -> KeyBinding { 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() { if binding.parts() == crate::key_hint::ctrl(KeyCode::Char('7')).parts() {
crate::key_hint::ctrl(KeyCode::Char('/')) crate::key_hint::ctrl(KeyCode::Char('/'))
} else { } else {
+2
View File
@@ -273,6 +273,8 @@ pub(super) fn binding_slot<'a>(
("chat", "previous_permission_mode") => Some(&mut keymap.chat.previous_permission_mode), ("chat", "previous_permission_mode") => Some(&mut keymap.chat.previous_permission_mode),
("chat", "next_permission_mode") => Some(&mut keymap.chat.next_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", "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", "submit") => Some(&mut keymap.composer.submit),
("composer", "queue") => Some(&mut keymap.composer.queue), ("composer", "queue") => Some(&mut keymap.composer.queue),
("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts), ("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts),
+1
View File
@@ -32,6 +32,7 @@ impl From<&Config> for LocalSettings {
auto_recap: config.tui_auto_recap, auto_recap: config.tui_auto_recap,
disable_paste_burst: Some(config.disable_paste_burst), disable_paste_burst: Some(config.disable_paste_burst),
vim_mode_default: config.tui_vim_mode_default, vim_mode_default: config.tui_vim_mode_default,
question_esc_back: config.tui_question_esc_back,
raw_output_mode: config.tui_raw_output_mode, raw_output_mode: config.tui_raw_output_mode,
alternate_screen: config.tui_alternate_screen, alternate_screen: config.tui_alternate_screen,
status_line: config.tui_status_line.clone(), status_line: config.tui_status_line.clone(),