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