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
@@ -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>,