Preserve TUI question state and integrate history and queue navigation (#42903)

## What changed

- Retain question drafts, selections, expanded state, and handled message IDs across thread input restoration and reconnects. Preserve buffered live questions during session refresh without replaying their message text.
- Enable history recall and search in question answers, honoring updated key bindings and stripping image attachments and placeholders from recalled entries. Preserve Vim mode when an answer is not accepted.
- Let forward navigation past the last question restore the latest queued message as an editable main composer draft. Keep working status and queued messages visible alongside questions.
- Show a countdown for newly arrived collapsed questions and clear it when the question editor is opened or used.
- Disable question submission and skipping while the thread is unavailable.

## Testing

Add regression tests and snapshots for draft restoration and replay deduplication, history search and key bindings, queued-message editing, countdown rendering, and question layout with working status.

GitOrigin-RevId: de016120a979db860fd0d4b8098b322b312b7d84
This commit is contained in:
Ian MacLeod
2026-09-05 01:13:54 +00:00
committed by copyberry
parent d05e6d5f46
commit be2684ede0
35 changed files with 700 additions and 87 deletions
+36 -2
View File
@@ -56,7 +56,19 @@ pub(super) struct ThreadEventStore {
}
impl ThreadEventStore {
pub(super) fn event_survives_session_refresh(event: &ThreadBufferedEvent) -> bool {
pub(super) fn event_survives_session_refresh(event: &mut ThreadBufferedEvent) -> bool {
if let ThreadBufferedEvent::Notification(notification) = event
&& let ServerNotification::ItemCompleted(notification) = notification.as_mut()
&& let ThreadItem::AgentMessage {
questions: Some(_),
text,
..
} = &mut notification.item
{
// Refreshed turns contain the text; retain only the live question state.
text.clear();
return true;
}
match event {
ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::FeedbackSubmission(_) => true,
ThreadBufferedEvent::Notification(notification) => matches!(
@@ -104,7 +116,7 @@ impl ThreadEventStore {
}
pub(super) fn rebase_buffer_after_session_refresh(&mut self) {
self.buffer.retain(Self::event_survives_session_refresh);
self.buffer.retain_mut(Self::event_survives_session_refresh);
self.buffered_agent_message_delta_bytes = 0;
}
@@ -721,6 +733,28 @@ mod tests {
assert_eq!(store.has_pending_thread_approvals(), false);
}
#[test]
fn refresh_retains_live_questions_without_replaying_their_text() {
let mut expected = serde_json::json!({
"method": "item/completed", "params": {
"threadId": "thread", "turnId": "turn", "completedAtMs": 0,
"item": {
"type": "agentMessage", "id": "question", "text": "already in the snapshot",
"phase": null, "memoryCitation": null, "delivery": null,
"questions": [{"title": "Which way?", "options": null}]
}
}
});
let mut store = ThreadEventStore::new(/*capacity*/ 8);
store.push_notification(serde_json::from_value(expected.clone()).unwrap());
store.rebase_buffer_after_session_refresh();
expected["params"]["item"]["text"] = serde_json::json!("");
let ThreadBufferedEvent::Notification(actual) = &store.snapshot().events[0] else {
panic!("missing live question");
};
assert_eq!(serde_json::to_value(actual).unwrap(), expected);
}
#[test]
fn thread_event_store_rebase_preserves_hook_notifications() {
let thread_id = ThreadId::new();
+1 -1
View File
@@ -1477,7 +1477,7 @@ impl App {
snapshot.turns = turns;
snapshot
.events
.retain(ThreadEventStore::event_survives_session_refresh);
.retain_mut(ThreadEventStore::event_survives_session_refresh);
}
/// Opens the `/subagents` picker after refreshing cached labels for known threads.
@@ -161,6 +161,7 @@ impl BottomPaneView for AsyncQuestions {
if key.kind == KeyEventKind::Release || self.is_complete() {
return;
}
self.snooze_auto_resolution();
if self.handles_key_as_editing(key) {
self.edit(key);
return;
@@ -287,6 +288,7 @@ impl BottomPaneView for AsyncQuestions {
if self.is_complete() || text.is_empty() {
return false;
}
self.snooze_auto_resolution();
if !self.focus_is_notes() {
self.focus_other();
}
@@ -299,8 +301,17 @@ impl BottomPaneView for AsyncQuestions {
fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
}
fn next_frame_delay(&self) -> Option<Duration> {
self.composer.footer_flash_delay()
self.timer_remaining(Instant::now())
.map(|remaining| {
if remaining > Duration::from_secs(20) {
remaining - Duration::from_secs(20)
} else {
remaining.min(Duration::from_secs(1))
}
})
.into_iter()
.chain(self.composer.footer_flash_delay())
.min()
}
}
@@ -21,6 +21,7 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use std::collections::HashSet;
use std::time::Duration;
use std::time::Instant;
use unicode_width::UnicodeWidthStr;
mod input;
@@ -34,16 +35,19 @@ pub(super) const DESIRED_SPACERS_BETWEEN_SECTIONS: u16 = 2;
#[derive(Debug, Clone, PartialEq)]
struct PendingQuestion {
message_id: String,
question: AsyncUserInputQuestion,
options_state: ScrollState,
draft: ComposerDraft,
expires_at: Option<Instant>,
}
/// Locally retained async questions; replayed message IDs cannot resurrect handled answers.
#[derive(Debug, Clone, Default, PartialEq)]
struct QuestionState {
pub(crate) struct QuestionState {
pending: Vec<PendingQuestion>,
current_idx: usize,
expanded: bool,
seen_ids: HashSet<String>,
}
@@ -56,6 +60,7 @@ pub(crate) struct AsyncQuestions {
app_event_tx: AppEventSender,
state: QuestionState,
pub(crate) expanded: bool,
pub(crate) has_queued_messages: bool,
pub(crate) delivery_enabled: bool,
pub(crate) submission: Option<QuestionSubmission>,
visible_options: std::cell::Cell<(usize, usize)>,
@@ -80,7 +85,10 @@ impl AsyncQuestions {
enhanced_keys_supported,
"Type your answer".into(),
disable_paste_burst,
ChatComposerConfig::plain_text(),
ChatComposerConfig {
reset_vim_on_submission: false,
..ChatComposerConfig::plain_text()
},
);
composer.set_keymap_bindings(&keymap);
composer.set_footer_hint_override(Some(Vec::new()));
@@ -88,6 +96,7 @@ impl AsyncQuestions {
app_event_tx,
state: QuestionState::default(),
expanded: false,
has_queued_messages: false,
delivery_enabled: true,
submission: None,
visible_options: std::cell::Cell::new((0, 0)),
@@ -210,6 +210,8 @@ impl AsyncQuestions {
}
let next = if self.state.current_idx + 1 < self.state.pending.len() {
Some("next question")
} else if self.has_queued_messages {
Some("queued messages")
} else {
None
};
@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: buffer_text(&buffer)
---
2 of 2
Second
11. Option 11
12. Option 12
› 13. earlier answer
reverse-i-search: e enter accept · esc
enter submit ctrl + ] skip option 13/13
⌥ + ↓ prev question
@@ -11,6 +11,7 @@ impl AsyncQuestions {
return;
}
let was_empty = self.state.pending.is_empty();
let expires_at = (!self.expanded).then(|| Instant::now() + Duration::from_secs(30));
self.state.pending.extend(questions.iter().map(|question| {
// Bound work before cloning or wrapping model-authored suggestions.
let question = AsyncUserInputQuestion {
@@ -31,9 +32,11 @@ impl AsyncQuestions {
let mut options_state = ScrollState::new();
options_state.selected_idx = has_options.then_some(0);
PendingQuestion {
message_id: message_id.into(),
question,
options_state,
draft: ComposerDraft::default(),
expires_at,
}
}));
if was_empty {
@@ -45,6 +48,9 @@ impl AsyncQuestions {
pub(crate) fn set_expanded(&mut self, expanded: bool) {
self.save_current_draft();
self.expanded = expanded && !self.state.pending.is_empty();
if self.expanded {
self.snooze_auto_resolution();
}
}
pub(crate) fn navigate(&mut self, forward: bool) -> bool {
@@ -63,6 +69,27 @@ impl AsyncQuestions {
true
}
pub(super) fn snooze_auto_resolution(&mut self) {
for question in &mut self.state.pending {
question.expires_at = None;
}
}
pub(super) fn timer_remaining(&self, now: Instant) -> Option<Duration> {
self.state
.pending
.iter()
.filter_map(|question| question.expires_at?.checked_duration_since(now))
.filter(|remaining| !remaining.is_zero())
.min()
}
pub(crate) fn countdown(&self, now: Instant) -> Option<String> {
self.timer_remaining(now)
.filter(|remaining| *remaining <= Duration::from_secs(20))
.map(|remaining| format!("{}s", remaining.as_secs_f64().ceil() as u64))
}
pub(super) fn go_next_or_submit(&mut self) {
self.save_current_draft();
if !self.delivery_enabled {
@@ -83,7 +110,7 @@ impl AsyncQuestions {
if !self.focus_is_notes() && !(first..first + count).contains(&index) {
self.composer.show_footer_flash(
"Expand terminal to read the entire option".into(),
std::time::Duration::from_secs(5),
Duration::from_secs(5),
);
return;
}
@@ -98,7 +125,7 @@ impl AsyncQuestions {
if text.chars().count() > limit {
self.composer.show_footer_flash(
format!("Answer too long; limit {limit} characters").into(),
std::time::Duration::from_secs(5),
Duration::from_secs(5),
);
} else if !text.is_empty() {
self.submission = Some(QuestionSubmission::Submit(format!("{framing}{text}")));
@@ -119,4 +146,28 @@ impl AsyncQuestions {
self.restore_current_draft();
self.composer.reset_vim_mode();
}
pub(crate) fn capture(&mut self) -> QuestionState {
self.composer.cancel_history_search();
self.save_current_draft();
self.state.expanded = self.expanded;
self.state.clone()
}
pub(crate) fn restore(&mut self, saved: QuestionState) {
self.visible_options.set((0, 0));
let incoming = std::mem::replace(&mut self.state, saved);
self.state.pending.extend(
incoming
.pending
.into_iter()
.filter(|question| !self.state.seen_ids.contains(&question.message_id)),
);
self.state.seen_ids.extend(incoming.seen_ids);
self.expanded = self.state.expanded && !self.state.pending.is_empty();
if self.expanded {
self.snooze_auto_resolution();
}
self.restore_current_draft();
}
}
@@ -28,6 +28,27 @@ fn editor() -> AsyncQuestions {
editor
}
#[test]
fn replay_preserves_drafts_and_does_not_reopen_handled_questions() {
let mut original = editor();
original.set_expanded(/*expanded*/ true);
original.accept_answer();
original.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
let saved = original.capture();
for replay_first in [false, true] {
let mut restored = editor();
if !replay_first {
restored.state = QuestionState::default();
}
restored.restore(saved.clone());
restored.append("message", &[question("First", /*options*/ None)]);
assert_eq!(restored.capture(), saved);
restored.accept_answer();
restored.append("message", &[question("First", /*options*/ None)]);
assert_eq!(restored.unanswered_count(), 0);
}
}
#[test]
fn arrival_preserves_vim_undo_and_navigation_flushes_buffered_input() {
let mut editor = editor();
@@ -174,6 +195,33 @@ fn question_choice_remaps_paging_and_inline_feedback() {
insta::assert_snapshot!("question_remapped_choice_footer", buffer_text(&buffer));
}
#[test]
fn question_inline_history_search() {
let mut editor = editor();
editor.navigate(/*forward*/ true);
editor.state.pending[1].question.options =
Some((1..=12).map(|i| format!("Option {i}")).collect());
editor.select_option(/*index*/ 12);
editor
.composer
.record_replayed_user_message_history(crate::bottom_pane::HistoryEntry::new(
"earlier answer".into(),
));
editor.handle_paste("original answer".into());
let saved = editor.capture();
editor.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
editor.handle_key_event(KeyEvent::from(KeyCode::Char('e')));
let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 12);
assert!(
buffer
.content
.iter()
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
);
insta::assert_snapshot!("question_inline_history_search", buffer_text(&buffer));
assert_eq!(editor.capture(), saved);
}
#[test]
fn question_other_remains_visible_at_nine_columns_and_distinguishes_named_other() {
let mut editor = editor();
@@ -328,3 +376,19 @@ fn existing_cross_context_keymaps_load_without_misleading_submit_hints() {
assert!(editor.submission.is_none());
}
}
#[test]
fn question_navigation_resets_history_recall() {
let mut editor = editor();
for text in ["older", "newer"] {
editor.composer.record_replayed_user_message_history(
crate::bottom_pane::HistoryEntry::new(text.into()),
);
}
editor.handle_key_event(KeyCode::Up.into());
assert_eq!(editor.composer.current_text(), "newer");
editor.navigate(/*forward*/ true);
editor.navigate(/*forward*/ false);
editor.handle_key_event(KeyCode::Up.into());
assert_eq!(editor.composer.current_text(), "newer");
}
+28 -27
View File
@@ -54,14 +54,12 @@
//! mention bindings, but attachments cannot be restored).
//! - Local in-session history (full text + text elements + local/remote image attachments).
//!
//! When recalling a local entry, the composer rehydrates text elements and both attachment kinds
//! (local image paths + remote image URLs).
//! Plain-text history recall strips images and their placeholders.
//! When recalling a persistent entry, encoded task links restore atomic elements and bindings.
//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like
//! history traversal semantics instead of dropping to column 0.
//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the
//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as
//! an editable draft and `Esc` restores the draft that was active when search started.
//! Recall moves the cursor to the end. Question editors copy primary history on recall/search;
//! draft capture cancels previews, and restoration resets traversal.
//! Ctrl+R searches history in the footer and previews matches in the composer.
//! Enter accepts the preview; Esc restores the original draft.
//! Vim undo/redo snapshots complete drafts and groups direct edits with active Vim transactions.
//! An active edit keeps one separately capped snapshot; canceling does not evict committed history.
//! Canceled history previews restore history and active commands; accepting another prompt resets them.
@@ -483,6 +481,8 @@ pub(crate) struct ChatComposerConfig {
pub(crate) image_paste_enabled: bool,
/// Strip leading and trailing whitespace from submissions.
pub(crate) trim_submission: bool,
/// Embedded editors reset Vim only when their owner accepts the answer.
pub(crate) reset_vim_on_submission: bool,
}
impl Default for ChatComposerConfig {
@@ -493,6 +493,7 @@ impl Default for ChatComposerConfig {
shell_commands_enabled: true,
image_paste_enabled: true,
trim_submission: true,
reset_vim_on_submission: true,
}
}
}
@@ -509,6 +510,7 @@ impl ChatComposerConfig {
shell_commands_enabled: false,
image_paste_enabled: false,
trim_submission: true,
reset_vim_on_submission: true,
}
}
}
@@ -1580,18 +1582,7 @@ impl ChatComposer {
);
}
/// Replace the entire composer content while restoring mention link targets.
///
/// Mention popup insertion stores both visible text (for example `$file`)
/// and hidden mention bindings used to resolve the canonical target during
/// submission. Use this method when restoring an interrupted or blocked
/// draft; if callers restore only text and images, mentions can appear
/// intact to users while resolving to the wrong target or dropping on
/// retry.
///
/// This helper intentionally places the cursor at the start of the restored text. Callers
/// that need end-of-line restore behavior (for example shell-style history recall) should call
/// [`Self::move_cursor_to_end`] after this method.
/// Restore draft content; clear pending input and undo history. The cursor starts at zero.
pub(crate) fn set_text_content_with_mention_bindings(
&mut self,
text: String,
@@ -1797,13 +1788,8 @@ impl ChatComposer {
}
}
/// Rehydrate a history entry into the composer with shell-like cursor placement.
///
/// This path restores text, elements, images, mention bindings, and pending paste payloads,
/// then moves the cursor to the active mode's history boundary. If a caller reused
/// [`Self::set_text_content_with_mention_bindings`] directly for history recall and forgot the
/// final cursor move, repeated Up/Down would stop navigating history because cursor-gating
/// treats interior positions as normal editing mode.
/// Recall content at its history boundary, preserving pending pastes and omitting images
/// in plain-text editors.
fn apply_history_entry(&mut self, entry: HistoryEntry) {
let HistoryEntry {
text,
@@ -1821,6 +1807,20 @@ impl ChatComposer {
mention_bindings,
);
self.set_pending_pastes(pending_pastes);
if !self.config.image_paste_enabled {
for image in self.attachments.local_images() {
if let Some(element) = self.draft.textarea.text_elements().into_iter().find(|e| {
e.placeholder(self.draft.textarea.text()) == Some(image.placeholder.as_str())
}) {
let range = element.byte_range;
self.draft
.textarea
.replace_range(range.start..range.end, "");
}
}
self.attachments = AttachmentState::default();
}
self.history.record_recalled_text(self.current_text());
self.move_cursor_to_history_entry_end();
}
@@ -3203,7 +3203,8 @@ impl ChatComposer {
| InputResult::Command(_)
| InputResult::ServiceTierCommand(_)
| InputResult::CommandWithArgs(_, _, _)
) {
) && self.config.reset_vim_on_submission
{
self.reset_vim_mode();
}
}
@@ -32,6 +32,25 @@ impl ChatComposer {
self.draft.textarea.enter_vim_insert_mode();
}
/// Refresh at traversal start without resetting recalled entries or pending lookups.
pub(crate) fn copy_history_for_key(&mut self, composer: &Self, key: KeyEvent) {
let recall = self.is_empty()
&& !self.history.is_navigating()
&& if self.draft.textarea.is_vim_normal_mode() {
self.vim_normal_keymap.move_up.is_pressed(key)
|| self.vim_normal_keymap.move_down.is_pressed(key)
} else {
self.editor_keymap.move_up.is_pressed(key)
|| self.editor_keymap.move_down.is_pressed(key)
};
let search =
self.history_search_previous_keys.is_pressed(key) && !self.handles_key_as_editing(key);
if self.history_search.is_none() && (recall || search) {
self.history = composer.history.clone();
self.history.reset_navigation();
}
}
/// Give modal editing precedence over a containing view's submit/navigation keys.
/// Up/Down remain available to inline choices; j/k keep their Vim editing behavior.
pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool {
@@ -351,6 +351,15 @@ impl ChatComposerHistory {
true
}
/// Track the displayed text when a composer normalizes a recalled entry.
pub(super) fn record_recalled_text(&mut self, text: String) {
self.last_history_text = Some(text);
}
pub(super) fn is_navigating(&self) -> bool {
self.history_cursor.is_some()
}
/// Resets normal history navigation so the next Up key resumes from the newest entry.
///
/// This also clears any active incremental search, since normal browsing and Ctrl+R search
@@ -554,7 +563,7 @@ impl ChatComposerHistory {
.map(HistoryEntryResponse::Found)
.unwrap_or(HistoryEntryResponse::Ignored);
}
self.last_history_text = Some(entry.text.clone());
self.record_recalled_text(entry.text.clone());
return HistoryEntryResponse::Found(entry);
}
@@ -788,7 +797,7 @@ impl ChatComposerHistory {
fn search_match(&mut self, offset: usize, entry: HistoryEntry) -> HistorySearchResult {
self.history_cursor = Some(offset as isize);
self.last_history_text = Some(entry.text.clone());
self.record_recalled_text(entry.text.clone());
if let Some(search) = self.search.as_mut() {
search.selected_offset = Some(offset);
search.record_match(offset, &entry);
@@ -817,7 +826,7 @@ impl ChatComposerHistory {
let history_match = self.search.as_ref()?.unique_matches[next_index].clone();
self.history_cursor = Some(history_match.offset as isize);
self.last_history_text = Some(history_match.entry.text.clone());
self.record_recalled_text(history_match.entry.text.clone());
if let Some(search) = self.search.as_mut() {
search.select_match(next_index);
}
@@ -864,7 +873,7 @@ impl ChatComposerHistory {
continue;
}
self.pending_navigation_direction = None;
self.last_history_text = Some(entry.text.clone());
self.record_recalled_text(entry.text.clone());
return Some(entry);
}
@@ -1596,7 +1605,7 @@ mod tests {
);
history.reset_navigation();
assert!(history.history_cursor.is_none());
assert!(!history.is_navigating());
assert!(history.last_history_text.is_none());
assert_eq!(
+50 -27
View File
@@ -55,6 +55,7 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use std::time::Duration;
use std::time::Instant;
@@ -67,6 +68,9 @@ mod async_questions;
mod hook_status;
mod mcp_server_elicitation;
mod multi_select_picker;
#[cfg(test)]
#[path = "questions_tests.rs"]
mod question_tests;
mod questions;
mod request_user_input;
mod status_line_setup;
@@ -89,6 +93,7 @@ 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::QuestionState;
pub(crate) use async_questions::QuestionSubmission;
pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest;
pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay;
@@ -724,6 +729,9 @@ impl BottomPane {
if self.view_stack.is_empty()
&& let Some(questions) = self.questions.as_mut().filter(|q| q.expanded)
{
questions
.composer
.copy_history_for_key(&self.composer, key_event);
questions.composer.set_task_running(self.is_task_running);
questions.handle_key_event(key_event);
if records_composer_activity {
@@ -1878,29 +1886,28 @@ impl BottomPane {
}
pub(crate) fn on_history_lookup_response(&mut self, response: HistoryLookupResponse) {
let updated = match response {
HistoryLookupResponse::Entry {
offset,
log_id,
entry,
} => self
.composer
.on_history_entry_response(log_id, offset, entry),
HistoryLookupResponse::Batch {
cursor,
log_id,
entries,
next_older_cursor,
} => {
self.composer
.on_history_batch_response(log_id, cursor, entries, next_older_cursor)
}
HistoryLookupResponse::BatchError { cursor, log_id } => {
self.composer.on_history_batch_error(log_id, cursor)
}
};
let mut updated = false;
for composer in std::iter::once(&mut self.composer)
.chain(self.questions.iter_mut().map(|q| &mut q.composer))
{
updated |= match response.clone() {
HistoryLookupResponse::Entry {
offset,
log_id,
entry,
} => composer.on_history_entry_response(log_id, offset, entry),
HistoryLookupResponse::Batch {
cursor,
log_id,
entries,
next_older_cursor,
} => composer.on_history_batch_response(log_id, cursor, entries, next_older_cursor),
HistoryLookupResponse::BatchError { cursor, log_id } => {
composer.on_history_batch_error(log_id, cursor)
}
};
}
if updated {
self.composer.sync_popups();
self.request_redraw();
}
}
@@ -1990,7 +1997,12 @@ impl BottomPane {
);
}
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
let has_pending_input = !self.pending_input_preview.queued_messages.is_empty()
let has_questions = self
.questions
.as_ref()
.is_some_and(|q| q.unanswered_count() > 0);
let has_pending_input = has_questions
|| !self.pending_input_preview.queued_messages.is_empty()
|| !self.pending_input_preview.pending_steers.is_empty()
|| !self.pending_input_preview.rejected_steers.is_empty();
let has_status_or_footer = self.status_widget().is_some()
@@ -2009,7 +2021,15 @@ impl BottomPane {
}
flex.push(
/*flex*/ 1,
RenderableItem::Borrowed(&self.pending_input_preview),
if has_questions {
RenderableItem::Owned(Box::new(
pending_input_preview::PendingInputPreviewContent(
&self.pending_input_preview,
),
))
} else {
RenderableItem::Borrowed(&self.pending_input_preview)
},
);
let question_editor = self.questions.as_ref().filter(|q| q.expanded);
if !has_inline_previews
@@ -2018,8 +2038,11 @@ impl BottomPane {
{
flex.push(/*flex*/ 0, RenderableItem::Owned("".into()));
}
if let Some(summary) = self.question_summary() {
flex.push(/*flex*/ 0, RenderableItem::Owned(Box::new(summary)));
if let Some(summary) = self.question_summary(Instant::now()) {
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(Paragraph::new(summary))),
);
}
let mut flex2 = FlexRenderable::new();
flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into()));
@@ -2170,7 +2193,7 @@ mod tests {
snapshot_buffer(&buf)
}
fn test_pane(app_event_tx: AppEventSender) -> BottomPane {
pub(super) fn test_pane(app_event_tx: AppEventSender) -> BottomPane {
test_pane_with_disable_paste_burst(app_event_tx, /*disable_paste_burst*/ false)
}
@@ -31,6 +31,11 @@ pub(crate) struct PendingInputPreview {
interrupt_binding: Option<key_hint::ShortcutHint>,
}
enum QuestionPresence {
Absent,
Present,
}
const PREVIEW_LINE_LIMIT: usize = 3;
impl PendingInputPreview {
@@ -76,10 +81,12 @@ impl PendingInputPreview {
));
}
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
fn as_renderable(&self, width: u16, questions: QuestionPresence) -> Box<dyn Renderable> {
let has_questions = matches!(questions, QuestionPresence::Present);
if (self.pending_steers.is_empty()
&& self.rejected_steers.is_empty()
&& self.queued_messages.is_empty())
&& self.queued_messages.is_empty()
&& !has_questions)
|| width < 4
{
return Box::new(());
@@ -136,7 +143,7 @@ impl PendingInputPreview {
}
}
if !self.queued_messages.is_empty() {
if !self.queued_messages.is_empty() || has_questions {
if !lines.is_empty() {
lines.push(Line::from(""));
}
@@ -161,6 +168,7 @@ impl PendingInputPreview {
}
if !self.queued_messages.is_empty()
&& !has_questions
&& let Some(edit_binding) = self.edit_binding
{
lines.push(
@@ -183,11 +191,30 @@ impl Renderable for PendingInputPreview {
return;
}
self.as_renderable(area.width).render(area, buf);
self.as_renderable(area.width, QuestionPresence::Absent)
.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_renderable(width).desired_height(width)
self.as_renderable(width, QuestionPresence::Absent)
.desired_height(width)
}
}
/// Pending questions keep the follow-up group visible and provide its navigation hint.
pub(super) struct PendingInputPreviewContent<'a>(pub(super) &'a PendingInputPreview);
impl Renderable for PendingInputPreviewContent<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.0
.as_renderable(area.width, QuestionPresence::Present)
.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.0
.as_renderable(width, QuestionPresence::Present)
.desired_height(width)
}
}
+18 -8
View File
@@ -28,25 +28,35 @@ impl BottomPane {
})
}
pub(super) fn question_summary(&self) -> Option<Line<'static>> {
pub(crate) fn restore_questions(&mut self, state: Option<QuestionState>) {
if let Some(state) = state {
self.question_editor().restore(state);
}
self.request_redraw();
}
pub(super) fn question_summary(&self, now: Instant) -> Option<Vec<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()))
let countdown = questions
.countdown(now)
.map(|text| format!(" · {text}"))
.unwrap_or_default();
Some(Line::from(vec![
let mut lines = vec![Line::from(vec![
" ? ".dim(),
Span::styled(
format!("{count} question{}", if count == 1 { "" } else { "s" }),
crate::style::accent_style(),
)
.bold(),
hint.dim(),
]))
countdown.dim(),
])];
if let Some(binding) = self.pending_input_preview.edit_binding {
lines.push(Line::from(vec![" ".into(), binding.into(), " to answer".into()]).dim());
}
Some(lines)
}
}
@@ -0,0 +1,52 @@
//! Question orchestration retains input buffers and renders pending-question summaries.
use super::*;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn questions_flush_buffered_typing_and_record_activity() {
let (tx, _rx) = unbounded_channel();
let mut pane = super::tests::test_pane(AppEventSender::new(tx));
let questions = [codex_protocol::items::AsyncUserInputQuestion {
title: "Which way?".into(),
options: None,
}];
pane.push_async_questions("message", &questions);
pane.questions
.as_mut()
.unwrap()
.set_expanded(/*expanded*/ true);
pane.handle_paste("pasted ".into());
assert!(pane.last_composer_activity_at.is_some());
pane.last_composer_activity_at = None;
for ch in "answer".chars() {
pane.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
assert!(pane.last_composer_activity_at.is_some());
std::thread::sleep(paste_burst::PasteBurst::recommended_active_flush_delay());
assert!(pane.flush_paste_burst_if_due());
assert_eq!(
pane.questions.as_ref().unwrap().composer.current_text(),
"pasted answer"
);
pane.questions
.as_mut()
.unwrap()
.set_expanded(/*expanded*/ false);
pane.push_async_questions("next", &questions);
let now = Instant::now() + Duration::from_secs(15);
insta::assert_snapshot!(
"question_collapsed_countdown",
pane.question_summary(now)
.unwrap()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
);
let editor = pane.questions.as_mut().unwrap();
assert!(editor.countdown(now + Duration::from_secs(30)).is_none());
editor.set_expanded(/*expanded*/ true);
editor.append("while-open", &questions);
assert!(editor.countdown(now).is_none());
}
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/questions_tests.rs
expression: "pane.question_summary(now).unwrap().iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\")"
---
? 2 questions · 15s
⌥ + ↑ to answer
@@ -247,6 +247,10 @@ impl ChatWidget {
/// Rebuild and update the bottom-pane pending-input preview.
pub(super) fn refresh_pending_input_preview(&mut self) {
let has_queued = self.has_queued_follow_up_messages();
if let Some(questions) = &mut self.bottom_pane.questions {
questions.has_queued_messages = has_queued;
}
let preview = self.input_queue.preview();
self.bottom_pane.set_pending_input_preview(
preview.queued_messages,
+12 -1
View File
@@ -98,6 +98,11 @@ impl ChatWidget {
pending_draft: &mut Option<ComposerDraftSnapshot>,
) {
if self.has_active_view()
|| self
.bottom_pane
.questions
.as_ref()
.is_some_and(|q| q.expanded)
|| self.has_pending_protected_request()
|| !self.bottom_pane.composer_input_enabled()
{
@@ -432,7 +437,7 @@ impl ChatWidget {
}
}
pub(crate) fn capture_thread_input_state(&self) -> Option<ThreadInputState> {
pub(crate) fn capture_thread_input_state(&mut self) -> Option<ThreadInputState> {
let draft = self.bottom_pane.composer_draft_snapshot();
let composer = ThreadComposerState {
text: draft.text,
@@ -443,6 +448,11 @@ impl ChatWidget {
pending_pastes: draft.pending_pastes,
};
Some(ThreadInputState {
questions: self
.bottom_pane
.questions
.as_deref_mut()
.map(crate::bottom_pane::AsyncQuestions::capture),
composer: composer.has_content().then_some(composer),
safety_buffering_prompt: self.safety_buffering_prompt.clone(),
pending_steers: self.input_queue.pending_steers.clone(),
@@ -474,6 +484,7 @@ impl ChatWidget {
let restored_task_running =
preserve_in_flight_turn && input_state.as_ref().is_some_and(|state| state.task_running);
if let Some(input_state) = input_state {
self.bottom_pane.restore_questions(input_state.questions);
self.input_queue.recovered_queue = input_state.recovered_queue;
self.current_collaboration_mode = input_state.current_collaboration_mode;
self.active_collaboration_mask = input_state.active_collaboration_mask;
+10
View File
@@ -440,6 +440,16 @@ impl ChatWidget {
notification: ItemCompletedNotification,
replay_kind: Option<ReplayKind>,
) {
// Buffered live notifications can introduce questions; historical turn replay cannot.
if replay_kind == Some(ReplayKind::ThreadSnapshot)
&& let ThreadItem::AgentMessage {
id,
questions: Some(questions),
..
} = &notification.item
{
self.add_async_questions(id, questions);
}
match notification.item {
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item),
item => self.handle_thread_item(
+14 -1
View File
@@ -12,6 +12,7 @@ impl ChatWidget {
questions: &[AsyncUserInputQuestion],
) {
self.bottom_pane.push_async_questions(message_id, questions);
self.refresh_pending_input_preview();
}
pub(super) fn handle_question_key(&mut self, key: KeyEvent) -> bool {
@@ -49,7 +50,6 @@ impl ChatWidget {
self.request_redraw();
return true;
}
return true;
} else if forward
&& self.bottom_pane.no_modal_or_popup_active()
&& let Some(questions) = self
@@ -62,6 +62,19 @@ impl ChatWidget {
self.request_redraw();
return true;
}
if expanded
&& forward
&& !self.blocks_direct_input
&& let Some(composer) = self.pop_latest_queued_composer_state()
{
if let Some(questions) = &mut self.bottom_pane.questions {
questions.set_expanded(/*expanded*/ false);
}
self.restore_composer_state(composer);
self.refresh_pending_input_preview();
self.request_redraw();
return true;
}
return expanded;
}
if !expanded {
+3
View File
@@ -54,6 +54,9 @@ impl ChatWidget {
}
pub(crate) fn pause_unavailable_thread(&mut self) {
if let Some(questions) = &mut self.bottom_pane.questions {
questions.delivery_enabled = false;
}
self.turn_lifecycle
.restore_running(/*running*/ false, Instant::now());
self.update_task_running_state();
@@ -1756,6 +1756,7 @@ async fn restore_thread_input_state_applies_running_state_policy() {
text_elements: Vec::new(),
});
let input_state = ThreadInputState {
questions: None,
composer: Some(ThreadComposerState {
text: "composer draft".to_string(),
..Default::default()
@@ -2441,8 +2442,12 @@ async fn reconnect_holds_only_recovered_input_until_manually_edited() {
chat.set_queue_autosend_suppressed(/*suppressed*/ false);
if let Some(text) = recovered {
assert!(!chat.maybe_send_next_queued_input());
assert_eq!(chat.pop_latest_queued_composer_state().unwrap().text, text);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert_eq!(chat.bottom_pane.composer_text(), text);
assert_no_submit_op(&mut ops);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(next_submit_op(&mut ops), Op::UserTurn { .. });
chat.input_queue.user_turn_pending_start = false;
}
chat.input_queue
.queued_user_messages
@@ -136,6 +136,12 @@ async fn question_inputs_support_vim_editing_and_search() {
}
assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded);
let normal = render_bottom_popup(&chat, /*width*/ 80);
let editor = chat.bottom_pane.questions.as_mut().unwrap();
editor.delivery_enabled = false;
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), normal);
let editor = chat.bottom_pane.questions.as_mut().unwrap();
editor.delivery_enabled = true;
for ch in "dw".chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
@@ -169,6 +175,101 @@ async fn question_inputs_support_vim_editing_and_search() {
}
}
#[tokio::test]
async fn question_history_search_uses_updated_bindings() {
for (binding, word) in [("f12", "earlier"), ("ctrl-x r", "persistent")] {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.bottom_pane
.set_history_metadata(thread_id, /*log_id*/ 7, /*entry_count*/ 1);
chat.bottom_pane.record_replayed_user_message_history(
crate::bottom_pane::HistoryEntry::with_pending_and_remote(
"[Image #2]earlier answer".into(),
vec![TextElement::new((0..10).into(), Some("[Image #2]".into()))],
vec![PathBuf::from("/tmp/image.png")],
Vec::new(),
vec!["https://example.com/remote.png".into()],
),
);
chat.add_async_questions("message", &questions());
let config = toml::from_str(&format!(
"[composer]\nhistory_search_previous = '{binding}'"
))
.unwrap();
let keymap = RuntimeKeymap::from_config(&config).unwrap();
chat.apply_keymap_update(config, &keymap);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.bottom_pane.record_replayed_user_message_history(
crate::bottom_pane::HistoryEntry::new("newest answer".into()),
);
for (key, expected) in [
(KeyCode::Up, "newest answer"),
(KeyCode::Up, "earlier answer"),
(KeyCode::Down, "newest answer"),
] {
chat.handle_key_event(KeyEvent::from(key));
let rendered = render_bottom_popup(&chat, /*width*/ 80);
assert!(
rendered.contains(expected),
"{key:?}: expected {expected}, got {rendered}"
);
}
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
// Chords reach the editor using the dispatch binding resolved by the app.
let (key, modifiers) = keymap.composer.history_search_previous[0].parts();
chat.handle_key_event(KeyEvent::new(key, modifiers));
for ch in word.chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
if binding != "f12" {
chat.bottom_pane.on_history_lookup_response(
crate::app_event::HistoryLookupResponse::Entry {
offset: 0,
log_id: 7,
entry: Some("persistent answer".into()),
},
);
}
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(op_rx.try_recv().is_err());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_answer(
op_rx.try_recv().unwrap(),
&format!("> Which way?\n\n{word} answer"),
);
}
}
#[tokio::test]
async fn question_editor_keeps_working_status_and_queued_messages_visible() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.bottom_pane.set_task_running(/*running*/ true);
chat.bottom_pane
.set_composer_text("main draft".into(), Vec::new(), Vec::new());
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued follow-up".to_string()).into());
chat.refresh_pending_input_preview();
chat.add_async_questions("message", &questions());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
let open = render_bottom_popup(&chat, /*width*/ 80);
let open = open.split("\n 1 of 2").next().unwrap().trim_end();
chat.bottom_pane.set_task_running(/*running*/ false);
assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded);
chat.input_queue.queued_user_messages.clear();
chat.refresh_pending_input_preview();
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
chat.bottom_pane.set_status_line_enabled(/*enabled*/ false);
let completed = render_bottom_popup(&chat, /*width*/ 80);
let completed = completed.split("\n\n›").next().unwrap();
insta::assert_snapshot!(
"questions_with_status_and_queue",
format!("OPEN\n{open}\n\nONLY QUESTIONS\n{completed}")
);
}
#[tokio::test]
async fn async_questions_reject_blank_answers_but_allow_explicit_skip() {
for options in [None, Some(vec!["Named".to_string()])] {
@@ -274,6 +375,94 @@ async fn question_key_repeats_do_not_consume_another_question() {
}
}
#[tokio::test]
async fn question_queue_pop_becomes_an_ordinary_composer_draft() {
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
for text in ["older", "newer"] {
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from(text).into());
}
open_questions(&mut chat, /*options*/ None);
let forward = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT);
chat.handle_key_event(forward);
insta::assert_snapshot!(
"last_question_with_queue",
render_bottom_popup(&chat, /*width*/ 80)
);
chat.handle_key_event(forward);
assert_eq!(chat.bottom_pane.composer_text(), "newer");
chat.bottom_pane.handle_paste(" edited".into());
chat.handle_key_event(forward);
assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded);
assert_eq!(chat.bottom_pane.composer_text(), "newer edited");
chat.handle_key_event(forward);
assert_eq!(chat.bottom_pane.composer_text(), "older");
assert!(chat.input_queue.queued_user_messages.is_empty());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_answer(ops.try_recv().unwrap(), "older");
chat.handle_key_event(forward);
assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded);
assert!(
!chat
.bottom_pane
.questions
.as_ref()
.unwrap()
.has_queued_messages
);
}
#[tokio::test]
async fn question_drafts_survive_navigation_and_snapshot_replay() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane
.set_composer_text("main draft".into(), Vec::new(), Vec::new());
open_questions(&mut chat, /*options*/ None);
chat.bottom_pane.handle_paste("first draft".into());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
chat.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
chat.bottom_pane.handle_paste("second draft".into());
let saved = chat.capture_thread_input_state();
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
let item = AppServerThreadItem::AgentMessage {
id: "buffered".into(),
text: String::new(),
phase: None,
memory_citation: None,
delivery: None,
questions: Some(vec![question("Buffered?", /*options*/ None)]),
};
for kind in [
ReplayKind::ResumeInitialMessages,
ReplayKind::ThreadSnapshot,
] {
chat.replay_thread_item(item.clone(), "turn".into(), kind);
assert!(chat.bottom_pane.questions.is_none());
}
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
item,
thread_id: "thread".into(),
turn_id: "turn".into(),
completed_at_ms: 0,
}),
Some(ReplayKind::ThreadSnapshot),
);
assert_eq!(question_count(&chat), 1);
chat.restore_reconnected_input(saved);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("second draft"));
chat.pause_unavailable_thread();
chat.handle_question_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL));
chat.handle_question_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(question_count(&chat), 3);
assert!(ops.try_recv().is_err());
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("first draft"));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
assert_eq!(chat.bottom_pane.composer_text(), "main draft");
}
#[tokio::test]
async fn single_question_spacing_with_working_status() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -287,7 +476,7 @@ async fn single_question_spacing_with_working_status() {
.iter()
.position(|line| line.trim() == "Only question?")
.unwrap();
assert!(rows[question - 2].contains("Working"));
assert!(rows[question - 2].contains("Queued follow-up inputs"));
assert!(rows[question - 1].is_empty());
assert_eq!(rows[question + 2].trim(), "A typed answer");
assert!(rows[question + 3].is_empty());
@@ -376,6 +376,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
chat.restore_thread_input_state(
Some(ThreadInputState {
questions: None,
composer: None,
safety_buffering_prompt: None,
pending_steers: VecDeque::from([expected_pending.clone()]),
@@ -2,6 +2,8 @@
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 80)"
---
• Queued follow-up inputs
1 of 2
Which way?
@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 80)"
---
• Queued follow-up inputs
↳ older
↳ newer
2 of 2
Second?
› 1. Next
2. Other
enter submit ctrl + ] skip ⌥ + ↓ prev question ⌥ + ↑ queued messages
@@ -2,6 +2,8 @@
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 80)"
---
• Queued follow-up inputs
What next?
› 1.
@@ -2,6 +2,8 @@
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "render_bottom_popup(&chat, 80)"
---
• Queued follow-up inputs
What next?
Type your answer
@@ -4,7 +4,6 @@ expression: "render_bottom_popup(&chat, 100)"
---
• Queued follow-up inputs
↳ queued
shift + ← edit last queued message
1 of 2
@@ -4,7 +4,6 @@ expression: "render_bottom_popup(&chat, 100)"
---
• Queued follow-up inputs
↳ queued
⌥ + ↑ edit last queued message
1 of 4
@@ -3,6 +3,8 @@ source: tui/src/chatwidget/tests/questions_tests.rs
expression: "format!(\"NORMAL\\n{normal}\\n\\nSEARCH\\n{search}\")"
---
NORMAL
• Queued follow-up inputs
Which way?
1. Named
@@ -12,6 +14,8 @@ NORMAL
enter submit ctrl + ] skip ⌥ + ↓ main prompt
SEARCH
• Queued follow-up inputs
Which way?
1. Named
@@ -3,6 +3,8 @@ source: tui/src/chatwidget/tests/questions_tests.rs
expression: "format!(\"NORMAL\\n{normal}\\n\\nSEARCH\\n{search}\")"
---
NORMAL
• Queued follow-up inputs
Which way?
alpha beta
@@ -11,6 +13,8 @@ NORMAL
enter submit ctrl + ] skip ⌥ + ↓ main prompt
SEARCH
• Queued follow-up inputs
Which way?
alpha beta
@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
expression: "format!(\"OPEN\\n{open}\\n\\nONLY QUESTIONS\\n{completed}\")"
---
OPEN
• Working (0s • esc to interrupt)
• Queued follow-up inputs
↳ queued follow-up
ONLY QUESTIONS
• Queued follow-up inputs
? 2 questions
⌥ + ↑ to answer
@@ -4,6 +4,8 @@ expression: rendered
---
• Working (0s • esc to interrupt)
• Queued follow-up inputs
Only question?
A typed answer
@@ -122,6 +122,7 @@ impl ThreadComposerState {
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadInputState {
pub(crate) questions: Option<crate::bottom_pane::QuestionState>,
pub(super) composer: Option<ThreadComposerState>,
pub(super) safety_buffering_prompt: Option<UserMessage>,
pub(crate) pending_steers: VecDeque<PendingSteer>,