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:
Charlie Marsh authored and copyberry committed 2026-08-14 20:30:30 +00:00
1 parent 58d2daba45
commit a0bed4be21
26 files changed
+4348 -1151

No files matched your search

+33 -1
View File
@@ -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>();
+14 -7
View File
@@ -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()
+113
View File
@@ -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);
}
}
}