mirror of
https://github.com/openai/codex.git
synced 2026-10-24 23:54:33 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
+15
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
+6
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
..
|
||||
} = ¬ification.item
|
||||
{
|
||||
self.add_async_questions(id, questions);
|
||||
}
|
||||
match notification.item {
|
||||
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item),
|
||||
item => self.handle_thread_item(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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?
|
||||
|
||||
+15
@@ -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
@@ -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
@@ -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
|
||||
|
||||
-1
@@ -4,7 +4,6 @@ expression: "render_bottom_popup(&chat, 100)"
|
||||
---
|
||||
• Queued follow-up inputs
|
||||
↳ queued
|
||||
shift + ← edit last queued message
|
||||
|
||||
1 of 2
|
||||
|
||||
|
||||
-1
@@ -4,7 +4,6 @@ expression: "render_bottom_popup(&chat, 100)"
|
||||
---
|
||||
• Queued follow-up inputs
|
||||
↳ queued
|
||||
⌥ + ↑ edit last queued message
|
||||
|
||||
1 of 4
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+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?
|
||||
|
||||
alpha beta
|
||||
@@ -11,6 +13,8 @@ NORMAL
|
||||
enter submit ctrl + ] skip ⌥ + ↓ main prompt
|
||||
|
||||
SEARCH
|
||||
• Queued follow-up inputs
|
||||
|
||||
Which way?
|
||||
|
||||
alpha beta
|
||||
|
||||
+14
@@ -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
|
||||
+2
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user