mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Keep the composer editable during TUI startup (#38642)
## Why Configuration and app-server initialization can take time before the main TUI is ready, leaving users unable to begin drafting a prompt. ## What changed - Show a provisional composer while startup work runs and carry its text, cursor position, paste state, and attachments into the initialized chat. - Limit the provisional composer to safe editing and cancellation, and quarantine input around session pickers, approvals, and other actionable startup screens. - Preserve configuration validation before terminal checks and recover terminal state cleanly from startup failures and caught panics. ## Testing - Cover startup editing, multiline and large pastes, keymaps, cancellation, session-picker handoff, approval boundaries, and draft restoration. - Verify non-interactive launches report configuration errors before terminal errors. GitOrigin-RevId: fe04a85cfbbcff21ff87fa81fd17474827858575
This commit is contained in:
1 parent
58d2daba45
commit
a0bed4be21
26 files changed
+4348
-1151
No files matched your search
@@ -57,6 +57,16 @@
|
||||
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after
|
||||
//! `ChatWidget` dispatches the command.
|
||||
//!
|
||||
//! # Startup Draft Handoff
|
||||
//!
|
||||
//! Startup uses a provisional plain-text composer: editing remains available, but submission,
|
||||
//! popups, attachments, and other actions are disabled. [`ComposerDraftSnapshot`] transfers its
|
||||
//! text, cursor, pending paste placeholders, local history, and recent activity to the fully
|
||||
//! initialized composer.
|
||||
//! `ChatWidget` merges the draft with any existing initial prompt and attachments, rebasing cursor
|
||||
//! and placeholder positions while preserving both composers' contents. The draft remains deferred
|
||||
//! until protected views close, input is enabled, and required sandbox setup completes.
|
||||
//!
|
||||
//! # Submission and Prompt Expansion
|
||||
//!
|
||||
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
|
||||
@@ -529,11 +539,14 @@ struct ComposerDraft {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ComposerDraftSnapshot {
|
||||
pub(crate) text: String,
|
||||
pub(crate) cursor: usize,
|
||||
pub(crate) text_elements: Vec<TextElement>,
|
||||
pub(crate) local_images: Vec<LocalImageAttachment>,
|
||||
pub(crate) remote_image_urls: Vec<String>,
|
||||
pub(crate) mention_bindings: Vec<MentionBinding>,
|
||||
pub(crate) pending_pastes: Vec<(String, String)>,
|
||||
pub(crate) startup_local_history: Vec<HistoryEntry>,
|
||||
pub(crate) last_composer_activity_at: Option<Instant>,
|
||||
}
|
||||
|
||||
const FOOTER_SPACING_HEIGHT: u16 = 0;
|
||||
@@ -1298,6 +1311,22 @@ impl ChatComposer {
|
||||
self.footer.mode = reset_mode_after_activity(self.footer.mode);
|
||||
}
|
||||
|
||||
/// Enable Vim while keeping already-active text entry in insert mode.
|
||||
pub(crate) fn enable_vim_in_insert_mode(&mut self) {
|
||||
self.set_vim_enabled(/*enabled*/ true);
|
||||
self.draft.textarea.enter_vim_insert_mode();
|
||||
}
|
||||
|
||||
/// Restore draft history transferred from the startup composer.
|
||||
pub(crate) fn restore_startup_local_history(
|
||||
&mut self,
|
||||
startup_local_history: Vec<HistoryEntry>,
|
||||
) {
|
||||
for entry in startup_local_history {
|
||||
self.history.record_local_submission(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle Vim editing and return the new enabled state.
|
||||
///
|
||||
/// This is the app-level command target for the configurable Vim toggle
|
||||
@@ -1532,7 +1561,7 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor(&mut self, cursor: usize) {
|
||||
pub(crate) fn set_current_cursor(&mut self, cursor: usize) {
|
||||
let visible_cursor = if self.draft.is_bash_mode {
|
||||
cursor.saturating_sub(1)
|
||||
} else {
|
||||
@@ -1716,11 +1745,14 @@ impl ChatComposer {
|
||||
pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot {
|
||||
ComposerDraftSnapshot {
|
||||
text: self.current_text(),
|
||||
cursor: self.current_cursor(),
|
||||
text_elements: self.text_elements(),
|
||||
local_images: self.local_images(),
|
||||
remote_image_urls: self.remote_image_urls(),
|
||||
mention_bindings: self.mention_bindings(),
|
||||
pending_pastes: self.pending_pastes(),
|
||||
startup_local_history: self.history.startup_local_history().to_vec(),
|
||||
last_composer_activity_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,15 +280,17 @@ impl ChatComposerHistory {
|
||||
|
||||
/// Updates persistent history metadata when a new session is configured.
|
||||
///
|
||||
/// This clears fetched entries, local entries, navigation cursors, and active search state
|
||||
/// because offsets only make sense within one history log snapshot. Reusing old offsets after a
|
||||
/// log-id change would allow a stale async response to hydrate the wrong prompt.
|
||||
/// Startup-local entries survive the first session configuration because they were recorded
|
||||
/// before a thread existed. Later configurations clear local history, while every configuration
|
||||
/// resets fetched entries, navigation cursors, and search state tied to the old history log.
|
||||
pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) {
|
||||
self.thread_id = Some(thread_id);
|
||||
let had_configured_thread = self.thread_id.replace(thread_id).is_some();
|
||||
self.persistent_log_id = Some(log_id);
|
||||
self.persistent_entry_count = entry_count;
|
||||
self.fetched_history.clear();
|
||||
self.local_history.clear();
|
||||
if had_configured_thread {
|
||||
self.local_history.clear();
|
||||
}
|
||||
self.replay_seeded_history.clear();
|
||||
self.history_cursor = None;
|
||||
self.pending_navigation_direction = None;
|
||||
@@ -296,6 +298,15 @@ impl ChatComposerHistory {
|
||||
self.search = None;
|
||||
}
|
||||
|
||||
/// Return draft history recorded before the composer became associated with a thread.
|
||||
pub(crate) fn startup_local_history(&self) -> &[HistoryEntry] {
|
||||
if self.thread_id.is_none() {
|
||||
&self.local_history
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a current-session submission so it can be recalled with full draft metadata.
|
||||
///
|
||||
/// Empty submissions are ignored, adjacent duplicates are collapsed, and active navigation or
|
||||
@@ -1002,6 +1013,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_metadata_preserves_startup_history_but_session_changes_clear_it() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut history = ChatComposerHistory::new();
|
||||
let startup_entry = HistoryEntry::new("cleared during startup".to_string());
|
||||
history.record_local_submission(startup_entry.clone());
|
||||
|
||||
assert_eq!(
|
||||
history.startup_local_history(),
|
||||
std::slice::from_ref(&startup_entry)
|
||||
);
|
||||
|
||||
history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 0);
|
||||
|
||||
assert!(history.startup_local_history().is_empty());
|
||||
assert_eq!(history.navigate_up(&tx), Some(startup_entry));
|
||||
|
||||
history.record_local_submission(HistoryEntry::new("thread-owned draft".to_string()));
|
||||
history.set_metadata(ThreadId::new(), /*log_id*/ 2, /*entry_count*/ 0);
|
||||
|
||||
assert_eq!(history.navigate_up(&tx), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_restore_gates_at_mentions() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -156,6 +156,7 @@ mod scroll_state;
|
||||
mod selection_popup_common;
|
||||
mod selection_row_layout;
|
||||
mod selection_tabs;
|
||||
mod startup;
|
||||
mod textarea;
|
||||
mod unified_exec_footer;
|
||||
pub(crate) use feedback_view::FeedbackNoteView;
|
||||
@@ -194,6 +195,7 @@ pub(crate) enum CancellationEvent {
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
pub(crate) use chat_composer::ChatComposerConfig;
|
||||
pub(crate) use chat_composer::ComposerDraftSnapshot;
|
||||
pub(crate) use chat_composer::InputResult;
|
||||
pub(crate) use chat_composer::QueuedInputAction;
|
||||
pub(crate) use chat_composer_history::HistoryEntry;
|
||||
@@ -267,6 +269,14 @@ pub(crate) struct BottomPaneParams {
|
||||
|
||||
impl BottomPane {
|
||||
pub fn new(params: BottomPaneParams) -> Self {
|
||||
Self::new_with_composer_config(params, ChatComposerConfig::default())
|
||||
}
|
||||
|
||||
/// Construct a bottom pane with explicitly restricted composer behavior.
|
||||
pub(crate) fn new_with_composer_config(
|
||||
params: BottomPaneParams,
|
||||
composer_config: ChatComposerConfig,
|
||||
) -> Self {
|
||||
let BottomPaneParams {
|
||||
app_event_tx,
|
||||
frame_requester,
|
||||
@@ -277,12 +287,13 @@ impl BottomPane {
|
||||
animations_enabled,
|
||||
skills,
|
||||
} = params;
|
||||
let mut composer = ChatComposer::new(
|
||||
let mut composer = ChatComposer::new_with_config(
|
||||
has_input_focus,
|
||||
app_event_tx.clone(),
|
||||
enhanced_keys_supported,
|
||||
placeholder_text,
|
||||
disable_paste_burst,
|
||||
composer_config,
|
||||
);
|
||||
composer.set_frame_requester(frame_requester.clone());
|
||||
let keymap = RuntimeKeymap::defaults();
|
||||
@@ -577,7 +588,7 @@ impl BottomPane {
|
||||
|
||||
fn record_composer_activity_at(&mut self, now: Instant) {
|
||||
self.last_composer_activity_at = Some(now);
|
||||
if !self.delayed_approval_requests.is_empty()
|
||||
if self.has_pending_approval()
|
||||
&& let Some(delay) = self.approval_prompt_delay_remaining(now)
|
||||
{
|
||||
self.request_redraw_in(delay);
|
||||
@@ -585,7 +596,7 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
fn maybe_show_delayed_approval_requests_at(&mut self, now: Instant) {
|
||||
if self.delayed_approval_requests.is_empty() || !self.view_stack.is_empty() {
|
||||
if !self.has_pending_approval() || !self.view_stack.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(delay) = self.approval_prompt_delay_remaining(now) {
|
||||
@@ -884,10 +895,6 @@ impl BottomPane {
|
||||
self.composer.cursor()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot {
|
||||
self.composer.draft_snapshot()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn composer_text_elements(&self) -> Vec<TextElement> {
|
||||
self.composer.text_elements()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Bottom-pane adapters for the provisional startup composer and its protected handoff.
|
||||
//!
|
||||
//! Startup accepts safe editor input while keeping submission, search, and other actions
|
||||
//! unavailable. Once initialization finishes, these adapters preserve buffered paste text,
|
||||
//! composer history and cursor state, and the approval-idle timing inherited by the real composer.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use super::BottomPane;
|
||||
use super::ComposerDraftSnapshot;
|
||||
use super::HistoryEntry;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
|
||||
impl BottomPane {
|
||||
/// Return whether a key would submit, queue, or search outside the startup draft.
|
||||
pub(crate) fn is_startup_composer_action(&self, key_event: KeyEvent) -> bool {
|
||||
let composer = &self.keymap.composer;
|
||||
[
|
||||
&composer.submit,
|
||||
&composer.queue,
|
||||
&composer.history_search_previous,
|
||||
&composer.history_search_next,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|bindings| bindings.is_pressed(key_event))
|
||||
|| self.composer_is_empty() && composer.toggle_shortcuts.is_pressed(key_event)
|
||||
}
|
||||
|
||||
/// Return whether a key is bound only to local startup-safe text editing.
|
||||
pub(crate) fn is_safe_startup_editor_key(&self, key_event: KeyEvent) -> bool {
|
||||
let editor = &self.keymap.editor;
|
||||
[
|
||||
&editor.insert_newline,
|
||||
&editor.move_left,
|
||||
&editor.move_right,
|
||||
&editor.move_up,
|
||||
&editor.move_down,
|
||||
&editor.move_word_left,
|
||||
&editor.move_word_right,
|
||||
&editor.move_line_start,
|
||||
&editor.move_line_end,
|
||||
&editor.delete_backward,
|
||||
&editor.delete_forward,
|
||||
&editor.delete_backward_word,
|
||||
&editor.delete_forward_word,
|
||||
&editor.kill_line_start,
|
||||
&editor.kill_whole_line,
|
||||
&editor.kill_line_end,
|
||||
&editor.yank,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|bindings| bindings.is_pressed(key_event))
|
||||
}
|
||||
|
||||
/// Enable Vim without interrupting an in-progress startup draft.
|
||||
pub(crate) fn enable_vim_in_insert_mode(&mut self) {
|
||||
self.composer.enable_vim_in_insert_mode();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Apply paste-burst preferences without dropping input buffered before config loading.
|
||||
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
|
||||
self.disable_paste_burst = disabled;
|
||||
self.composer.set_disable_paste_burst(disabled);
|
||||
}
|
||||
|
||||
/// Capture draft content together with the activity timestamp used by delayed approvals.
|
||||
pub(crate) fn composer_draft_snapshot(&self) -> ComposerDraftSnapshot {
|
||||
let mut draft = self.composer.draft_snapshot();
|
||||
draft.last_composer_activity_at = self.last_composer_activity_at;
|
||||
draft
|
||||
}
|
||||
|
||||
/// Restore the visible cursor before synchronizing composer-owned popups.
|
||||
pub(crate) fn set_composer_cursor(&mut self, cursor: usize) {
|
||||
self.composer.set_current_cursor(cursor);
|
||||
self.composer.sync_popups();
|
||||
}
|
||||
|
||||
/// Preserve startup-local history and the newest protected-prompt activity timestamp.
|
||||
pub(crate) fn restore_startup_composer_state(
|
||||
&mut self,
|
||||
last_composer_activity_at: Option<Instant>,
|
||||
startup_local_history: Vec<HistoryEntry>,
|
||||
) {
|
||||
self.composer
|
||||
.restore_startup_local_history(startup_local_history);
|
||||
if let Some(last_composer_activity_at) = last_composer_activity_at {
|
||||
let last_composer_activity_at = self
|
||||
.last_composer_activity_at
|
||||
.map_or(last_composer_activity_at, |current| {
|
||||
current.max(last_composer_activity_at)
|
||||
});
|
||||
self.record_composer_activity_at(last_composer_activity_at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether an approval is waiting to become visible after typing stops.
|
||||
pub(crate) fn has_pending_approval(&self) -> bool {
|
||||
!self.delayed_approval_requests.is_empty()
|
||||
}
|
||||
|
||||
/// Preserve buffered composer input before its owner or input mode changes.
|
||||
pub(crate) fn flush_composer_paste_burst(&mut self) {
|
||||
if self.composer.is_in_paste_burst() {
|
||||
self.composer.set_disable_paste_burst(/*disabled*/ true);
|
||||
self.composer
|
||||
.set_disable_paste_burst(self.disable_paste_burst);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user