Integrate asynchronous questions into the TUI (#42891)

## What changed

- Display questions from live agent messages with a collapsed question count and an expandable answer editor.
- Support navigating, answering, queueing, and skipping questions while preserving the main composer draft. Expose question navigation and skip actions in the keymap picker.
- Route answers through existing input delivery and consume them only after an accepted send or queue operation. Keep questions editable while disconnected, with delivery and skipping disabled.

## Testing

Add tests for answer delivery, queued answers, retained drafts, disconnected editing, Vim undo, buffered input, constrained layouts, and question shortcut descriptions.

GitOrigin-RevId: 084b54dceb8c0100aefbf9f0b7897bf8d26eac47
This commit is contained in:
Ian MacLeod
2026-09-05 00:15:42 +00:00
committed by copyberry
parent 6ae8dcf6e1
commit 218e8df926
26 changed files with 765 additions and 34 deletions
@@ -17,6 +17,7 @@ impl AsyncQuestions {
}
let before = self.composer.snapshot_draft();
let (result, _) = self.composer.handle_key_event(key);
self.composer.cancel_history_search();
let queued = matches!(result, InputResult::Queued { .. });
if matches!(
result,
@@ -41,9 +42,61 @@ impl AsyncQuestions {
}
impl BottomPaneView for AsyncQuestions {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
self.composer.keymap_contexts().with(KeymapContext::Chat)
}
fn handle_key_event(&mut self, key: KeyEvent) {
if key.kind == KeyEventKind::Release || self.is_complete() {
return;
}
if self.handles_key_as_editing(key) {
self.edit(key);
return;
}
if key.kind != KeyEventKind::Press && self.keymap.composer.submit.is_pressed(key) {
return;
}
if self.keymap.chat.interrupt_turn.is_pressed(key) {
self.app_event_tx.interrupt();
return;
}
if key.kind == KeyEventKind::Press && self.keymap.chat.skip_question.is_pressed(key) {
if self.delivery_enabled {
self.accept_answer();
}
return;
}
if self.keymap.chat.edit_queued_message.is_pressed(key) {
self.navigate(/*forward*/ true);
} else if self.keymap.chat.prompt_stack_back.is_pressed(key) {
self.navigate(/*forward*/ false);
} else {
self.edit(key);
}
}
fn is_complete(&self) -> bool {
self.unanswered_count() == 0
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.composer.cancel_vim_search() || self.composer.cancel_history_search() {
return CancellationEvent::Handled;
}
if self.composer.clear_for_ctrl_c().is_some() {
self.save_current_draft();
} else {
return CancellationEvent::NotHandled;
}
CancellationEvent::Handled
}
fn handle_paste(&mut self, text: String) -> bool {
if self.is_complete() || text.is_empty() {
return false;
}
self.composer.flush_pending_input();
self.composer.handle_paste(text)
}
fn flush_paste_burst_if_due(&mut self) -> bool {
self.composer.flush_paste_burst_if_due()
}
@@ -2,12 +2,14 @@
//! Only locally accepted submissions remove questions; arrival and expiry never steal focus.
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
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::KeymapContext;
use crate::keymap::RuntimeKeymap;
use codex_protocol::items::AsyncUserInputQuestion;
use crossterm::event::KeyEvent;
@@ -116,3 +118,7 @@ impl AsyncQuestions {
self.state.pending.len()
}
}
#[cfg(test)]
#[path = "state_tests.rs"]
mod tests;
@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: text
---
1 of 2
A lengthy prompt. A lengthy prompt.
A lengthy prompt. A lengthy prompt.
A lengthy prompt. A lengthy prompt.
A lengthy prompt. A lengthy prompt.
visible answer
@@ -0,0 +1,103 @@
//! Exercise question drafts, selection, and the constrained terminal viewport.
use super::*;
use crate::render::renderable::Renderable;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
fn editor() -> AsyncQuestions {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut editor = AsyncQuestions::new(
AppEventSender::new(tx),
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ true,
/*disable_paste_burst*/ true,
RuntimeKeymap::defaults(),
);
editor.append(
"message",
&[
question("First", /*options*/ None),
question("Second", Some(vec!["Named".into()])),
],
);
editor
}
#[test]
fn arrival_preserves_vim_undo_and_navigation_flushes_buffered_input() {
let mut editor = editor();
editor.set_expanded(/*expanded*/ true);
editor.set_vim_enabled(/*enabled*/ true);
editor.handle_key_event(KeyEvent::from(KeyCode::Char('i')));
editor.handle_paste("draft".into());
editor.handle_key_event(KeyEvent::from(KeyCode::Esc));
editor.append("new", &[question("New", /*options*/ None)]);
editor.handle_key_event(KeyEvent::from(KeyCode::Char('u')));
assert_eq!(editor.composer.current_text(), "");
editor.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert_eq!(editor.composer.current_text(), "draft");
editor.set_vim_enabled(/*enabled*/ false);
editor.composer.set_disable_paste_burst(/*disabled*/ false);
editor.composer.move_cursor_to_end();
for ch in "buffered".chars() {
editor.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
assert!(editor.composer.is_in_paste_burst());
editor.navigate(/*forward*/ true);
editor.navigate(/*forward*/ false);
assert_eq!(editor.composer.current_text_with_pending(), "draftbuffered");
for ch in "skipped".chars() {
editor.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
assert!(editor.composer.is_in_paste_burst());
editor.handle_key_event(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL));
editor.composer.flush_pending_input();
assert_eq!(
(
editor.current_question().unwrap().title.as_str(),
editor.composer.current_text()
),
("Second", String::new())
);
}
#[test]
fn long_prompt_keeps_the_active_input_visible() {
let mut editor = editor();
editor.state.pending[0].question.title = "A lengthy prompt. ".repeat(30);
editor.handle_paste("visible answer".into());
let buffer = render_editor(&editor, /*width*/ 40, /*height*/ 8);
let text = buffer_text(&buffer);
assert!(text.contains("visible answer"));
insta::assert_snapshot!("long_prompt_active_input", text);
}
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
(buffer.area.y..buffer.area.bottom())
.map(|y| {
(buffer.area.x..buffer.area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn question(title: &str, options: Option<Vec<String>>) -> AsyncUserInputQuestion {
AsyncUserInputQuestion {
title: title.into(),
options,
}
}
fn render_editor(editor: &AsyncQuestions, width: u16, height: u16) -> Buffer {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buffer = Buffer::empty(area);
editor.render(area, &mut buffer);
buffer
}
+95 -20
View File
@@ -52,7 +52,9 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use std::time::Duration;
use std::time::Instant;
@@ -61,11 +63,11 @@ 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;
mod questions;
mod request_user_input;
mod status_line_setup;
mod status_line_style;
@@ -86,6 +88,8 @@ pub(crate) use approval_overlay::ExecApprovalRequest;
pub(crate) use approval_overlay::McpElicitationApprovalRequest;
pub(crate) use approval_overlay::PermissionsApprovalRequest;
pub(crate) use approval_overlay::format_requested_permissions_rule;
pub(crate) use async_questions::AsyncQuestions;
pub(crate) use async_questions::QuestionSubmission;
pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest;
pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay;
pub(crate) use request_user_input::RequestUserInputOverlay;
@@ -237,6 +241,7 @@ pub(crate) struct BottomPane {
/// Stack of views displayed instead of the composer (e.g. popups/modals).
view_stack: Vec<Box<dyn BottomPaneView>>,
pub(crate) questions: Option<Box<AsyncQuestions>>,
delayed_approval_requests: VecDeque<DelayedApprovalRequest>,
last_composer_activity_at: Option<Instant>,
@@ -318,6 +323,7 @@ impl BottomPane {
Self {
composer,
view_stack: Vec::new(),
questions: None,
delayed_approval_requests: VecDeque::new(),
last_composer_activity_at: None,
app_event_tx,
@@ -452,6 +458,9 @@ impl BottomPane {
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.keymap = keymap.clone();
self.composer.set_keymap_bindings(keymap);
if let Some(questions) = &mut self.questions {
questions.set_keymap(keymap);
}
let interrupt_binding = keymap.primary_hint(KeymapContext::Chat, "interrupt_turn");
self.pending_input_preview
.set_interrupt_binding(interrupt_binding);
@@ -544,16 +553,25 @@ impl BottomPane {
binding: Option<crate::key_hint::ShortcutHint>,
) {
self.pending_input_preview.set_edit_binding(binding);
if let Some(questions) = &mut self.questions {
questions.next_hint = binding;
}
self.request_redraw();
}
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.composer.set_vim_enabled(enabled);
if let Some(questions) = &mut self.questions {
questions.set_vim_enabled(enabled);
}
self.request_redraw();
}
pub(crate) fn toggle_vim_enabled(&mut self) -> bool {
let enabled = self.composer.toggle_vim_enabled();
if let Some(questions) = &mut self.questions {
questions.set_vim_enabled(enabled);
}
self.request_redraw();
enabled
}
@@ -692,6 +710,29 @@ impl BottomPane {
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
let records_composer_activity =
matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !key_hint::has_ctrl_or_alt(key_event.modifiers)
&& matches!(
key_event.code,
KeyCode::Char(_)
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Enter
| KeyCode::Tab
);
if self.view_stack.is_empty()
&& let Some(questions) = self.questions.as_mut().filter(|q| q.expanded)
{
questions.composer.set_task_running(self.is_task_running);
questions.handle_key_event(key_event);
if records_composer_activity {
self.record_composer_activity_at(Instant::now());
}
self.request_redraw();
self.schedule_active_view_frame();
return InputResult::None;
}
// If a modal/view is active, handle it here; otherwise forward to composer.
if !self.view_stack.is_empty() {
if key_event.kind == KeyEventKind::Release {
@@ -753,17 +794,6 @@ impl BottomPane {
self.request_redraw();
return InputResult::None;
}
let records_composer_activity =
matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !key_hint::has_ctrl_or_alt(key_event.modifiers)
&& matches!(
key_event.code,
KeyCode::Char(_)
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Enter
| KeyCode::Tab
);
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
if records_composer_activity {
self.record_composer_activity_at(Instant::now());
@@ -782,6 +812,8 @@ impl BottomPane {
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if let Some(view) = self.view_stack.last() {
view.keymap_contexts()
} else if let Some(questions) = self.questions.as_ref().filter(|q| q.expanded) {
questions.keymap_contexts()
} else {
self.composer.keymap_contexts()
}
@@ -797,6 +829,11 @@ impl BottomPane {
/// was received, but it does not decide whether the process should exit; `ChatWidget` owns the
/// quit/interrupt state machine and uses the result to decide what happens next.
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.view_stack.is_empty()
&& let Some(questions) = self.questions.as_mut().filter(|q| q.expanded)
{
return questions.on_ctrl_c();
}
if let Some(view) = self.view_stack.last_mut() {
let event = view.on_ctrl_c();
let view_complete = view.is_complete();
@@ -824,6 +861,14 @@ impl BottomPane {
}
pub fn handle_paste(&mut self, pasted: String) {
if self.view_stack.is_empty()
&& let Some(questions) = self.questions.as_mut().filter(|q| q.expanded)
{
questions.handle_paste(pasted);
self.record_composer_activity_at(Instant::now());
self.request_redraw();
return;
}
let has_pasted_text = !pasted.is_empty();
if let Some(view) = self.view_stack.last_mut() {
let needs_redraw = view.handle_paste(pasted);
@@ -878,6 +923,14 @@ impl BottomPane {
}
fn schedule_active_view_frame(&self) {
if let Some(questions) = &self.questions {
if let Some(delay) = questions.next_frame_delay() {
self.request_redraw_in(delay);
}
if questions.is_in_paste_burst() {
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
}
}
if let Some(delay) = self.active_view().map_or_else(
|| self.composer.footer_flash_delay(),
BottomPaneView::next_frame_delay,
@@ -1506,8 +1559,12 @@ impl BottomPane {
}
pub(crate) fn terminal_title_requires_action(&self) -> bool {
self.active_view()
.is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action)
self.questions
.as_ref()
.is_some_and(|q| q.unanswered_count() > 0)
|| self
.active_view()
.is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action)
}
pub(crate) fn has_active_view(&self) -> bool {
@@ -1531,6 +1588,7 @@ impl BottomPane {
/// use Esc-Esc for backtracking from the main view.
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
!self.is_task_running
&& !self.questions.as_ref().is_some_and(|q| q.expanded)
&& self.view_stack.is_empty()
&& !self.composer.popup_active()
&& !self.inline_banner_accepts_dismissal()
@@ -1538,7 +1596,9 @@ impl BottomPane {
/// Return true when no popups or modal views are active, regardless of task state.
pub(crate) fn can_launch_external_editor(&self) -> bool {
self.view_stack.is_empty() && !self.composer.popup_active()
self.view_stack.is_empty()
&& !self.composer.popup_active()
&& !self.questions.as_ref().is_some_and(|q| q.expanded)
}
/// Returns true when the bottom pane has no active modal view and no active composer popup.
@@ -1794,20 +1854,26 @@ impl BottomPane {
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
// A covering view and the main composer can both retain buffered typing.
// Every editor can retain buffered input after losing focus. Flush all
// of them, matching the buffers that can defer drawing below.
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();
}
if let Some(questions) = &mut self.questions {
flushed |= questions.flush_paste_burst_if_due();
}
flushed
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
// A view can hold paste-burst state independently of the primary
// composer, so check it first.
self.view_stack
.last()
.is_some_and(|view| view.is_in_paste_burst())
|| self
.questions
.as_deref()
.is_some_and(bottom_pane_view::BottomPaneView::is_in_paste_burst)
|| self.composer.is_in_paste_burst()
}
@@ -1945,12 +2011,21 @@ impl BottomPane {
/*flex*/ 1,
RenderableItem::Borrowed(&self.pending_input_preview),
);
if !has_inline_previews && has_status_or_footer {
let question_editor = self.questions.as_ref().filter(|q| q.expanded);
if !has_inline_previews
&& has_status_or_footer
&& question_editor.is_none_or(|q| q.unanswered_count() > 1)
{
flex.push(/*flex*/ 0, RenderableItem::Owned("".into()));
}
if let Some(summary) = self.question_summary() {
flex.push(/*flex*/ 0, RenderableItem::Owned(Box::new(summary)));
}
let mut flex2 = FlexRenderable::new();
flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into()));
let composer: RenderableItem<'_> = if composer_right_reserve == 0 {
let composer: RenderableItem<'_> = if let Some(questions) = question_editor {
RenderableItem::Borrowed(questions.as_ref())
} else if composer_right_reserve == 0 {
RenderableItem::Borrowed(&self.composer)
} else {
RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable {
+52
View File
@@ -0,0 +1,52 @@
//! Own question-editor creation, restoration, and the collapsed entry point.
use super::*;
impl BottomPane {
pub(crate) fn push_async_questions(
&mut self,
message_id: &str,
questions: &[codex_protocol::items::AsyncUserInputQuestion],
) {
self.question_editor().append(message_id, questions);
self.schedule_active_view_frame();
self.request_redraw();
}
fn question_editor(&mut self) -> &mut AsyncQuestions {
self.questions.get_or_insert_with(|| {
let mut questions = AsyncQuestions::new(
self.app_event_tx.clone(),
self.has_input_focus,
self.enhanced_keys_supported,
self.disable_paste_burst,
self.keymap.clone(),
);
questions.set_vim_enabled(self.composer.is_vim_enabled());
questions.next_hint = self.pending_input_preview.edit_binding;
Box::new(questions)
})
}
pub(super) fn question_summary(&self) -> Option<Line<'static>> {
let questions = self
.questions
.as_ref()
.filter(|q| !q.expanded && q.unanswered_count() > 0)?;
let count = questions.unanswered_count();
let hint = self
.pending_input_preview
.edit_binding
.map(|key| format!(" · {} to answer", key.display_label()))
.unwrap_or_default();
Some(Line::from(vec![
" ? ".dim(),
Span::styled(
format!("{count} question{}", if count == 1 { "" } else { "s" }),
crate::style::accent_style(),
)
.bold(),
hint.dim(),
]))
}
}
+1
View File
@@ -357,6 +357,7 @@ mod input_flow;
mod input_restore;
mod input_submission;
mod interrupts;
mod questions;
use self::interrupts::InterruptManager;
mod keymap_picker;
mod mcp_startup;
+4 -1
View File
@@ -14,6 +14,9 @@ impl ChatWidget {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.handle_question_key(key_event) {
return;
}
if self.bottom_pane.has_active_view()
&& !matches!(
key_event,
@@ -521,7 +524,7 @@ impl ChatWidget {
///
/// When the double-press quit shortcut is enabled, pressing the same shortcut again before
/// expiry requests a shutdown-first quit.
fn on_ctrl_c(&mut self) {
pub(super) fn on_ctrl_c(&mut self) {
let key = key_hint::ctrl(KeyCode::Char('c'));
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
let should_pause_active_goal = self
+138
View File
@@ -0,0 +1,138 @@
//! Connect the retained question editor to ordinary input delivery and queue navigation.
//! Only an accepted local send or queue operation consumes an asynchronous answer.
use super::*;
use crate::bottom_pane::QuestionSubmission;
use codex_protocol::items::AsyncUserInputQuestion;
impl ChatWidget {
pub(super) fn add_async_questions(
&mut self,
message_id: &str,
questions: &[AsyncUserInputQuestion],
) {
self.bottom_pane.push_async_questions(message_id, questions);
}
pub(super) fn handle_question_key(&mut self, key: KeyEvent) -> bool {
if self.bottom_pane.has_active_view() {
return false;
}
if key.kind == KeyEventKind::Release {
return self
.bottom_pane
.questions
.as_ref()
.is_some_and(|q| q.expanded);
}
let expanded = self
.bottom_pane
.questions
.as_ref()
.is_some_and(|q| q.expanded);
let editing = expanded
&& self
.bottom_pane
.questions
.as_ref()
.is_some_and(|q| q.handles_key_as_editing(key));
let forward = self.chat_keymap.edit_queued_message.is_pressed(key);
let backward = self.chat_keymap.prompt_stack_back.is_pressed(key);
if key.kind == KeyEventKind::Press && !editing && (forward || backward) {
if let Some(questions) = self.bottom_pane.questions.as_mut().filter(|q| q.expanded) {
if questions.navigate(forward) {
self.request_redraw();
return true;
}
if backward {
questions.set_expanded(/*expanded*/ false);
self.request_redraw();
return true;
}
return true;
} else if forward
&& self.bottom_pane.no_modal_or_popup_active()
&& let Some(questions) = self
.bottom_pane
.questions
.as_mut()
.filter(|q| q.unanswered_count() > 0)
{
questions.set_expanded(/*expanded*/ true);
self.request_redraw();
return true;
}
return expanded;
}
if !expanded {
return false;
}
if key.code == KeyCode::Esc && self.local_settings.tui.question_esc_back && !editing {
if let Some(questions) = &mut self.bottom_pane.questions {
questions.set_expanded(/*expanded*/ false);
}
} else if key_hint::ctrl(KeyCode::Char('c')).is_press(key) {
self.on_ctrl_c();
} else {
let interrupting = !editing && self.chat_keymap.interrupt_turn.is_pressed(key);
self.bottom_pane.handle_key_event(key);
if interrupting {
self.pause_active_goal_for_interrupt();
}
let submission = self
.bottom_pane
.questions
.as_mut()
.and_then(|q| q.submission.take());
if let Some(submission) = submission {
let (text, queued) = match submission {
QuestionSubmission::Submit(text) => (text, false),
QuestionSubmission::Queue(text) => (text, true),
};
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return true;
}
if self.has_misalignment_policy_violation() {
return true;
}
let main = self.bottom_pane.composer_draft_snapshot();
let accepted = if queued
|| self.input_queue.suppress_queue_autosend
|| self.is_plan_streaming_in_tui()
|| self.input_queue.user_turn_pending_start
&& !self.turn_lifecycle.agent_turn_running
|| self.only_user_shell_commands_running()
{
self.queue_user_message(UserMessage::from(text));
true
} else {
self.submit_user_message_with_history_and_shell_escape_policy(
UserMessage::from(text),
UserMessageHistoryRecord::UserMessageText,
ShellEscapePolicy::Disallow,
)
.0
};
if accepted {
if let Some(questions) = &mut self.bottom_pane.questions {
questions.accept_answer();
}
} else if self.bottom_pane.composer_draft_snapshot() != main {
let cursor = main.cursor;
self.restore_composer_state(ThreadComposerState {
text: main.text,
text_elements: main.text_elements,
local_images: main.local_images,
remote_image_urls: main.remote_image_urls,
mention_bindings: main.mention_bindings,
pending_pastes: main.pending_pastes,
});
self.bottom_pane.set_composer_cursor(cursor);
}
}
}
self.request_redraw();
true
}
}
+6
View File
@@ -4,6 +4,9 @@ use super::*;
impl ChatWidget {
pub(crate) fn pause_for_disconnect(&mut self) {
if let Some(questions) = &mut self.bottom_pane.questions {
questions.delivery_enabled = false;
}
self.input_queue.recovered_queue = true;
self.input_queue.suppress_queue_autosend = true;
self.set_initial_user_message_submit_suppressed(/*suppressed*/ true);
@@ -61,6 +64,9 @@ impl ChatWidget {
}
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
if self.handle_question_key(key) {
return;
}
if key.kind == KeyEventKind::Press && self.chat_keymap.edit_queued_message.is_pressed(key) {
if let Some(composer) = self.pop_latest_queued_composer_state() {
self.restore_composer_state(composer);
+11 -5
View File
@@ -322,6 +322,9 @@ impl ChatWidget {
turn_id: &str,
from_replay: bool,
) {
if !from_replay && let Some(questions) = &item.questions {
self.add_async_questions(&item.id, questions);
}
self.transcript.last_completed_agent_message = Some((turn_id.to_string(), item.id.clone()));
let mut message = String::new();
for content in &item.content {
@@ -378,11 +381,14 @@ impl ChatWidget {
}
});
}
self.status_state.pending_status_indicator_restore = match item.phase {
// Models that don't support preambles only output AgentMessageItems on turn completion.
Some(MessagePhase::FinalAnswer) | None => !self.input_queue.pending_steers.is_empty(),
Some(MessagePhase::Commentary) => true,
};
self.status_state.pending_status_indicator_restore = item.questions.is_some()
|| match item.phase {
// Models that don't support preambles only output AgentMessageItems on turn completion.
Some(MessagePhase::FinalAnswer) | None => {
!self.input_queue.pending_steers.is_empty()
}
Some(MessagePhase::Commentary) => true,
};
self.maybe_restore_status_indicator_after_stream_idle();
}
+3
View File
@@ -278,3 +278,6 @@ pub(crate) use helpers::make_chatwidget_manual_with_sender;
pub(crate) use helpers::set_chatgpt_auth;
pub(crate) use helpers::set_fast_mode_test_catalog;
pub(super) use helpers::*;
#[path = "tests/questions_tests.rs"]
mod questions_tests;
@@ -0,0 +1,163 @@
//! Verify question input delivery, retained drafts, and visible editor behavior.
use super::*;
use codex_protocol::items::AsyncUserInputQuestion;
use pretty_assertions::assert_eq;
fn questions() -> Vec<AsyncUserInputQuestion> {
vec![
question("Which way?", /*options*/ None),
question("Any details?", /*options*/ None),
]
}
#[tokio::test]
async fn unavailable_send_keeps_answer_and_skip_remains_available() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.add_async_questions("message", &questions());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.bottom_pane.handle_paste("kept answer".into());
chat.blocks_direct_input = true;
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), 2);
chat.handle_key_event(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL));
assert_eq!(question_count(&chat), 1);
}
#[tokio::test]
async fn accepted_question_answer_uses_existing_delivery_and_keeps_main_draft() {
for queued in [false, true] {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.bottom_pane
.set_composer_text("main draft".into(), Vec::new(), Vec::new());
chat.input_queue.suppress_queue_autosend = queued;
chat.add_async_questions("message", &questions());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), 2);
if !queued {
insta::assert_snapshot!(
"freeform_question",
render_bottom_popup(&chat, /*width*/ 80)
);
}
chat.bottom_pane.handle_paste("!literal answer".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), 1);
assert_eq!(chat.bottom_pane.composer_text(), "main draft");
let expected = "> Which way?\n\n!literal answer";
if queued {
assert_eq!(
chat.input_queue.queued_user_messages.front().unwrap().text,
expected
);
assert!(op_rx.try_recv().is_err());
} else {
assert_answer(op_rx.try_recv().unwrap(), expected);
}
}
}
#[tokio::test]
async fn disconnected_questions_remain_editable_without_sending() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.add_async_questions("message", &questions());
chat.pause_for_disconnect();
chat.handle_disconnected_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.bottom_pane.handle_paste("offline draft".into());
chat.handle_disconnected_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), 2);
assert!(op_rx.try_recv().is_err());
chat.handle_disconnected_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL));
assert_eq!(question_count(&chat), 2);
}
fn question_count(chat: &ChatWidget) -> usize {
chat.bottom_pane
.questions
.as_ref()
.unwrap()
.unanswered_count()
}
fn open_questions(chat: &mut ChatWidget, options: Option<Vec<String>>) {
chat.thread_id = Some(ThreadId::new());
chat.on_agent_message_item_completed(
AgentMessageItem {
id: "review".into(),
content: Vec::new(),
phase: None,
memory_citation: None,
delivery: None,
questions: Some(vec![
AsyncUserInputQuestion {
title: "First?".into(),
options,
},
question("Second?", Some(vec!["Next".into()])),
]),
},
"turn",
/*from_replay*/ false,
);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
render_bottom_popup(chat, /*width*/ 80);
}
#[tokio::test]
async fn question_queue_key_does_not_steer_the_running_turn() {
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
open_questions(&mut chat, /*options*/ None);
chat.on_task_started();
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.bottom_pane.handle_paste("kept".into());
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
chat.bottom_pane.handle_paste(" later ".into());
chat.handle_key_event(KeyEvent::from(KeyCode::Tab));
assert_eq!(
chat.input_queue.queued_user_messages.front().unwrap().text,
"> First?\n\nlater"
);
let mut repeat = KeyEvent::from(KeyCode::Tab);
repeat.kind = KeyEventKind::Repeat;
chat.handle_key_event(repeat);
assert_eq!(question_count(&chat), 1);
assert!(ops.try_recv().is_err());
}
#[tokio::test]
async fn single_question_spacing_with_working_status() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.set_task_running(/*running*/ true);
chat.add_async_questions("single", &[question("Only question?", /*options*/ None)]);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
let rendered = render_bottom_popup(&chat, /*width*/ 80);
let rows: Vec<_> = rendered.lines().collect();
let question = rows
.iter()
.position(|line| line.trim() == "Only question?")
.unwrap();
assert!(rows[question - 2].contains("Working"));
assert!(rows[question - 1].is_empty());
insta::assert_snapshot!("single_question_working_spacing", rendered);
}
fn assert_answer(op: Op, expected: &str) {
let Op::UserTurn { items, .. } = op else {
panic!("user turn")
};
assert_eq!(
items,
vec![UserInput::Text {
text: expected.into(),
text_elements: Vec::new()
}]
);
}
fn question(title: &str, options: Option<Vec<String>>) -> AsyncUserInputQuestion {
AsyncUserInputQuestion {
title: title.into(),
options,
}
}
@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 80)"
---
1 of 2
Which way?
Type your answer
@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: rendered
---
• Working (0s • esc to interrupt)
Only question?
Type your answer
+12
View File
@@ -2693,12 +2693,24 @@ mod tests {
keymap.chat.previous_permission_mode = None;
keymap.chat.next_permission_mode = Some(one(binding));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
keymap.chat.next_permission_mode = None;
keymap.chat.skip_question = Some(one(binding));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
keymap.chat.skip_question = None;
keymap.chat.prompt_stack_back = Some(one(binding));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
}
#[cfg(windows)]
{
let mut keymap = TuiKeymap::default();
keymap.chat.next_permission_mode = Some(one("ctrl-alt-q"));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
keymap.chat.next_permission_mode = None;
keymap.chat.skip_question = Some(one("ctrl-alt-q"));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
keymap.chat.skip_question = None;
keymap.chat.prompt_stack_back = Some(one("ctrl-alt-q"));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
}
let mut keymap = TuiKeymap::default();
keymap.chat.previous_permission_mode = Some(one("f7"));
+33
View File
@@ -1152,6 +1152,39 @@ mod tests {
assert_snapshot!("keymap_picker_redo", redo_row);
}
#[test]
fn picker_question_actions_snapshot() {
let runtime = RuntimeKeymap::defaults();
let params = build_keymap_picker_params_for_selected_action(
&runtime,
&TuiKeymap::default(),
"chat",
"skip_question",
);
assert_snapshot!(
"keymap_question_actions",
render_picker(params, /*width*/ 120)
);
let descriptions = ["edit_queued_message", "prompt_stack_back", "skip_question"]
.map(|action| {
render_picker(
build_keymap_action_menu_params(
"chat".into(),
action.into(),
&runtime,
&TuiKeymap::default(),
),
/*width*/ 120,
)
.split("\n\n")
.next()
.unwrap()
.to_string()
})
.join("\n\n");
assert_snapshot!("keymap_question_action_descriptions", descriptions);
}
#[test]
fn picker_all_tab_items_remain_searchable() {
let runtime = RuntimeKeymap::defaults();
+3 -1
View File
@@ -98,7 +98,9 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
action("chat", "Chat", "previous_permission_mode", "Switch to the previous available permission mode."),
action("chat", "Chat", "next_permission_mode", "Switch to the next available permission mode."),
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
action("chat", "Chat", "edit_queued_message", "Move up through questions, then edit the last queued message."),
action("chat", "Chat", "prompt_stack_back", "Move back through questions toward the composer."),
action("chat", "Chat", "skip_question", "Skip the focused question."),
action("composer", "Composer", "submit", "Submit the current composer draft."),
action("composer", "Composer", "queue", "Queue the draft while a task is running."),
action("composer", "Composer", "toggle_shortcuts", "Show or hide the composer shortcut overlay."),
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
139 actions, 1 customized, 5 unbound.
141 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
140 actions, 0 customized, 6 unbound.
142 actions, 0 customized, 6 unbound.
[All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -2,11 +2,11 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (139 selectable)
tab: All (141 selectable)
tab: Common (20 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (5) (5 selectable)
tab: App (14 selectable)
tab: App (16 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (69 selectable)
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
139 actions, 1 customized, 5 unbound.
141 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
139 actions, 0 customized, 5 unbound.
141 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim
Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
139 actions, 0 customized, 5 unbound.
141 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -0,0 +1,24 @@
---
source: tui/src/keymap_setup.rs
expression: descriptions
---
Edit Shortcut
Edit Queued Message · Chat
Current alt-up, shift-left · Default keymap
Config `tui.keymap.chat.edit_queued_message`
Move up through questions, then edit the last queued message.
Edit Shortcut
Prompt Stack Back · Chat
Current alt-down, shift-right · Default keymap
Config `tui.keymap.chat.prompt_stack_back`
Move back through questions toward the composer.
Edit Shortcut
Skip Question · Chat
Current ctrl-] · Default keymap
Config `tui.keymap.chat.skip_question`
Skip the focused question.
@@ -0,0 +1,22 @@
---
source: tui/src/keymap_setup.rs
expression: "render_picker(params, 120)"
---
Keymap
All configurable shortcuts.
141 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
Type to search shortcuts
Chat Interrupt Turn esc
Chat Decrease Reasoning Effort alt-,, shift-down
Chat Increase Reasoning Effort alt-., shift-up
Chat - Previous Permission Mode unbound
Chat - Next Permission Mode unbound
Chat Edit Queued Message alt-up, shift-left
Chat Prompt Stack Back alt-down, shift-right
› Chat Skip Question ctrl-]
left/right group · enter edit shortcut · * custom · - unbound · esc close