mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +00:00
Add TUI building blocks for inline async question editing (#42889)
## What changed - Introduce an async-question editor component with per-question drafts, navigation, replay deduplication, and submit or queue handling using the shared composer. - Add bounded `AnsweredQuestion` framing that truncates question text at a UTF-8 boundary and flattens line breaks before prepending it to an answer. - Add `prompt_stack_back` and `skip_question` keybinding actions and the `tui.question_esc_back` setting. Normalize `Ctrl+]` and `Ctrl+5` for key matching and conflict detection, and let default question shortcuts yield to explicit bindings. - Flush buffered typing in both the main composer and the covering view so background input cannot keep the draw loop waiting. The async-question component is not yet connected to the TUI event flow. ## Testing Add regression coverage for bounded Unicode question framing, question-shortcut conflicts, and paste-buffer flushing in background and covering editors. GitOrigin-RevId: 4098043cf588ddafc05c27505645495edd690cb9
This commit is contained in:
1 parent
574a36ff99
commit
6ae8dcf6e1
32 files changed
+750
-44
No files matched your search
Generated
+2
@@ -2875,6 +2875,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
"codex-utils-string",
|
"codex-utils-string",
|
||||||
|
"pretty_assertions",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4621,6 +4622,7 @@ dependencies = [
|
|||||||
"codex-cloud-config",
|
"codex-cloud-config",
|
||||||
"codex-config",
|
"codex-config",
|
||||||
"codex-connectors",
|
"codex-connectors",
|
||||||
|
"codex-context-fragments",
|
||||||
"codex-core-plugins",
|
"codex-core-plugins",
|
||||||
"codex-exec-server",
|
"codex-exec-server",
|
||||||
"codex-features",
|
"codex-features",
|
||||||
|
|||||||
@@ -135,8 +135,12 @@ pub struct TuiChatKeymap {
|
|||||||
pub previous_permission_mode: Option<KeybindingsSpec>,
|
pub previous_permission_mode: Option<KeybindingsSpec>,
|
||||||
/// Switch to the next available permission mode.
|
/// Switch to the next available permission mode.
|
||||||
pub next_permission_mode: Option<KeybindingsSpec>,
|
pub next_permission_mode: Option<KeybindingsSpec>,
|
||||||
/// Edit the most recently queued message.
|
/// Move up through pending async questions, then edit the most recently queued message.
|
||||||
pub edit_queued_message: Option<KeybindingsSpec>,
|
pub edit_queued_message: Option<KeybindingsSpec>,
|
||||||
|
/// Move back through pending async questions toward the composer.
|
||||||
|
pub prompt_stack_back: Option<KeybindingsSpec>,
|
||||||
|
/// Skip the focused question.
|
||||||
|
pub skip_question: Option<KeybindingsSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composer context keybindings. These override corresponding `global` actions.
|
/// Composer context keybindings. These override corresponding `global` actions.
|
||||||
|
|||||||
@@ -757,6 +757,10 @@ pub struct Tui {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vim_mode_default: bool,
|
pub vim_mode_default: bool,
|
||||||
|
|
||||||
|
/// Escape returns from async questions to the composer, preserving the answer draft.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub question_esc_back: bool,
|
||||||
|
|
||||||
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
|
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
|
||||||
/// Defaults to `false`.
|
/// Defaults to `false`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ version.workspace = true
|
|||||||
[lib]
|
[lib]
|
||||||
name = "codex_context_fragments"
|
name = "codex_context_fragments"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
test = false
|
|
||||||
doctest = false
|
doctest = false
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
@@ -16,3 +15,6 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
codex-protocol = { workspace = true }
|
codex-protocol = { workspace = true }
|
||||||
codex-utils-string = { workspace = true }
|
codex-utils-string = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = { workspace = true }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! Bounded question framing accompanying an explicitly submitted user answer.
|
||||||
|
|
||||||
|
use super::ContextualUserFragment;
|
||||||
|
use codex_protocol::models::ContentItemKind;
|
||||||
|
|
||||||
|
/// Identifies an answered question without repeating an unbounded model-authored prompt.
|
||||||
|
pub struct AnsweredQuestion {
|
||||||
|
question: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnsweredQuestion {
|
||||||
|
pub fn new(question: &str) -> Self {
|
||||||
|
let end = question.floor_char_boundary(question.len().min(512));
|
||||||
|
Self {
|
||||||
|
question: question[..end].to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextualUserFragment for AnsweredQuestion {
|
||||||
|
fn content_kind(&self) -> ContentItemKind {
|
||||||
|
ContentItemKind("user.answered_question".into())
|
||||||
|
}
|
||||||
|
fn role(&self) -> &'static str {
|
||||||
|
"user"
|
||||||
|
}
|
||||||
|
fn markers(&self) -> (&'static str, &'static str) {
|
||||||
|
Self::type_markers()
|
||||||
|
}
|
||||||
|
fn type_markers() -> (&'static str, &'static str) {
|
||||||
|
("", "")
|
||||||
|
}
|
||||||
|
fn body(&self) -> String {
|
||||||
|
format!("> {}\n\n", self.question.replace(['\n', '\r'], " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "answered_question_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! Bounds and Unicode handling for model-authored question framing.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn question_context_is_bounded_and_keeps_unicode_boundaries() {
|
||||||
|
let text = "é\n".repeat(1_000);
|
||||||
|
let rendered = AnsweredQuestion::new(&text).render();
|
||||||
|
assert!(rendered.len() <= 516);
|
||||||
|
assert_eq!(
|
||||||
|
rendered,
|
||||||
|
format!(
|
||||||
|
"> {}\n\n",
|
||||||
|
text[..text.floor_char_boundary(512)].replace('\n', " ")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod answered_question;
|
||||||
|
pub use answered_question::AnsweredQuestion;
|
||||||
mod additional_context;
|
mod additional_context;
|
||||||
mod annotated_content;
|
mod annotated_content;
|
||||||
mod fragment;
|
mod fragment;
|
||||||
|
|||||||
@@ -3889,6 +3889,8 @@
|
|||||||
"interrupt_turn": null,
|
"interrupt_turn": null,
|
||||||
"next_permission_mode": null,
|
"next_permission_mode": null,
|
||||||
"previous_permission_mode": null,
|
"previous_permission_mode": null,
|
||||||
|
"prompt_stack_back": null,
|
||||||
|
"skip_question": null,
|
||||||
"toggle_voice_mute": null
|
"toggle_voice_mute": null
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
@@ -4085,6 +4087,11 @@
|
|||||||
"default": "composer",
|
"default": "composer",
|
||||||
"description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport."
|
"description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport."
|
||||||
},
|
},
|
||||||
|
"question_esc_back": {
|
||||||
|
"default": true,
|
||||||
|
"description": "Escape returns from async questions to the composer, preserving the answer draft.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"raw_output_mode": {
|
"raw_output_mode": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.",
|
"description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.",
|
||||||
@@ -4303,7 +4310,7 @@
|
|||||||
"$ref": "#/definitions/KeybindingsSpec"
|
"$ref": "#/definitions/KeybindingsSpec"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Edit the most recently queued message."
|
"description": "Move up through pending async questions, then edit the most recently queued message."
|
||||||
},
|
},
|
||||||
"increase_reasoning_effort": {
|
"increase_reasoning_effort": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
@@ -4337,6 +4344,22 @@
|
|||||||
],
|
],
|
||||||
"description": "Switch to the previous available permission mode."
|
"description": "Switch to the previous available permission mode."
|
||||||
},
|
},
|
||||||
|
"prompt_stack_back": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/KeybindingsSpec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Move back through pending async questions toward the composer."
|
||||||
|
},
|
||||||
|
"skip_question": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/KeybindingsSpec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Skip the focused question."
|
||||||
|
},
|
||||||
"toggle_voice_mute": {
|
"toggle_voice_mute": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -4690,6 +4713,8 @@
|
|||||||
"interrupt_turn": null,
|
"interrupt_turn": null,
|
||||||
"next_permission_mode": null,
|
"next_permission_mode": null,
|
||||||
"previous_permission_mode": null,
|
"previous_permission_mode": null,
|
||||||
|
"prompt_stack_back": null,
|
||||||
|
"skip_question": null,
|
||||||
"toggle_voice_mute": null
|
"toggle_voice_mute": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1261,6 +1261,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
|||||||
auto_recap: true,
|
auto_recap: true,
|
||||||
disable_paste_burst: None,
|
disable_paste_burst: None,
|
||||||
vim_mode_default: false,
|
vim_mode_default: false,
|
||||||
|
question_esc_back: true,
|
||||||
raw_output_mode: false,
|
raw_output_mode: false,
|
||||||
alternate_screen: AltScreenMode::default(),
|
alternate_screen: AltScreenMode::default(),
|
||||||
status_line: None,
|
status_line: None,
|
||||||
@@ -4275,6 +4276,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
|||||||
auto_recap: true,
|
auto_recap: true,
|
||||||
disable_paste_burst: None,
|
disable_paste_burst: None,
|
||||||
vim_mode_default: false,
|
vim_mode_default: false,
|
||||||
|
question_esc_back: true,
|
||||||
raw_output_mode: false,
|
raw_output_mode: false,
|
||||||
alternate_screen: AltScreenMode::Auto,
|
alternate_screen: AltScreenMode::Auto,
|
||||||
status_line: None,
|
status_line: None,
|
||||||
|
|||||||
@@ -754,6 +754,7 @@ pub struct Config {
|
|||||||
|
|
||||||
/// Start the composer in Vim mode (`Normal`) by default.
|
/// Start the composer in Vim mode (`Normal`) by default.
|
||||||
pub tui_vim_mode_default: bool,
|
pub tui_vim_mode_default: bool,
|
||||||
|
pub tui_question_esc_back: bool,
|
||||||
|
|
||||||
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
|
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
|
||||||
pub tui_raw_output_mode: bool,
|
pub tui_raw_output_mode: bool,
|
||||||
@@ -4354,6 +4355,7 @@ impl Config {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|t| t.model_availability_nux.clone())
|
.map(|t| t.model_availability_nux.clone())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
tui_question_esc_back: cfg.tui.as_ref().map(|t| t.question_esc_back).unwrap_or(true),
|
||||||
tui_vim_mode_default: cfg
|
tui_vim_mode_default: cfg
|
||||||
.tui
|
.tui
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
|
|||||||
pub(crate) use base_instructions::BaseInstructionsFragment;
|
pub(crate) use base_instructions::BaseInstructionsFragment;
|
||||||
pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment;
|
pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment;
|
||||||
pub(crate) use codex_context_fragments::AdditionalContextUserFragment;
|
pub(crate) use codex_context_fragments::AdditionalContextUserFragment;
|
||||||
|
pub use codex_context_fragments::AnsweredQuestion;
|
||||||
pub use codex_context_fragments::ContextualUserFragment;
|
pub use codex_context_fragments::ContextualUserFragment;
|
||||||
pub(crate) use compaction_summary::CompactionSummary;
|
pub(crate) use compaction_summary::CompactionSummary;
|
||||||
pub(crate) use contextual_user_message::is_contextual_user_fragment;
|
pub(crate) use contextual_user_message::is_contextual_user_fragment;
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
|
|||||||
tui_session_picker_view: SessionPickerViewMode::Dense,
|
tui_session_picker_view: SessionPickerViewMode::Dense,
|
||||||
tui_resume_cwd: None,
|
tui_resume_cwd: None,
|
||||||
tui_vim_mode_default: false,
|
tui_vim_mode_default: false,
|
||||||
|
tui_question_esc_back: true,
|
||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
workspace_roots: vec![cwd],
|
workspace_roots: vec![cwd],
|
||||||
workspace_roots_explicit: false,
|
workspace_roots_explicit: false,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ codex-arg0 = { workspace = true }
|
|||||||
codex-install-context = { workspace = true }
|
codex-install-context = { workspace = true }
|
||||||
codex-cloud-config = { workspace = true }
|
codex-cloud-config = { workspace = true }
|
||||||
codex-config = { workspace = true }
|
codex-config = { workspace = true }
|
||||||
|
codex-context-fragments = { workspace = true }
|
||||||
codex-connectors = { workspace = true }
|
codex-connectors = { workspace = true }
|
||||||
codex-core-plugins = { workspace = true }
|
codex-core-plugins = { workspace = true }
|
||||||
codex-history = { workspace = true }
|
codex-history = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//! Route question actions through the shared composer, preserving drafts until delivery accepts them.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl AsyncQuestions {
|
||||||
|
pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool {
|
||||||
|
self.composer.handles_key_as_editing(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn edit(&mut self, key: KeyEvent) {
|
||||||
|
if key.kind == KeyEventKind::Repeat && self.keymap.composer.queue.is_pressed(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.keymap.composer.submit.is_pressed(key) && !self.composer.is_in_paste_burst() {
|
||||||
|
self.composer.flush_pending_input();
|
||||||
|
}
|
||||||
|
let before = self.composer.snapshot_draft();
|
||||||
|
let (result, _) = self.composer.handle_key_event(key);
|
||||||
|
let queued = matches!(result, InputResult::Queued { .. });
|
||||||
|
if matches!(
|
||||||
|
result,
|
||||||
|
InputResult::Submitted { .. } | InputResult::Queued { .. }
|
||||||
|
) {
|
||||||
|
self.composer.restore_draft(before);
|
||||||
|
self.go_next_or_submit();
|
||||||
|
if queued && let Some(QuestionSubmission::Submit(text)) = self.submission.take() {
|
||||||
|
self.submission = Some(QuestionSubmission::Queue(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_keymap(&mut self, keymap: &RuntimeKeymap) {
|
||||||
|
self.keymap = keymap.clone();
|
||||||
|
self.composer.set_keymap_bindings(keymap);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
|
||||||
|
self.composer.set_vim_enabled(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BottomPaneView for AsyncQuestions {
|
||||||
|
fn is_complete(&self) -> bool {
|
||||||
|
self.unanswered_count() == 0
|
||||||
|
}
|
||||||
|
fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||||
|
self.composer.flush_paste_burst_if_due()
|
||||||
|
}
|
||||||
|
fn next_frame_delay(&self) -> Option<std::time::Duration> {
|
||||||
|
self.composer.footer_flash_delay()
|
||||||
|
}
|
||||||
|
fn is_in_paste_burst(&self) -> bool {
|
||||||
|
self.composer.is_in_paste_burst()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! Inline editing for asynchronous questions. Legacy request_user_input keeps its own overlay.
|
||||||
|
//! Only locally accepted submissions remove questions; arrival and expiry never steal focus.
|
||||||
|
|
||||||
|
use crate::app_event_sender::AppEventSender;
|
||||||
|
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::RuntimeKeymap;
|
||||||
|
use codex_protocol::items::AsyncUserInputQuestion;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyEventKind;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
mod input;
|
||||||
|
mod render;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct PendingQuestion {
|
||||||
|
question: AsyncUserInputQuestion,
|
||||||
|
draft: ComposerDraft,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locally retained async questions; replayed message IDs cannot resurrect handled answers.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
struct QuestionState {
|
||||||
|
pending: Vec<PendingQuestion>,
|
||||||
|
current_idx: usize,
|
||||||
|
seen_ids: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum QuestionSubmission {
|
||||||
|
Submit(String),
|
||||||
|
Queue(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct AsyncQuestions {
|
||||||
|
app_event_tx: AppEventSender,
|
||||||
|
state: QuestionState,
|
||||||
|
pub(crate) expanded: bool,
|
||||||
|
pub(crate) delivery_enabled: bool,
|
||||||
|
pub(crate) submission: Option<QuestionSubmission>,
|
||||||
|
pub(crate) next_hint: Option<crate::key_hint::ShortcutHint>,
|
||||||
|
keymap: RuntimeKeymap,
|
||||||
|
pub(super) composer: ChatComposer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncQuestions {
|
||||||
|
pub(crate) fn new(
|
||||||
|
app_event_tx: AppEventSender,
|
||||||
|
has_input_focus: bool,
|
||||||
|
enhanced_keys_supported: bool,
|
||||||
|
disable_paste_burst: bool,
|
||||||
|
keymap: RuntimeKeymap,
|
||||||
|
) -> Self {
|
||||||
|
let mut composer = ChatComposer::new_with_config(
|
||||||
|
has_input_focus,
|
||||||
|
app_event_tx.clone(),
|
||||||
|
enhanced_keys_supported,
|
||||||
|
"Type your answer".into(),
|
||||||
|
disable_paste_burst,
|
||||||
|
ChatComposerConfig::plain_text(),
|
||||||
|
);
|
||||||
|
composer.set_keymap_bindings(&keymap);
|
||||||
|
composer.set_footer_hint_override(Some(Vec::new()));
|
||||||
|
Self {
|
||||||
|
app_event_tx,
|
||||||
|
state: QuestionState::default(),
|
||||||
|
expanded: false,
|
||||||
|
delivery_enabled: true,
|
||||||
|
submission: None,
|
||||||
|
next_hint: None,
|
||||||
|
keymap,
|
||||||
|
composer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_question(&self) -> Option<&AsyncUserInputQuestion> {
|
||||||
|
self.current_answer().map(|answer| &answer.question)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_answer_mut(&mut self) -> Option<&mut PendingQuestion> {
|
||||||
|
self.state.pending.get_mut(self.state.current_idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_answer(&self) -> Option<&PendingQuestion> {
|
||||||
|
self.state.pending.get(self.state.current_idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn progress_prefix_text(&self) -> String {
|
||||||
|
let current = self.state.current_idx + 1;
|
||||||
|
let total = self.unanswered_count();
|
||||||
|
format!("{current} of {total}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_current_draft(&mut self) {
|
||||||
|
self.composer.flush_pending_input();
|
||||||
|
let draft = self.composer.snapshot_draft();
|
||||||
|
if let Some(answer) = self.current_answer_mut() {
|
||||||
|
answer.draft = draft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_current_draft(&mut self) {
|
||||||
|
let draft = self
|
||||||
|
.current_answer()
|
||||||
|
.map(|answer| answer.draft.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.composer.restore_inline_draft(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unanswered_count(&self) -> usize {
|
||||||
|
self.state.pending.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Render freeform questions inline using the existing modal editing engine.
|
||||||
|
use super::AsyncQuestions;
|
||||||
|
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
|
||||||
|
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
|
||||||
|
use crate::bottom_pane::selection_popup_common::render_menu_surface;
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Stylize;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::widgets::Widget;
|
||||||
|
|
||||||
|
impl AsyncQuestions {
|
||||||
|
fn question_lines(&self, width: u16) -> Vec<Line<'_>> {
|
||||||
|
self.current_question()
|
||||||
|
.map(|q| {
|
||||||
|
textwrap::wrap(&q.title, usize::from(width.max(1)))
|
||||||
|
.into_iter()
|
||||||
|
.map(Line::from)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
fn input_area(&self, area: Rect) -> Rect {
|
||||||
|
let area = menu_surface_inset(area);
|
||||||
|
let input_height = self
|
||||||
|
.composer
|
||||||
|
.inline_input_height(area.width)
|
||||||
|
.min(area.height);
|
||||||
|
Rect::new(
|
||||||
|
area.x,
|
||||||
|
area.bottom().saturating_sub(input_height),
|
||||||
|
area.width,
|
||||||
|
input_height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Renderable for AsyncQuestions {
|
||||||
|
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
|
||||||
|
self.composer.cursor_style(area)
|
||||||
|
}
|
||||||
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
|
let width = menu_surface_inset(Rect::new(/*x*/ 0, /*y*/ 0, width, u16::MAX)).width;
|
||||||
|
self.question_lines(width).len() as u16
|
||||||
|
+ self.composer.inline_input_height(width)
|
||||||
|
+ 1
|
||||||
|
+ u16::from(self.unanswered_count() > 1)
|
||||||
|
+ u16::from(self.unanswered_count() > 1)
|
||||||
|
+ menu_surface_padding_height()
|
||||||
|
}
|
||||||
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let input = self.input_area(area);
|
||||||
|
let mut prompt = render_menu_surface(area, buf);
|
||||||
|
prompt.height = input.y.saturating_sub(prompt.y);
|
||||||
|
if self.unanswered_count() > 1 && prompt.height > 0 {
|
||||||
|
Paragraph::new(self.progress_prefix_text().dim()).render(
|
||||||
|
Rect::new(prompt.x, prompt.y, prompt.width, /*height*/ 1),
|
||||||
|
buf,
|
||||||
|
);
|
||||||
|
prompt.y += 1;
|
||||||
|
prompt.height -= 1;
|
||||||
|
}
|
||||||
|
if self.unanswered_count() > 1
|
||||||
|
&& prompt.height > self.question_lines(prompt.width).len() as u16
|
||||||
|
{
|
||||||
|
prompt.y += 1;
|
||||||
|
prompt.height -= 1;
|
||||||
|
}
|
||||||
|
Paragraph::new(self.question_lines(prompt.width))
|
||||||
|
.style(crate::style::accent_style())
|
||||||
|
.bold()
|
||||||
|
.render(prompt, buf);
|
||||||
|
self.composer.render_inline_input(input, buf);
|
||||||
|
}
|
||||||
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||||
|
self.composer.inline_cursor_pos(self.input_area(area))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Pending async questions are local drafts; handling one removes it immediately.
|
||||||
|
//! Message IDs survive removal so replay cannot reopen an answered or skipped question.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use codex_context_fragments::AnsweredQuestion;
|
||||||
|
use codex_context_fragments::ContextualUserFragment;
|
||||||
|
|
||||||
|
impl AsyncQuestions {
|
||||||
|
pub(crate) fn append(&mut self, message_id: &str, questions: &[AsyncUserInputQuestion]) {
|
||||||
|
if questions.is_empty() || !self.state.seen_ids.insert(message_id.to_string()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let was_empty = self.state.pending.is_empty();
|
||||||
|
self.state
|
||||||
|
.pending
|
||||||
|
.extend(questions.iter().map(|question| PendingQuestion {
|
||||||
|
question: question.clone(),
|
||||||
|
draft: ComposerDraft::default(),
|
||||||
|
}));
|
||||||
|
if was_empty {
|
||||||
|
self.state.current_idx = 0;
|
||||||
|
self.restore_current_draft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_expanded(&mut self, expanded: bool) {
|
||||||
|
self.save_current_draft();
|
||||||
|
self.expanded = expanded && !self.state.pending.is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn navigate(&mut self, forward: bool) -> bool {
|
||||||
|
let next = if forward {
|
||||||
|
self.state.current_idx.checked_add(1)
|
||||||
|
} else {
|
||||||
|
self.state.current_idx.checked_sub(1)
|
||||||
|
};
|
||||||
|
let Some(next) = next.filter(|&index| index < self.state.pending.len()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.save_current_draft();
|
||||||
|
self.state.current_idx = next;
|
||||||
|
self.restore_current_draft();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn go_next_or_submit(&mut self) {
|
||||||
|
self.save_current_draft();
|
||||||
|
if !self.delivery_enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(answer) = self.current_answer() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let text = answer.draft.text_with_pending();
|
||||||
|
let text = text.trim();
|
||||||
|
let framing = AnsweredQuestion::new(&answer.question.title).render();
|
||||||
|
let limit = codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS - framing.chars().count();
|
||||||
|
if text.chars().count() > limit {
|
||||||
|
self.composer.show_footer_flash(
|
||||||
|
format!("Answer too long; limit {limit} characters").into(),
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
);
|
||||||
|
} else if !text.is_empty() {
|
||||||
|
self.submission = Some(QuestionSubmission::Submit(format!("{framing}{text}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn accept_answer(&mut self) {
|
||||||
|
if self.state.pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.composer.flush_pending_input();
|
||||||
|
self.state.pending.remove(self.state.current_idx);
|
||||||
|
if self.state.current_idx >= self.state.pending.len() {
|
||||||
|
self.state.current_idx = 0;
|
||||||
|
}
|
||||||
|
self.expanded &= !self.state.pending.is_empty();
|
||||||
|
self.restore_current_draft();
|
||||||
|
self.composer.reset_vim_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
//! The chat composer is the bottom-pane text input state machine.
|
//! The chat composer is the bottom-pane text input state machine.
|
||||||
//!
|
//!
|
||||||
//! It is responsible for:
|
//! It edits the [`TextArea`] buffer and attachment elements, routes popup keys, promotes
|
||||||
//!
|
//! completed slash commands to atomic elements, and handles Enter submission/newlines.
|
||||||
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
|
//! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts
|
||||||
//! - Routing keys to the active popup (slash commands, file search, skill/apps/task mentions).
|
//! from raw key streams, particularly on Windows.
|
||||||
//! - Promoting typed slash commands into atomic elements when the command name is completed.
|
|
||||||
//! - Handling submit vs newline on Enter.
|
|
||||||
//! - Showing a single yellow prompt arrow while the active model is Luna Reserve.
|
|
||||||
//! - Turning raw key streams into explicit paste operations on platforms where terminals
|
|
||||||
//! don't provide reliable bracketed paste (notably Windows).
|
|
||||||
//!
|
//!
|
||||||
//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab
|
//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab
|
||||||
//! submit ordinary text without enabling shell mode.
|
//! submit ordinary text without enabling shell mode.
|
||||||
@@ -178,6 +173,8 @@
|
|||||||
//! Shift, or Windows AltGr) into
|
//! Shift, or Windows AltGr) into
|
||||||
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
|
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
|
||||||
//! through [`ChatComposer::handle_paste`].
|
//! through [`ChatComposer::handle_paste`].
|
||||||
|
//! Parent views must keep flushing editors that lose focus while input is buffered; a hidden
|
||||||
|
//! editor's pending burst can otherwise keep the shared draw loop waiting indefinitely.
|
||||||
//!
|
//!
|
||||||
//! The burst detector intentionally treats ASCII and non-ASCII differently:
|
//! The burst detector intentionally treats ASCII and non-ASCII differently:
|
||||||
//!
|
//!
|
||||||
@@ -313,6 +310,7 @@ mod completion_target;
|
|||||||
mod draft_state;
|
mod draft_state;
|
||||||
mod footer_state;
|
mod footer_state;
|
||||||
mod history_search;
|
mod history_search;
|
||||||
|
mod inline_input;
|
||||||
mod popup_state;
|
mod popup_state;
|
||||||
mod reconnect;
|
mod reconnect;
|
||||||
mod slash_input;
|
mod slash_input;
|
||||||
@@ -575,9 +573,9 @@ struct MentionCompletionTarget {
|
|||||||
prebuilt_mentions: Option<Vec<MentionItem>>,
|
prebuilt_mentions: Option<Vec<MentionItem>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
struct ComposerDraft {
|
pub(super) struct ComposerDraft {
|
||||||
text: String,
|
pub(super) text: String,
|
||||||
text_elements: Vec<TextElement>,
|
text_elements: Vec<TextElement>,
|
||||||
local_image_paths: Vec<PathBuf>,
|
local_image_paths: Vec<PathBuf>,
|
||||||
remote_image_urls: Vec<String>,
|
remote_image_urls: Vec<String>,
|
||||||
@@ -1464,7 +1462,7 @@ impl ChatComposer {
|
|||||||
.should_handle_vim_insert_escape(key_event)
|
.should_handle_vim_insert_escape(key_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
|
pub(crate) fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
|
||||||
self.draft.textarea.vim_mode_indicator_span()
|
self.draft.textarea.vim_mode_indicator_span()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1621,7 +1619,7 @@ impl ChatComposer {
|
|||||||
self.sync_popups();
|
self.sync_popups();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_cursor(&self) -> usize {
|
pub(crate) fn current_cursor(&self) -> usize {
|
||||||
self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 }
|
self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1674,7 +1672,7 @@ impl ChatComposer {
|
|||||||
Some(element.map_range(|_| (start..end).into()))
|
Some(element.map_range(|_| (start..end).into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot_draft(&self) -> ComposerDraft {
|
pub(super) fn snapshot_draft(&self) -> ComposerDraft {
|
||||||
ComposerDraft {
|
ComposerDraft {
|
||||||
text: self.current_text(),
|
text: self.current_text(),
|
||||||
text_elements: self.current_text_elements(),
|
text_elements: self.current_text_elements(),
|
||||||
@@ -1686,7 +1684,7 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_draft(&mut self, draft: ComposerDraft) {
|
pub(super) fn restore_draft(&mut self, draft: ComposerDraft) {
|
||||||
let ComposerDraft {
|
let ComposerDraft {
|
||||||
text,
|
text,
|
||||||
text_elements,
|
text_elements,
|
||||||
@@ -3206,8 +3204,7 @@ impl ChatComposer {
|
|||||||
| InputResult::ServiceTierCommand(_)
|
| InputResult::ServiceTierCommand(_)
|
||||||
| InputResult::CommandWithArgs(_, _, _)
|
| InputResult::CommandWithArgs(_, _, _)
|
||||||
) {
|
) {
|
||||||
self.vim_history = VimHistory::default();
|
self.reset_vim_mode();
|
||||||
self.draft.textarea.enter_vim_insert_mode();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Embed the composer in question rows, resetting history navigation when drafts change.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl ComposerDraft {
|
||||||
|
pub(in crate::bottom_pane) fn text_with_pending(&self) -> String {
|
||||||
|
ChatComposer::expand_pending_pastes(
|
||||||
|
&self.text,
|
||||||
|
self.text_elements.clone(),
|
||||||
|
&self.pending_pastes,
|
||||||
|
)
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatComposer {
|
||||||
|
pub(in crate::bottom_pane) fn restore_inline_draft(&mut self, draft: ComposerDraft) {
|
||||||
|
self.history.reset_navigation();
|
||||||
|
self.restore_draft(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reset_vim_mode(&mut self) {
|
||||||
|
self.vim_history = VimHistory::default();
|
||||||
|
self.draft.textarea.enter_vim_insert_mode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give modal editing precedence over a containing view's submit/navigation keys.
|
||||||
|
pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool {
|
||||||
|
self.history_search.is_some()
|
||||||
|
|| self.should_handle_vim_insert_escape(key)
|
||||||
|
|| self.draft.textarea.is_vim_operator_pending()
|
||||||
|
|| self.draft.textarea.wants_vim_search_key(key)
|
||||||
|
|| (self.draft.textarea.is_vim_normal_mode()
|
||||||
|
&& !self.submit_keys.is_pressed(key)
|
||||||
|
&& !self.queue_keys.is_pressed(key)
|
||||||
|
&& (self.vim_normal_keymap.move_up.is_pressed(key)
|
||||||
|
|| self.vim_normal_keymap.move_down.is_pressed(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish buffered typing before another answer takes over this editor.
|
||||||
|
pub(crate) fn flush_pending_input(&mut self) {
|
||||||
|
if let Some(text) = self.draft.paste_burst.flush_before_modified_input() {
|
||||||
|
self.handle_paste(text);
|
||||||
|
}
|
||||||
|
self.draft.paste_burst.clear_after_explicit_paste();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn inline_input_height(&self, width: u16) -> u16 {
|
||||||
|
self.draft.textarea.desired_height(width.max(1))
|
||||||
|
+ u16::from(self.history_search.is_some() || self.draft.textarea.vim_query().is_some())
|
||||||
|
+ u16::from(self.footer.flash_visible())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn inline_cursor_pos(&self, mut area: Rect) -> Option<(u16, u16)> {
|
||||||
|
area.height = area
|
||||||
|
.height
|
||||||
|
.saturating_sub(u16::from(self.footer.flash_visible()));
|
||||||
|
let query_area = Rect::new(
|
||||||
|
area.x,
|
||||||
|
area.bottom().saturating_sub(1),
|
||||||
|
area.width,
|
||||||
|
area.height.min(1),
|
||||||
|
);
|
||||||
|
if let Some(query) = self.draft.textarea.vim_query() {
|
||||||
|
return query.cursor_pos(query_area);
|
||||||
|
}
|
||||||
|
self.draft
|
||||||
|
.textarea
|
||||||
|
.cursor_pos_with_state(area, *self.draft.textarea_state.borrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_inline_input(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ratatui::widgets::Clear.render(area, buf);
|
||||||
|
Block::default()
|
||||||
|
.style(Style::reset().patch(user_message_style()))
|
||||||
|
.render(area, buf);
|
||||||
|
let mut area = area;
|
||||||
|
if self.footer.flash_visible()
|
||||||
|
&& let Some(flash) = &self.footer.flash
|
||||||
|
{
|
||||||
|
area.height = area.height.saturating_sub(1);
|
||||||
|
flash.line.clone().render(
|
||||||
|
Rect::new(area.x, area.bottom(), area.width, /*height*/ 1),
|
||||||
|
buf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(query) = self.draft.textarea.vim_query() {
|
||||||
|
area.height = area.height.saturating_sub(1);
|
||||||
|
query.render(
|
||||||
|
Rect::new(area.x, area.bottom(), area.width, /*height*/ 1),
|
||||||
|
buf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut state = self.draft.textarea_state.borrow_mut();
|
||||||
|
let highlights: Vec<_> = self
|
||||||
|
.history_search_highlight_ranges()
|
||||||
|
.into_iter()
|
||||||
|
.chain(self.draft.textarea.vim_search_highlights())
|
||||||
|
.map(|range| (range, Style::new().reversed().bold()))
|
||||||
|
.collect();
|
||||||
|
self.draft.textarea.render_ref_styled_with_highlights(
|
||||||
|
area,
|
||||||
|
buf,
|
||||||
|
&mut state,
|
||||||
|
Style::default(),
|
||||||
|
&highlights,
|
||||||
|
);
|
||||||
|
if self.draft.textarea.is_empty() {
|
||||||
|
Line::from(self.placeholder_text.as_str().dim()).render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ impl HistoryEntry {
|
|||||||
/// State machine that manages shell-style history navigation (Up/Down) inside
|
/// State machine that manages shell-style history navigation (Up/Down) inside
|
||||||
/// the chat composer. This struct is intentionally decoupled from the
|
/// the chat composer. This struct is intentionally decoupled from the
|
||||||
/// rendering widget so the logic remains isolated and easier to test.
|
/// rendering widget so the logic remains isolated and easier to test.
|
||||||
|
#[derive(Clone)]
|
||||||
pub(crate) struct ChatComposerHistory {
|
pub(crate) struct ChatComposerHistory {
|
||||||
/// Thread that owns persistent lookup responses for this metadata snapshot.
|
/// Thread that owns persistent lookup responses for this metadata snapshot.
|
||||||
thread_id: Option<ThreadId>,
|
thread_id: Option<ThreadId>,
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ mod actionable_banner;
|
|||||||
mod app_link_view;
|
mod app_link_view;
|
||||||
mod apply_patch_header;
|
mod apply_patch_header;
|
||||||
mod approval_overlay;
|
mod approval_overlay;
|
||||||
|
#[expect(dead_code)]
|
||||||
|
mod async_questions;
|
||||||
mod hook_status;
|
mod hook_status;
|
||||||
mod mcp_server_elicitation;
|
mod mcp_server_elicitation;
|
||||||
mod multi_select_picker;
|
mod multi_select_picker;
|
||||||
@@ -1792,14 +1794,12 @@ impl BottomPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||||
// Give the active view the first chance to flush paste-burst state so
|
// A covering view and the main composer can both retain buffered typing.
|
||||||
// overlays that reuse the composer behave consistently.
|
let mut flushed = self.composer.flush_paste_burst_if_due();
|
||||||
if let Some(view) = self.view_stack.last_mut()
|
if let Some(view) = self.view_stack.last_mut() {
|
||||||
&& view.flush_paste_burst_if_due()
|
flushed |= view.flush_paste_burst_if_due();
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
self.composer.flush_paste_burst_if_due()
|
flushed
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||||
@@ -2133,6 +2133,49 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_buffered_typing_in_background_and_covering_editors() {
|
||||||
|
let (tx, _rx) = unbounded_channel();
|
||||||
|
let tx = AppEventSender::new(tx);
|
||||||
|
let mut pane = test_pane(tx.clone());
|
||||||
|
for ch in "main draft".chars() {
|
||||||
|
pane.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
|
||||||
|
}
|
||||||
|
assert!(pane.composer.is_in_paste_burst());
|
||||||
|
let request = ToolRequestUserInputParams {
|
||||||
|
thread_id: "thread".into(),
|
||||||
|
turn_id: "turn".into(),
|
||||||
|
item_id: "call".into(),
|
||||||
|
is_blocking: true,
|
||||||
|
auto_resolution_ms: None,
|
||||||
|
questions: vec![codex_app_server_protocol::ToolRequestUserInputQuestion {
|
||||||
|
id: "question".into(),
|
||||||
|
header: String::new(),
|
||||||
|
question: "Which way?".into(),
|
||||||
|
is_other: false,
|
||||||
|
is_secret: false,
|
||||||
|
options: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let mut covering = RequestUserInputOverlay::new_with_keymap(
|
||||||
|
request,
|
||||||
|
tx,
|
||||||
|
/*has_input_focus*/ true,
|
||||||
|
/*enhanced_keys_supported*/ false,
|
||||||
|
/*disable_paste_burst*/ false,
|
||||||
|
RuntimeKeymap::defaults(),
|
||||||
|
);
|
||||||
|
for ch in "modal".chars() {
|
||||||
|
covering.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
|
||||||
|
}
|
||||||
|
assert!(covering.is_in_paste_burst());
|
||||||
|
pane.push_view(Box::new(covering));
|
||||||
|
std::thread::sleep(paste_burst::PasteBurst::recommended_active_flush_delay());
|
||||||
|
assert!(pane.flush_paste_burst_if_due());
|
||||||
|
assert!(!pane.is_in_paste_burst());
|
||||||
|
assert_eq!(pane.composer_text(), "main draft");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inline_banner_snapshot() {
|
fn inline_banner_snapshot() {
|
||||||
let (tx, _rx) = unbounded_channel();
|
let (tx, _rx) = unbounded_channel();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub(crate) struct PendingInputPreview {
|
|||||||
pub queued_messages: Vec<String>,
|
pub queued_messages: Vec<String>,
|
||||||
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
||||||
/// be overridden for terminals where that chord is unavailable.
|
/// be overridden for terminals where that chord is unavailable.
|
||||||
edit_binding: Option<key_hint::ShortcutHint>,
|
pub(super) edit_binding: Option<key_hint::ShortcutHint>,
|
||||||
/// Key combination rendered for immediately interrupting and sending steers.
|
/// Key combination rendered for immediately interrupting and sending steers.
|
||||||
interrupt_binding: Option<key_hint::ShortcutHint>,
|
interrupt_binding: Option<key_hint::ShortcutHint>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crossterm::event::KeyEvent;
|
|||||||
use crossterm::event::KeyEventKind;
|
use crossterm::event::KeyEventKind;
|
||||||
use crossterm::event::KeyModifiers;
|
use crossterm::event::KeyModifiers;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod render;
|
pub(super) mod render;
|
||||||
|
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ impl RequestUserInputOverlay {
|
|||||||
///
|
///
|
||||||
/// This keeps footer spacing stable by anchoring the options block to the
|
/// This keeps footer spacing stable by anchoring the options block to the
|
||||||
/// bottom of its allocated region.
|
/// bottom of its allocated region.
|
||||||
fn render_rows_bottom_aligned(
|
pub(in crate::bottom_pane) fn render_rows_bottom_aligned(
|
||||||
area: Rect,
|
area: Rect,
|
||||||
buf: &mut Buffer,
|
buf: &mut Buffer,
|
||||||
rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow],
|
rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow],
|
||||||
@@ -471,7 +471,7 @@ fn render_rows_bottom_aligned(
|
|||||||
/// overflows, it truncates at the last word boundary when possible (falling back to the last
|
/// overflows, it truncates at the last word boundary when possible (falling back to the last
|
||||||
/// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the
|
/// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the
|
||||||
/// last visible span (or the line style if nothing was kept).
|
/// last visible span (or the line style if nothing was kept).
|
||||||
fn truncate_line_word_boundary_with_ellipsis(
|
pub(in crate::bottom_pane) fn truncate_line_word_boundary_with_ellipsis(
|
||||||
line: Line<'static>,
|
line: Line<'static>,
|
||||||
max_width: usize,
|
max_width: usize,
|
||||||
) -> Line<'static> {
|
) -> Line<'static> {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
/// knowing about their data model. Passing a stale length after filtering would
|
/// knowing about their data model. Passing a stale length after filtering would
|
||||||
/// leave selection pointing at the wrong row, so callers should clamp or move
|
/// leave selection pointing at the wrong row, so callers should clamp or move
|
||||||
/// through this type immediately after changing their visible row set.
|
/// through this type immediately after changing their visible row set.
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
#[derive(Debug, Default, Clone, Copy, PartialEq)]
|
||||||
pub(crate) struct ScrollState {
|
pub(crate) struct ScrollState {
|
||||||
pub selected_idx: Option<usize>,
|
pub selected_idx: Option<usize>,
|
||||||
pub scroll_top: usize,
|
pub scroll_top: usize,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ impl ChatWidget {
|
|||||||
.1
|
.1
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit_user_message_with_history_and_shell_escape_policy(
|
pub(super) fn submit_user_message_with_history_and_shell_escape_policy(
|
||||||
&mut self,
|
&mut self,
|
||||||
user_message: UserMessage,
|
user_message: UserMessage,
|
||||||
history_record: UserMessageHistoryRecord,
|
history_record: UserMessageHistoryRecord,
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ impl KeyBinding {
|
|||||||
(self.key, self.modifiers)
|
(self.key, self.modifiers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legacy Ctrl+] is reported as Ctrl+5; keep matching and conflicts consistent.
|
||||||
|
pub(crate) fn normalized_parts(&self) -> (KeyCode, KeyModifiers) {
|
||||||
|
match self.parts() {
|
||||||
|
(KeyCode::Char('5'), KeyModifiers::CONTROL) => ctrl(KeyCode::Char(']')).parts(),
|
||||||
|
parts => parts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn display_label(&self) -> String {
|
pub(crate) fn display_label(&self) -> String {
|
||||||
let modifiers = modifiers_to_string(self.modifiers);
|
let modifiers = modifiers_to_string(self.modifiers);
|
||||||
let key = match self.key {
|
let key = match self.key {
|
||||||
@@ -123,13 +131,13 @@ pub(crate) fn normalize_key_parts(
|
|||||||
if modifiers.is_empty()
|
if modifiers.is_empty()
|
||||||
&& let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch)
|
&& let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch)
|
||||||
{
|
{
|
||||||
return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers);
|
return normalize_key_parts(KeyCode::Char(ctrl_char), KeyModifiers::CONTROL);
|
||||||
}
|
}
|
||||||
if ch.is_ascii_uppercase() {
|
if ch.is_ascii_uppercase() {
|
||||||
modifiers.insert(KeyModifiers::SHIFT);
|
modifiers.insert(KeyModifiers::SHIFT);
|
||||||
return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers);
|
return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers);
|
||||||
}
|
}
|
||||||
(key, modifiers)
|
KeyBinding::new(key, modifiers).normalized_parts()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
|
fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
|
||||||
@@ -350,6 +358,7 @@ mod tests {
|
|||||||
('z', '\u{001a}'),
|
('z', '\u{001a}'),
|
||||||
('4', '\u{001c}'),
|
('4', '\u{001c}'),
|
||||||
('5', '\u{001d}'),
|
('5', '\u{001d}'),
|
||||||
|
(']', '\u{001d}'),
|
||||||
('6', '\u{001e}'),
|
('6', '\u{001e}'),
|
||||||
('7', '\u{001f}'),
|
('7', '\u{001f}'),
|
||||||
];
|
];
|
||||||
|
|||||||
+104
-8
@@ -121,8 +121,12 @@ pub(crate) struct ChatKeymap {
|
|||||||
pub(crate) previous_permission_mode: Vec<KeyBinding>,
|
pub(crate) previous_permission_mode: Vec<KeyBinding>,
|
||||||
/// Switch to the next available permission mode.
|
/// Switch to the next available permission mode.
|
||||||
pub(crate) next_permission_mode: Vec<KeyBinding>,
|
pub(crate) next_permission_mode: Vec<KeyBinding>,
|
||||||
/// Edit the most recently queued message.
|
/// Move up through async questions, then edit the most recently queued message.
|
||||||
pub(crate) edit_queued_message: Vec<KeyBinding>,
|
pub(crate) edit_queued_message: Vec<KeyBinding>,
|
||||||
|
/// Move back through async questions toward the composer.
|
||||||
|
pub(crate) prompt_stack_back: Vec<KeyBinding>,
|
||||||
|
/// Skip the focused question.
|
||||||
|
pub(crate) skip_question: Vec<KeyBinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composer-level keybindings validated in the second app-scope conflict pass.
|
/// Composer-level keybindings validated in the second app-scope conflict pass.
|
||||||
@@ -684,6 +688,8 @@ impl RuntimeKeymap {
|
|||||||
&defaults.chat.edit_queued_message,
|
&defaults.chat.edit_queued_message,
|
||||||
"tui.keymap.chat.edit_queued_message",
|
"tui.keymap.chat.edit_queued_message",
|
||||||
)?,
|
)?,
|
||||||
|
prompt_stack_back: resolve_local!(keymap, defaults, chat, prompt_stack_back),
|
||||||
|
skip_question: resolve_local!(keymap, defaults, chat, skip_question),
|
||||||
};
|
};
|
||||||
|
|
||||||
let composer = ComposerKeymap {
|
let composer = ComposerKeymap {
|
||||||
@@ -1170,6 +1176,37 @@ impl RuntimeKeymap {
|
|||||||
cancel: resolve_local!(keymap, defaults, vim_text_object, cancel),
|
cancel: resolve_local!(keymap, defaults, vim_text_object, cancel),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// New question shortcuts yield individually to existing explicit bindings.
|
||||||
|
for (configured, bindings, aliases) in [
|
||||||
|
(
|
||||||
|
keymap.chat.prompt_stack_back.as_ref(),
|
||||||
|
&mut chat.prompt_stack_back,
|
||||||
|
vec![
|
||||||
|
("alt-down", key_hint::alt(KeyCode::Down)),
|
||||||
|
("shift-right", key_hint::shift(KeyCode::Right)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
keymap.chat.skip_question.as_ref(),
|
||||||
|
&mut chat.skip_question,
|
||||||
|
vec![("ctrl-]", key_hint::ctrl(KeyCode::Char(']')))],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
if configured.is_none() {
|
||||||
|
bindings.retain(|binding| {
|
||||||
|
!aliases.iter().any(|(alias, candidate)| {
|
||||||
|
binding == candidate
|
||||||
|
&& (configured_main_surface_alias_is_used(keymap, alias)
|
||||||
|
|| configured_context_alias_is_used(&keymap.list, alias)
|
||||||
|
|| configured_context_alias_is_used(&keymap.vim_search, alias))
|
||||||
|
}) && !chords.bindings.iter().any(|chord| {
|
||||||
|
chord.action.context.overlaps(KeymapContext::Chat)
|
||||||
|
&& binding.normalized_parts() == chord.chord.prefix.normalized_parts()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reasoning arrow aliases are fallback defaults: existing explicit
|
// Reasoning arrow aliases are fallback defaults: existing explicit
|
||||||
// bindings on the same input path keep the keys, while explicit
|
// bindings on the same input path keep the keys, while explicit
|
||||||
// reasoning bindings remain authoritative.
|
// reasoning bindings remain authoritative.
|
||||||
@@ -1474,6 +1511,8 @@ impl RuntimeKeymap {
|
|||||||
previous_permission_mode: default_bindings![],
|
previous_permission_mode: default_bindings![],
|
||||||
next_permission_mode: default_bindings![],
|
next_permission_mode: default_bindings![],
|
||||||
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)],
|
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)],
|
||||||
|
prompt_stack_back: default_bindings![alt(KeyCode::Down), shift(KeyCode::Right)],
|
||||||
|
skip_question: default_bindings![ctrl(KeyCode::Char(']'))],
|
||||||
},
|
},
|
||||||
composer: ComposerKeymap {
|
composer: ComposerKeymap {
|
||||||
submit: default_bindings![plain(KeyCode::Enter)],
|
submit: default_bindings![plain(KeyCode::Enter)],
|
||||||
@@ -1753,6 +1792,8 @@ impl RuntimeKeymap {
|
|||||||
&self.chat.previous_permission_mode,
|
&self.chat.previous_permission_mode,
|
||||||
),
|
),
|
||||||
("next_permission_mode", &self.chat.next_permission_mode),
|
("next_permission_mode", &self.chat.next_permission_mode),
|
||||||
|
("prompt_stack_back", &self.chat.prompt_stack_back),
|
||||||
|
("skip_question", &self.chat.skip_question),
|
||||||
] {
|
] {
|
||||||
if bindings.iter().any(|binding| {
|
if bindings.iter().any(|binding| {
|
||||||
let (code, modifiers) = binding.parts();
|
let (code, modifiers) = binding.parts();
|
||||||
@@ -1826,6 +1867,11 @@ impl RuntimeKeymap {
|
|||||||
"chat.edit_queued_message",
|
"chat.edit_queued_message",
|
||||||
self.chat.edit_queued_message.as_slice(),
|
self.chat.edit_queued_message.as_slice(),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"chat.prompt_stack_back",
|
||||||
|
self.chat.prompt_stack_back.as_slice(),
|
||||||
|
),
|
||||||
|
("chat.skip_question", self.chat.skip_question.as_slice()),
|
||||||
("composer.submit", self.composer.submit.as_slice()),
|
("composer.submit", self.composer.submit.as_slice()),
|
||||||
("composer.queue", self.composer.queue.as_slice()),
|
("composer.queue", self.composer.queue.as_slice()),
|
||||||
(
|
(
|
||||||
@@ -1919,6 +1965,24 @@ impl RuntimeKeymap {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Async question shortcuts run before option selection and text editing.
|
||||||
|
validate_no_shadow_with_allowed_overlaps(
|
||||||
|
"async_questions",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"chat.prompt_stack_back",
|
||||||
|
self.chat.prompt_stack_back.as_slice(),
|
||||||
|
),
|
||||||
|
("chat.skip_question", self.chat.skip_question.as_slice()),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
("list.move_up", self.list.move_up.as_slice()),
|
||||||
|
("list.move_down", self.list.move_down.as_slice()),
|
||||||
|
("list.accept", self.list.accept.as_slice()),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
// While the composer is focused, these main-surface handlers always
|
// While the composer is focused, these main-surface handlers always
|
||||||
// consume matching keys before the event reaches the textarea editor.
|
// consume matching keys before the event reaches the textarea editor.
|
||||||
validate_no_shadow_with_allowed_overlaps(
|
validate_no_shadow_with_allowed_overlaps(
|
||||||
@@ -1949,6 +2013,11 @@ impl RuntimeKeymap {
|
|||||||
"chat.next_permission_mode",
|
"chat.next_permission_mode",
|
||||||
self.chat.next_permission_mode.as_slice(),
|
self.chat.next_permission_mode.as_slice(),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"chat.prompt_stack_back",
|
||||||
|
self.chat.prompt_stack_back.as_slice(),
|
||||||
|
),
|
||||||
|
("chat.skip_question", self.chat.skip_question.as_slice()),
|
||||||
("composer.submit", self.composer.submit.as_slice()),
|
("composer.submit", self.composer.submit.as_slice()),
|
||||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||||
@@ -2071,7 +2140,7 @@ impl RuntimeKeymap {
|
|||||||
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
||||||
for (action, bindings) in approval_overlay_bindings {
|
for (action, bindings) in approval_overlay_bindings {
|
||||||
for binding in bindings {
|
for binding in bindings {
|
||||||
let key = binding.parts();
|
let key = binding.normalized_parts();
|
||||||
if let Some(previous) = seen.insert(key, action) {
|
if let Some(previous) = seen.insert(key, action) {
|
||||||
// Approval overlays intentionally reserve Esc as a stable
|
// Approval overlays intentionally reserve Esc as a stable
|
||||||
// cancellation path even though decline options may also
|
// cancellation path even though decline options may also
|
||||||
@@ -2106,7 +2175,7 @@ fn validate_unique<'a>(
|
|||||||
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
||||||
for (action, bindings) in pairs {
|
for (action, bindings) in pairs {
|
||||||
for binding in bindings {
|
for binding in bindings {
|
||||||
let key = binding.parts();
|
let key = binding.normalized_parts();
|
||||||
if let Some(previous) = seen.insert(key, action) {
|
if let Some(previous) = seen.insert(key, action) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \
|
"Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \
|
||||||
@@ -2128,18 +2197,18 @@ fn validate_no_shadow_with_allowed_overlaps<const N: usize, const M: usize, cons
|
|||||||
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new();
|
||||||
for (action, bindings) in primary {
|
for (action, bindings) in primary {
|
||||||
for binding in bindings {
|
for binding in bindings {
|
||||||
seen.insert(binding.parts(), action);
|
seen.insert(binding.normalized_parts(), action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (action, bindings) in shadowed {
|
for (action, bindings) in shadowed {
|
||||||
for binding in bindings {
|
for binding in bindings {
|
||||||
let key = binding.parts();
|
let key = binding.normalized_parts();
|
||||||
if let Some(previous) = seen.get(&key) {
|
if let Some(previous) = seen.get(&key) {
|
||||||
if allowed_overlaps.iter().any(
|
if allowed_overlaps.iter().any(
|
||||||
|(allowed_primary, allowed_shadowed, allowed_binding)| {
|
|(allowed_primary, allowed_shadowed, allowed_binding)| {
|
||||||
*allowed_primary == *previous
|
*allowed_primary == *previous
|
||||||
&& *allowed_shadowed == action
|
&& *allowed_shadowed == action
|
||||||
&& allowed_binding.parts() == key
|
&& allowed_binding.normalized_parts() == key
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
@@ -2319,7 +2388,9 @@ fn configured_context_alias_is_used(context: &impl Serialize, alias: &str) -> bo
|
|||||||
|
|
||||||
fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool {
|
fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::String(value) => value == alias,
|
serde_json::Value::String(value) => parse_keybinding(value)
|
||||||
|
.zip(parse_keybinding(alias))
|
||||||
|
.is_some_and(|(a, b)| a.normalized_parts() == b.normalized_parts()),
|
||||||
serde_json::Value::Array(values) => values
|
serde_json::Value::Array(values) => values
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| keymap_value_contains_alias(value, alias)),
|
.any(|value| keymap_value_contains_alias(value, alias)),
|
||||||
@@ -2366,7 +2437,10 @@ See the Codex keymap documentation for supported actions and examples.",
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !parsed.contains(&binding) {
|
if !parsed
|
||||||
|
.iter()
|
||||||
|
.any(|previous: &KeyBinding| previous.normalized_parts() == binding.normalized_parts())
|
||||||
|
{
|
||||||
parsed.push(binding);
|
parsed.push(binding);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3561,6 +3635,28 @@ mod tests {
|
|||||||
expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right");
|
expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interrupt_turn_rejects_question_back_and_skip_bindings() {
|
||||||
|
for (key, action) in [
|
||||||
|
("alt-down", "chat.prompt_stack_back"),
|
||||||
|
("ctrl-]", "chat.skip_question"),
|
||||||
|
("ctrl-5", "chat.skip_question"),
|
||||||
|
("ctrl-] x", "chat.skip_question"),
|
||||||
|
("ctrl-5 x", "chat.skip_question"),
|
||||||
|
] {
|
||||||
|
let mut keymap = TuiKeymap::default();
|
||||||
|
keymap.chat.interrupt_turn = Some(one(key));
|
||||||
|
if key == "alt-down" {
|
||||||
|
keymap.chat.prompt_stack_back = Some(one(key));
|
||||||
|
} else {
|
||||||
|
let runtime = RuntimeKeymap::from_config(&keymap).unwrap();
|
||||||
|
assert!(runtime.chat.skip_question.is_empty());
|
||||||
|
keymap.chat.skip_question = Some(one("ctrl-]"));
|
||||||
|
}
|
||||||
|
expect_conflict(&keymap, "chat.interrupt_turn", action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
|
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
|
||||||
let mut keymap = TuiKeymap::default();
|
let mut keymap = TuiKeymap::default();
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ define_runtime_action_bindings! {
|
|||||||
previous_permission_mode,
|
previous_permission_mode,
|
||||||
next_permission_mode,
|
next_permission_mode,
|
||||||
edit_queued_message,
|
edit_queued_message,
|
||||||
|
prompt_stack_back,
|
||||||
|
skip_question,
|
||||||
],
|
],
|
||||||
"composer" => Composer, composer, composer [
|
"composer" => Composer, composer, composer [
|
||||||
submit,
|
submit,
|
||||||
|
|||||||
@@ -397,6 +397,8 @@ fn effective_configured_binding(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn normalize_chord_binding(binding: KeyBinding) -> KeyBinding {
|
pub(crate) fn normalize_chord_binding(binding: KeyBinding) -> KeyBinding {
|
||||||
|
let (key, modifiers) = binding.normalized_parts();
|
||||||
|
let binding = KeyBinding::new(key, modifiers);
|
||||||
if binding.parts() == crate::key_hint::ctrl(KeyCode::Char('7')).parts() {
|
if binding.parts() == crate::key_hint::ctrl(KeyCode::Char('7')).parts() {
|
||||||
crate::key_hint::ctrl(KeyCode::Char('/'))
|
crate::key_hint::ctrl(KeyCode::Char('/'))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ pub(super) fn binding_slot<'a>(
|
|||||||
("chat", "previous_permission_mode") => Some(&mut keymap.chat.previous_permission_mode),
|
("chat", "previous_permission_mode") => Some(&mut keymap.chat.previous_permission_mode),
|
||||||
("chat", "next_permission_mode") => Some(&mut keymap.chat.next_permission_mode),
|
("chat", "next_permission_mode") => Some(&mut keymap.chat.next_permission_mode),
|
||||||
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
||||||
|
("chat", "prompt_stack_back") => Some(&mut keymap.chat.prompt_stack_back),
|
||||||
|
("chat", "skip_question") => Some(&mut keymap.chat.skip_question),
|
||||||
("composer", "submit") => Some(&mut keymap.composer.submit),
|
("composer", "submit") => Some(&mut keymap.composer.submit),
|
||||||
("composer", "queue") => Some(&mut keymap.composer.queue),
|
("composer", "queue") => Some(&mut keymap.composer.queue),
|
||||||
("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts),
|
("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ impl From<&Config> for LocalSettings {
|
|||||||
auto_recap: config.tui_auto_recap,
|
auto_recap: config.tui_auto_recap,
|
||||||
disable_paste_burst: Some(config.disable_paste_burst),
|
disable_paste_burst: Some(config.disable_paste_burst),
|
||||||
vim_mode_default: config.tui_vim_mode_default,
|
vim_mode_default: config.tui_vim_mode_default,
|
||||||
|
question_esc_back: config.tui_question_esc_back,
|
||||||
raw_output_mode: config.tui_raw_output_mode,
|
raw_output_mode: config.tui_raw_output_mode,
|
||||||
alternate_screen: config.tui_alternate_screen,
|
alternate_screen: config.tui_alternate_screen,
|
||||||
status_line: config.tui_status_line.clone(),
|
status_line: config.tui_status_line.clone(),
|
||||||
|
|||||||
Reference in new issue
Block a user