diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index af0644806a1f..9fabe3e5a918 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -1,5 +1,8 @@ //! Daemon-wide overview of recent and locally retained sessions and their subagents. +#[path = "agents_overview_composer.rs"] +mod composer; + use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewRow; use super::agents_overview_view::AgentsOverviewView; @@ -98,6 +101,11 @@ impl App { return; } + self.agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .focus_composer(); let threads = self .agents_overview .threads @@ -252,6 +260,8 @@ impl App { }); } + self.sync_agents_overview_composer(); + AgentsOverviewView::new( rows, selected_thread_id, @@ -599,9 +609,7 @@ impl App { Some(cwd) => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await { Ok(config) => config, Err(error) => { - if let Ok(mut state) = self.agents_overview.view_state.lock() { - state.input = prompt; - } + self.restore_agents_overview_prompt(prompt); return self .chat_widget .add_error_message(format!("Failed to load project settings: {error}")); @@ -615,9 +623,7 @@ impl App { || config.permissions.profile_workspace_roots() != self.config.permissions.profile_workspace_roots()) { - if let Ok(mut state) = self.agents_overview.view_state.lock() { - state.input = prompt; - } + self.restore_agents_overview_prompt(prompt); return self .chat_widget .add_error_message("Permission profile has different settings.".to_string()); @@ -646,9 +652,7 @@ impl App { .await; } Err(error) => { - if let Ok(mut state) = self.agents_overview.view_state.lock() { - state.input = prompt; - } + self.restore_agents_overview_prompt(prompt); self.chat_widget .add_error_message(format!("Failed to start background task: {error}")); } @@ -676,9 +680,7 @@ impl App { }) .await; if let Err(error) = result { - if let Ok(mut state) = self.agents_overview.view_state.lock() { - state.input = prompt; - } + self.restore_agents_overview_prompt(prompt); self.chat_widget .add_error_message(format!("Failed to send task message: {error}")); } diff --git a/codex-rs/tui/src/app/agents_overview_composer.rs b/codex-rs/tui/src/app/agents_overview_composer.rs new file mode 100644 index 000000000000..7a15da32f06d --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_composer.rs @@ -0,0 +1,56 @@ +//! Retained composer setup and failed-dispatch recovery for the agent dashboard. +//! Refreshes rebind runtime settings without replacing drafts or editor state. + +use super::*; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::ChatComposerConfig; + +impl App { + pub(super) fn sync_agents_overview_composer(&self) { + let mut state = self + .agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let composer = state.composer.get_or_insert_with(|| { + let mut composer = ChatComposer::new_with_config( + /*has_input_focus*/ true, + self.app_event_tx.clone(), + self.enhanced_keys_supported, + "Describe a new task".to_string(), + self.config.disable_paste_burst, + ChatComposerConfig { + trim_submission: false, + ..ChatComposerConfig::plain_text() + }, + ); + // A new task has no context usage to report yet. + composer.set_context_window_pending(/*pending*/ true); + composer.set_footer_hint_override(Some(Vec::new())); + composer + }); + composer.set_app_event_sender(self.app_event_tx.clone()); + composer.set_disable_paste_burst(self.config.disable_paste_burst); + let vim_enabled = self.chat_widget.composer_is_vim_enabled(); + if composer.is_vim_enabled() != vim_enabled { + composer.set_vim_enabled(vim_enabled); + composer.resume_text_entry(); + } + composer.set_keymap_bindings(&self.keymap); + } + + pub(super) fn restore_agents_overview_prompt(&mut self, prompt: String) { + if let Ok(mut state) = self.agents_overview.view_state.lock() + && let Some(composer) = state.composer.as_mut() + && composer.is_empty() + && !composer.is_in_paste_burst() + && !composer.popup_active() + { + composer.set_text_content(prompt, Vec::new(), Vec::new()); + composer.move_cursor_to_end(); + return; + } + self.chat_widget + .add_info_message(format!("Unsent task: {prompt}"), /*hint*/ None); + } +} diff --git a/codex-rs/tui/src/app/agents_overview_input.rs b/codex-rs/tui/src/app/agents_overview_input.rs new file mode 100644 index 000000000000..e02a05767d3c --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_input.rs @@ -0,0 +1,92 @@ +//! Focus and input routing for the agent dashboard. Refreshes retain the shared +//! composer; dashboard actions never consume keys while its editor owns focus. + +use super::*; +use crate::bottom_pane::InputResult; + +impl AgentsOverviewView { + pub(super) fn handle_composer_key(&mut self, key: KeyEvent) { + let mut state = self.state(); + let offline = state.connection_notice.is_some(); + let status_grouping = state.status_grouping; + if key.code == KeyCode::Esc && !state.composer_owns_escape() { + state.focus = AgentsOverviewFocus::List; + return; + } + let Some(composer) = state.composer.as_mut() else { + return; + }; + if offline + && !composer.popup_active() + && (self.composer_keymap.submit.is_pressed(key) + || self.composer_keymap.queue.is_pressed(key)) + { + if key.code == KeyCode::Enter { + composer.handle_paste_enter(std::time::Instant::now()); + } + return; + } + let (result, _) = composer.handle_key_event(key); + drop(state); + if let InputResult::Submitted { text, .. } = result { + self.app_event_tx + .send(AppEvent::DispatchAgentsOverviewTask { + prompt: text, + cwd: (!status_grouping) + .then(|| self.selected_row().map(|row| row.thread.cwd.clone())) + .flatten(), + }); + } + } + pub(super) fn layout_areas(&self, area: Rect) -> [Rect; 7] { + let footer_height = if self.state().composing() { + 0 + } else { + (self.footer_lines(area.width.saturating_sub(4)).len() as u16) + .min(area.height.saturating_sub(7)) + }; + let mut state = self.state(); + let composing = state.composing(); + let mut hints = if state.connection_notice.is_some() && composing { + vec![("esc".to_string(), "tasks · dispatch paused".to_string())] + } else if composing { + self.composer_hints.clone() + } else { + Vec::new() + }; + if let Some((key, _)) = hints.last_mut() + && state.composer_owns_escape() + { + *key = "esc esc".to_string(); + } + if area.width < 60 && hints.len() > 2 { + hints.remove(/*index*/ 1); + } + if composing && let Some(override_hints) = &state.key_chord_hint { + hints = override_hints.clone(); + } + let metadata = state.editing_metadata(); + let input_height = if metadata { + 1 + } else if let Some(composer) = state.composer.as_mut() { + composer.set_footer_hint_override(Some(hints)); + composer + .desired_height(area.width) + .min((area.height / 3).max(/*other*/ 5)) + .min(area.height.saturating_sub(/*rhs*/ 7)) + .max(/*other*/ 3) + } else { + 3 + }; + Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(u16::from(!metadata)), + Constraint::Length(input_height), + Constraint::Length(footer_height), + ]) + .areas(area) + } +} diff --git a/codex-rs/tui/src/app/agents_overview_render.rs b/codex-rs/tui/src/app/agents_overview_render.rs index 96949b4f8b0b..1647f149684f 100644 --- a/codex-rs/tui/src/app/agents_overview_render.rs +++ b/codex-rs/tui/src/app/agents_overview_render.rs @@ -2,12 +2,13 @@ //! The prompt and its cursor reserve the same height for wrapped footer hints. use super::*; +use crossterm::cursor::SetCursorStyle; impl AgentsOverviewView { - fn footer_lines(&self, width: u16) -> Vec> { + pub(super) fn footer_lines(&self, width: u16) -> Vec> { if self.state().connection_notice.is_some() { return vec![ - "ctrl+c quit · actions paused until the list is refreshed" + "ctrl+c clear input, then quit · actions paused until the list is refreshed" .dim() .into(), ]; @@ -63,6 +64,12 @@ impl AgentsOverviewView { true, ); add_hint(list_hint(ListAction::Accept), "open", true); + add_hint( + self.agents_keymap + .primary_hint("new_task", &self.agents_keymap.new_task), + "new task", + true, + ); add_hint( self.agents_keymap .primary_hint("search", &self.agents_keymap.search), @@ -106,22 +113,39 @@ impl Renderable for AgentsOverviewView { 24 } + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + let state = self.state(); + if state.composing() + && let Some(composer) = &state.composer + { + composer.cursor_style(area) + } else { + SetCursorStyle::DefaultUserShape + } + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let state = self.state().clone(); + if area.width < 12 || area.height < 8 { + return None; + } + let [_, _, _, _, _, prompt, _] = self.layout_areas(area); + let state = self.state(); + if state.composing() { + return state.composer.as_ref()?.cursor_pos(prompt); + } + if !state.editing_metadata() { + return None; + } let (label, input) = if state.searching { (" Search › ", &state.search) - } else if state.renaming { - (" Rename › ", &state.input) } else { - (" New task › ", &state.input) + (" Rename › ", &state.input) }; let x = area .x .saturating_add((label.width() + input.width()) as u16) .min(area.right().saturating_sub(3)); - let footer_height = (self.footer_lines(area.width.saturating_sub(4)).len() as u16) - .min(area.height.saturating_sub(7)); - Some((x, area.bottom().saturating_sub(footer_height + 1))) + Some((x, prompt.y)) } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -129,17 +153,7 @@ impl Renderable for AgentsOverviewView { return; } Clear.render(area, buf); - let footer_lines = self.footer_lines(area.width.saturating_sub(4)); - let footer_height = (footer_lines.len() as u16).min(area.height.saturating_sub(7)); - let [header, summary, divider, body, prompt, footer] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(3), - Constraint::Length(1), - Constraint::Length(footer_height), - ]) - .areas(area); + let [header, summary, divider, body, title, prompt, footer] = self.layout_areas(area); let inset = |rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0)); Line::from("Agent command center".bold()).render(inset(header), buf); @@ -179,18 +193,11 @@ impl Renderable for AgentsOverviewView { } else { self.render_rows(body, buf); } - let state = self.state().clone(); + let state = self.state(); let (label, input) = if state.searching { ("Search › ", &state.search) - } else if state.renaming { - ("Rename › ", &state.input) } else { - ("New task › ", &state.input) - }; - let placeholder = if input.is_empty() && !state.searching && !state.renaming { - "Describe a task and press enter to dispatch it" - } else { - "" + ("Rename › ", &state.input) }; let available_width = usize::from(inset(prompt).width) .saturating_sub(label.width()) @@ -205,9 +212,19 @@ impl Renderable for AgentsOverviewView { visible_width += width; visible_start = index; } - let input = &input[visible_start..]; - Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()]) - .render(inset(prompt), buf); - Paragraph::new(footer_lines).render(inset(footer), buf); + if state.editing_metadata() { + Line::from(vec![label.cyan().bold(), input[visible_start..].into()]) + .render(inset(prompt), buf); + } else { + Line::from("New task".dim()).render(inset(title), buf); + if let Some(composer) = &state.composer { + composer.render(prompt, buf); + } + } + if state.composing() { + return; + } + drop(state); + Paragraph::new(self.footer_lines(inset(footer).width)).render(inset(footer), buf); } } diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index a86c1f242a86..d9a9d4e36416 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::app::test_support::make_test_app; use crate::app_event::AgentsOverviewThreadRefresh; use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; use crate::chatwidget::tests::helpers::render_bottom_popup; use crate::render::renderable::Renderable; use crate::test_support::PathBufExt; @@ -27,11 +28,195 @@ use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SubAgentSource; +use crossterm::cursor::SetCursorStyle; use pretty_assertions::assert_eq; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::layout::Rect; static OVERVIEW_TIMESTAMP: std::sync::LazyLock = std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120); +fn overview_draft(app: &App) -> (String, usize) { + let state = app.agents_overview.view_state.lock().unwrap(); + let composer = state.composer.as_ref().unwrap(); + (composer.current_text_with_pending(), composer.cursor()) +} + +#[tokio::test] +async fn overview_composer_preserves_editing_and_routes_focus() { + let mut app = make_test_app().await; + app.config.disable_paste_burst = true; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let thread_id = ThreadId::new(); + let threads = vec![overview_thread( + thread_id, + /*parent_thread_id*/ None, + "Existing task", + ThreadStatus::Idle, + )]; + let mut view = app.agents_overview_view(threads.clone(), Some(thread_id)); + view.handle_paste(" /first @file $skill\nthird \t".into()); + view.handle_key_event(KeyCode::Home.into()); + view.handle_paste("!second\n".into()); + let expected = overview_draft(&app); + assert_eq!( + expected, + (" /first @file $skill\n!second\nthird \t".into(), 32) + ); + view = app.agents_overview_view(threads.clone(), Some(thread_id)); + assert_eq!(overview_draft(&app), expected); + view.handle_key_event(KeyCode::Esc.into()); + view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); + view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + view.handle_paste(" renamed".into()); + view.handle_key_event(KeyCode::Enter.into()); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name == "Existing task renamed") + ); + view = app.agents_overview_view(threads, Some(thread_id)); + view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + view.handle_key_event(KeyCode::Esc.into()); + view.handle_key_event(KeyCode::Tab.into()); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::DispatchAgentsOverviewTask { prompt, .. }) if prompt == expected.0) + ); + view.handle_key_event(KeyCode::Up.into()); + assert_eq!(overview_draft(&app).0, expected.0); + assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); + view.handle_paste(" \t\n".into()); + view.handle_key_event(KeyCode::Enter.into()); + assert!(rx.try_recv().is_err()); + assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); + let mut server = crate::start_embedded_app_server_for_picker(&app.config) + .await + .unwrap(); + app.cli_kv_overrides = vec![("model".into(), toml::Value::Integer(1))]; + app.dispatch_agents_overview_task(&mut server, "retry me".into(), Some(app.config.cwd.clone())) + .await; + view.handle_paste(" later".into()); + app.submit_agents_overview_prompt(&server, thread_id, "older failure".into()) + .await; + assert_eq!(overview_draft(&app).0, "retry me later"); + server.shutdown().await.unwrap(); + while rx.try_recv().is_ok() {} + view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + view.handle_paste(format!( + "x{}", + " ".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS) + )); + view.handle_key_event(KeyCode::Enter.into()); + assert!(matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)))); + assert!(view.next_frame_delay().is_some()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" Message too long; limit 1048576 characters"); +} + +#[tokio::test] +async fn overview_composer_preserves_pastes_and_editor_bindings() { + let mut app = make_test_app().await; + app.config.disable_paste_burst = true; + app.chat_widget.toggle_vim_mode_and_notify(); + let mut keymap = TuiKeymap::default(); + keymap.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string()))); + keymap.editor.insert_newline = Some(KeybindingsSpec::One(KeybindingSpec("f7".to_string()))); + app.keymap = RuntimeKeymap::from_config(&keymap).unwrap(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + let pasted = "long paste ".repeat(/*n*/ 200); + view.handle_paste(pasted.clone()); + view.handle_key_event(KeyCode::F(7).into()); + view.handle_paste("last line".into()); + view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 24, + ); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + insta::assert_snapshot!(terminal.backend().to_string().lines().last().unwrap(), @r#"" f8 create task f7 newline esc esc tasks ""#); + assert_eq!(view.cursor_style(area), SetCursorStyle::SteadyBar); + view.handle_key_event(KeyCode::Home.into()); + view.handle_key_event(KeyCode::Esc.into()); + assert_eq!(view.cursor_style(area), SetCursorStyle::DefaultUserShape); + view.handle_key_event(KeyCode::Char('d').into()); + view.handle_key_event(KeyCode::Esc.into()); + assert!(view.cursor_pos(area).is_some()); + view.handle_key_event(KeyCode::Esc.into()); + assert_eq!(view.cursor_pos(area), None); + view.handle_key_event(KeyCode::Char('x').into()); + assert_eq!(view.cursor_pos(area), None); + view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + assert_eq!(view.cursor_style(area), SetCursorStyle::SteadyBar); + view.handle_key_event(KeyCode::Esc.into()); + view.handle_key_event(KeyCode::Esc.into()); + view.handle_paste("abc".into()); + view.handle_key_event(KeyCode::Char('x').into()); + view.handle_key_event(KeyCode::F(8).into()); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::DispatchAgentsOverviewTask { prompt, .. }) if prompt == format!("{pasted}\nabcxlast line")) + ); +} + +#[tokio::test] +async fn overview_composer_flushes_pending_typing_after_refresh() { + for disable_paste_burst in [false, true] { + let mut app = make_test_app().await; + app.config.disable_paste_burst = false; + let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + view.handle_key_event(KeyCode::Char('x').into()); + app.config.disable_paste_burst = disable_paste_burst; + if !disable_paste_burst { + app.chat_widget.toggle_vim_mode_and_notify(); + } + view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + assert_eq!(overview_draft(&app).0, "x"); + view.handle_key_event(KeyCode::Esc.into()); + if !disable_paste_burst { + view.handle_key_event(KeyCode::Esc.into()); + } + assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); + view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + view.handle_key_event(KeyCode::Up.into()); + assert_eq!(overview_draft(&app).0, "x"); + } +} + +#[tokio::test] +async fn offline_overview_preserves_unbracketed_paste_newlines() { + let mut app = make_test_app().await; + app.config.disable_paste_burst = false; + let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.agents_overview + .view_state + .lock() + .unwrap() + .connection_notice = Some("Reconnecting"); + for ch in "a\nbc\nd".chars() { + view.handle_key_event( + if ch == '\n' { + KeyCode::Enter + } else { + KeyCode::Char(ch) + } + .into(), + ); + } + view.handle_key_event(KeyCode::Tab.into()); + assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled); + view.handle_key_event(KeyCode::Up.into()); + view.handle_key_event(KeyCode::Enter.into()); + assert_eq!(overview_draft(&app).0, "a\nbc\nd"); + view.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" ctrl+c clear input, then quit · actions paused until the list is refreshed"); +} + fn overview_thread( thread_id: ThreadId, parent_thread_id: Option, @@ -236,6 +421,8 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res .copied() .collect(); assert_eq!(visible, expected); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); finish_overview_refresh(&mut app, &app_server, &mut event_rx).await; @@ -471,6 +658,7 @@ async fn shared_overview_shows_only_root_sessions() { ); let state = &app.agents_overview.view_state; assert!(!state.lock().unwrap().status_grouping); + action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); assert!(state.lock().unwrap().status_grouping); app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); @@ -478,13 +666,13 @@ async fn shared_overview_shows_only_root_sessions() { assert!( action_view.handle_paste("Use \u{1b}[31mthe\u{1b}[0m current project\u{7}".to_string()) ); - action_view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!( event_rx.try_recv(), Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: None }) if prompt == "Use the current project" )); + action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); assert!(action_view.handle_paste("Fix the flaky tests after all retries complete".to_string())); let area = ratatui::layout::Rect::new( @@ -506,6 +694,7 @@ async fn shared_overview_shows_only_root_sessions() { && cwd == test_path_buf("/tmp/project").abs() )); assert!(action_view.handle_paste(" ".to_string())); + action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); action_view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); assert!(action_view.handle_paste("Repair authentication".to_string())); action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -521,6 +710,7 @@ async fn shared_overview_shows_only_root_sessions() { if prompt.trim() == "Continue working" )); action_view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + action_view.handle_paste("First line\nSecond line".into()); crate::chatwidget::tests::helpers::set_active_cell( &mut app.chat_widget, Box::new(crate::history_cell::PlainHistoryCell::new(vec![ @@ -577,6 +767,8 @@ async fn shared_overview_shows_only_root_sessions() { .collect(); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); let view = app.agents_overview_view(threads, /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); @@ -668,10 +860,12 @@ async fn daemon_start_result_snapshots() -> Result<()> { #[tokio::test] async fn filtered_dashboard_actions_use_configured_shortcuts() { let mut app = make_test_app().await; + app.chat_widget.toggle_vim_mode_and_notify(); let mut keymap = TuiKeymap::default(); keymap.agents.search = Some(KeybindingsSpec::One(KeybindingSpec("f6".to_string()))); keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string()))); keymap.agents.resume = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string()))); + keymap.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("v".into()))); app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); let first = ThreadId::new(); let second = ThreadId::new(); @@ -701,12 +895,20 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { /*exit_on_cancel*/ false, crate::app_event_sender::AppEventSender::new(event_tx), app.keymap.clone(), - Arc::default(), + Arc::clone(&app.agents_overview.view_state), ); view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)); assert!(event_rx.try_recv().is_err()); assert!(view.handle_paste("Do not dispatch this draft".to_string())); + view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + view.handle_key_event(KeyCode::Esc.into()); + for (key, expected) in [('v', second), ('k', first), ('j', first)] { + view.handle_key_event(KeyCode::Char(key).into()); + let selected = &view.rows[view.selected_index().unwrap()]; + assert_eq!(selected.thread_id, expected); + } + assert_eq!(overview_draft(&app).0, "Do not dispatch this draft"); view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); assert!(view.handle_paste("Second task".to_string())); view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); @@ -1146,6 +1348,7 @@ async fn restored_server_permission_profile_survives_cd_without_turn_override() async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> { for primary_thread_id in [None, Some(ThreadId::new())] { let mut app = make_test_app().await; + app.config.disable_paste_burst = true; let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); app.primary_thread_id = primary_thread_id; @@ -1161,8 +1364,11 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> let view = app.agents_overview_view(threads.into(), Some(selected)); app.chat_widget.show_bottom_pane_view(Box::new(view)); for key in [ + KeyCode::Esc.into(), KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + KeyCode::Esc.into(), KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE), ] { @@ -1197,7 +1403,7 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), selection ); - assert_eq!(app.agents_overview.view_state.lock().unwrap().input, "d"); + assert_eq!(overview_draft(&app).0, "d"); } Ok(()) } @@ -1214,7 +1420,8 @@ async fn command_center_cursor_tracks_wrapped_footer() { )], /*selected_thread_id*/ None, ); - for (key, label) in [('n', "New task ›"), ('f', "Search ›"), ('r', "Rename ›")] { + for (key, label) in [('n', "› Describe"), ('f', "Search ›"), ('r', "Rename ›")] { + view.handle_key_event(KeyCode::Esc.into()); view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); for width in [48, 96, 120] { let area = @@ -1235,6 +1442,7 @@ async fn command_center_cursor_tracks_wrapped_footer() { view.cursor_pos(area).map(|(_, y)| usize::from(y)), Some(prompt_row) ); + assert!(area.contains(view.cursor_pos(area).unwrap().into())); } } } @@ -1246,6 +1454,7 @@ async fn empty_command_center_can_open_resume_picker() { app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); while event_rx.try_recv().is_ok() {} app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 20d20884b125..309d44e1670d 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -1,5 +1,8 @@ //! Dashboard for inspecting and managing the TUI's retained daemon tasks. +//! The shared view state retains the new-task editor across metadata refreshes. +#[path = "agents_overview_input.rs"] +mod input; #[path = "agents_overview_render.rs"] mod render; @@ -7,6 +10,8 @@ use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ChatComposer; use crate::bottom_pane::ViewCompletion; use crate::key_hint::KeyBindingListExt; use crate::key_hint::ShortcutHint; @@ -85,9 +90,13 @@ pub(super) struct AgentsOverviewRow { pub(super) is_current: bool, } -#[derive(Clone, Default)] +#[derive(Default)] pub(super) struct AgentsOverviewViewState { + // Search and rename never borrow the new-task draft. pub(super) input: String, + pub(super) composer: Option, + pub(super) key_chord_hint: Option>, + pub(super) focus: AgentsOverviewFocus, pub(super) connection_notice: Option<&'static str>, search: String, searching: bool, @@ -97,6 +106,40 @@ pub(super) struct AgentsOverviewViewState { pub(super) completion: Option, } +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(super) enum AgentsOverviewFocus { + #[default] + Composer, + List, +} + +impl AgentsOverviewViewState { + pub(super) fn focus_composer(&mut self) { + self.focus = AgentsOverviewFocus::Composer; + if let Some(composer) = self.composer.as_mut() { + composer.resume_text_entry(); + } + } + + fn editing_metadata(&self) -> bool { + self.searching || self.renaming + } + + fn composing(&self) -> bool { + self.focus == AgentsOverviewFocus::Composer && !self.searching && !self.renaming + } + + fn composer_owns_escape(&self) -> bool { + self.composer.as_ref().is_some_and(|composer| { + composer.popup_active() + || (composer.is_vim_enabled() + && !composer + .keymap_contexts() + .contains(KeymapContext::VimNormal)) + }) + } +} + pub(super) struct AgentsOverviewView { pub(super) rows: Vec, selected: usize, @@ -105,6 +148,8 @@ pub(super) struct AgentsOverviewView { app_event_tx: AppEventSender, keymap: ListKeymap, agents_keymap: AgentsKeymap, + composer_hints: Vec<(String, String)>, + composer_keymap: crate::keymap::ComposerKeymap, } impl AgentsOverviewView { @@ -120,6 +165,18 @@ impl AgentsOverviewView { .and_then(|thread_id| rows.iter().position(|row| row.thread_id == thread_id)) .or_else(|| rows.iter().position(|row| row.is_current)) .unwrap_or(0); + let composer_hints = [ + (KeymapContext::Composer, "submit", "create task"), + (KeymapContext::Editor, "insert_newline", "newline"), + ] + .into_iter() + .filter_map(|(context, action, label)| { + keymap + .primary_hint(context, action) + .map(|hint| (hint.display_label().replace(" + ", "+"), label.to_string())) + }) + .chain([("esc".to_string(), "tasks".to_string())]) + .collect(); let mut view = Self { rows, selected, @@ -128,6 +185,8 @@ impl AgentsOverviewView { app_event_tx, keymap: keymap.list, agents_keymap: keymap.agents, + composer_hints, + composer_keymap: keymap.composer, }; view.state().completion = None; let visible = view.visible_indices(); @@ -200,37 +259,23 @@ impl AgentsOverviewView { } fn activate(&mut self) { - let state = self.state().clone(); - let input = state.input.clone(); - if !state.searching && !input.is_empty() && input.trim().is_empty() { - return; - } - if !state.searching && !input.trim().is_empty() { - if state.renaming { - if let Some(row) = self.selected_row() { - self.app_event_tx - .send(AppEvent::RenameAgentsOverviewThread { - thread_id: row.thread_id, - name: input.trim().to_string(), - }); - } - self.state().renaming = false; - } else { + let input = self.state().input.clone(); + if self.state().renaming && !input.trim().is_empty() { + if let Some(row) = self.selected_row() { self.app_event_tx - .send(AppEvent::DispatchAgentsOverviewTask { - prompt: input, - cwd: (!state.status_grouping) - .then(|| self.selected_row().map(|row| row.thread.cwd.clone())) - .flatten(), + .send(AppEvent::RenameAgentsOverviewThread { + thread_id: row.thread_id, + name: input.trim().to_string(), }); } + self.state().renaming = false; self.state().input.clear(); - } else if let Some(row) = self.selected_row().filter(|_| !state.renaming) { + } else if let Some(row) = self.selected_row().filter(|_| !self.state().renaming) { self.app_event_tx .send(AppEvent::SelectAgentsOverviewThread { thread_id: row.thread_id, }); - if state.searching { + if self.state().searching { let mut state = self.state(); state.search.clear(); state.searching = false; @@ -404,6 +449,10 @@ impl AgentsOverviewView { } impl BottomPaneView for AgentsOverviewView { + fn next_frame_delay(&self) -> Option { + self.state().composer.as_ref()?.footer_flash_delay() + } + fn view_id(&self) -> Option<&'static str> { Some(AGENTS_OVERVIEW_VIEW_ID) } @@ -413,7 +462,15 @@ impl BottomPaneView for AgentsOverviewView { } fn keymap_contexts(&self) -> KeymapContextSet { - KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents) + let state = self.state(); + if state.composing() { + state + .composer + .as_ref() + .map_or_else(KeymapContextSet::default, ChatComposer::keymap_contexts) + } else { + KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents) + } } fn completion(&self) -> Option { @@ -428,13 +485,63 @@ impl BottomPaneView for AgentsOverviewView { true } + fn on_ctrl_c(&mut self) -> CancellationEvent { + let mut state = self.state(); + if state.editing_metadata() { + state.searching = false; + state.renaming = false; + state.search.clear(); + state.input.clear(); + return CancellationEvent::Handled; + } + if let Some(composer) = state.composer.as_mut() + && (composer.cancel_vim_search() + || composer.cancel_history_search() + || composer.clear_for_ctrl_c().is_some()) + { + return CancellationEvent::Handled; + } + CancellationEvent::NotHandled + } + fn handle_paste(&mut self, pasted: String) -> bool { - self.edit_input(|input| { - input.push_str(&crate::history_cell::sanitize_user_text(pasted.into())) - }) + if self.state().editing_metadata() { + return self.edit_input(|input| { + input.push_str(&crate::history_cell::sanitize_user_text(pasted.into())) + }); + } + let mut state = self.state(); + if state.focus == AgentsOverviewFocus::List { + state.focus_composer(); + } + state + .composer + .as_mut() + .is_some_and(|composer| composer.handle_paste(pasted)) + } + + fn flush_paste_burst_if_due(&mut self) -> bool { + self.state() + .composer + .as_mut() + .is_some_and(ChatComposer::flush_paste_burst_if_due) + } + + fn is_in_paste_burst(&self) -> bool { + self.state() + .composer + .as_ref() + .is_some_and(ChatComposer::is_in_paste_burst) } fn handle_key_event(&mut self, key: KeyEvent) { + if key.kind == crossterm::event::KeyEventKind::Release { + return; + } + if self.state().composing() { + self.handle_composer_key(key); + return; + } if key.code == KeyCode::Backspace && key.modifiers.is_empty() && self.keymap.action_for(key).is_none() @@ -447,8 +554,20 @@ impl BottomPaneView for AgentsOverviewView { if is_plain_text_key_event(key) && let KeyCode::Char(character) = key.code { - self.edit_input(|input| input.push(character)); - return; + if self.state().editing_metadata() { + self.edit_input(|input| input.push(character)); + return; + } + if !self + .state() + .composer + .as_ref() + .is_some_and(ChatComposer::is_vim_enabled) + { + self.state().focus_composer(); + self.handle_composer_key(key); + return; + } } if self.agents_keymap.search.is_pressed(key) || { @@ -467,7 +586,8 @@ impl BottomPaneView for AgentsOverviewView { return; } - if self.state().connection_notice.is_some() { + if self.state().connection_notice.is_some() && !self.agents_keymap.new_task.is_pressed(key) + { match self.keymap.action_for(key) { Some(ListAction::MoveUp) => self.move_selection(/*forward*/ false), Some(ListAction::MoveDown) => self.move_selection(/*forward*/ true), @@ -491,6 +611,7 @@ impl BottomPaneView for AgentsOverviewView { state.searching = false; state.renaming = false; state.input.clear(); + state.focus_composer(); return; } if self.agents_keymap.rename.is_pressed(key) { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index b24410da1e3b..4513430bc4cb 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -22,7 +22,7 @@ impl App { ) { crate::keymap::KeyChordMatch::PassThrough => { if was_pending && !self.key_chord_matcher.is_pending() { - self.chat_widget.set_footer_hint_override(/*items*/ None); + self.set_key_chord_hint_override(/*items*/ None); } Some(key_event) } @@ -30,7 +30,7 @@ impl App { if self.backtrack.primed { self.reset_backtrack_state(); } - self.chat_widget.set_footer_hint_override(Some(vec![ + self.set_key_chord_hint_override(Some(vec![ ( format!("{} …", prefix.display_label()), "waiting for next key".to_string(), @@ -42,11 +42,11 @@ impl App { None } crate::keymap::KeyChordMatch::Completed(dispatch_event) => { - self.chat_widget.set_footer_hint_override(/*items*/ None); + self.set_key_chord_hint_override(/*items*/ None); Some(dispatch_event) } crate::keymap::KeyChordMatch::Cancelled => { - self.chat_widget.set_footer_hint_override(/*items*/ None); + self.set_key_chord_hint_override(/*items*/ None); None } crate::keymap::KeyChordMatch::Ignored => None, @@ -59,16 +59,25 @@ impl App { .key_chord_matcher .expire(contexts, tokio::time::Instant::now()) { - self.chat_widget.set_footer_hint_override(/*items*/ None); + self.set_key_chord_hint_override(/*items*/ None); } } pub(super) fn cancel_pending_key_chord(&mut self) { if self.key_chord_matcher.cancel() { - self.chat_widget.set_footer_hint_override(/*items*/ None); + self.set_key_chord_hint_override(/*items*/ None); } } + fn set_key_chord_hint_override(&mut self, items: Option>) { + self.agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .key_chord_hint = items.clone(); + self.chat_widget.set_footer_hint_override(items); + } + fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet { if self.overlay.is_some() { return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Pager); diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index 50a35e8a8096..8410711ac05f 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -199,7 +199,9 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< }; let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); - app.agents_overview.view_state.lock().unwrap().input = "task draft".into(); + app.chat_widget.handle_paste("task draft".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); let mut tui = crate::tui::test_support::make_test_tui()?; app.handle_tui_event( &mut tui, @@ -232,7 +234,14 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< ) .await?; assert_eq!( - app.agents_overview.view_state.lock().unwrap().input, + app.agents_overview + .view_state + .lock() + .unwrap() + .composer + .as_ref() + .unwrap() + .current_text_with_pending(), "task draft!" ); assert!( diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 647aa437c779..b8d6887da5cb 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -271,3 +271,28 @@ async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<( assert!(app.chat_widget.can_launch_external_editor()); Ok(()) } + +#[tokio::test] +async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<()> { + let mut app = make_test_app().await; + app.keymap = RuntimeKeymap::from_config(&toml::from_str( + "[editor]\ninsert_newline = [\"ctrl-x n\"]", + )?) + .unwrap(); + let mut tui = crate::tui::test_support::make_test_tui()?; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 80); + assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None); + let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + insta::assert_snapshot!( + render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), + @" ctrl + x … waiting for next key esc cancel" + ); + assert_eq!( + app.route_key_chord_event(&mut tui, KeyCode::Esc.into()), + None + ); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 80), before); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 614aebfa0f29..d388bbff71cc 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -139,8 +139,20 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv ); app.agents_overview.visible_thread_ids = view.thread_ids(); app.chat_widget.show_bottom_pane_view(Box::new(view)); - app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into(); + if previous_thread.is_some() { + app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into(); + } else { + app.chat_widget.handle_paste("Keep this task draft".into()); + } app.agents_overview.view_state.lock().unwrap().renaming = previous_thread.is_some(); + let draft = |app: &App| { + let state = app.agents_overview.view_state.lock().unwrap(); + if previous_thread.is_some() { + state.input.clone() + } else { + state.composer.as_ref().unwrap().current_text_with_pending() + } + }; let stale_request = Uuid::new_v4(); app.agents_overview.request_id = Some(stale_request); app.agents_overview.refresh_pending = true; @@ -262,10 +274,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv .await?; app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into())) .await?; - assert_eq!( - app.agents_overview.view_state.lock().unwrap().input, - "Keep this task draft!" - ); + assert_eq!(draft(&app), "Keep this task draft!"); if previous_thread.is_none() { assert_snapshot!( "daemon_command_center_reconnecting", @@ -335,7 +344,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv assert!(!app.agents_overview.visible_thread_ids.contains(&vanished)); assert!(app.agents_overview.visible_thread_ids.contains(&added)); assert_eq!( - app.agents_overview.view_state.lock().unwrap().input, + draft(&app), if previous_thread.is_some() { "" } else { @@ -386,6 +395,14 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv ) .await?; + assert!(app.chat_widget.has_active_view()); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ) + .await?; + assert!(!app.chat_widget.has_active_view()); assert_eq!(app.current_displayed_thread_id(), Some(id)); let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 3b31bc5537fc..e852a7d4aab3 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -424,7 +424,9 @@ async fn removing_remote_thread_omits_disconnect_guidance() -> Result<()> { app.chat_widget.handle_thread_session(resumed.session); let mut tui = crate::tui::test_support::make_test_tui()?; let archived = matches!(&event, AppEvent::ArchiveCurrentThread); - let AppRunControl::Exit(reason) = app.handle_event(&mut tui, &mut server, event).await? + // Keep the large dispatcher future off the Windows test thread's stack. + let AppRunControl::Exit(reason) = + Box::pin(app.handle_event(&mut tui, &mut server, event)).await? else { panic!("removing the current thread must exit"); }; @@ -1162,14 +1164,15 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac assert_eq!(list_requests[0]["sourceKinds"], serde_json::Value::Null); let mut tui = crate::tui::test_support::make_test_tui()?; - app.handle_event( + // Box each dispatcher await so this test does not retain its large future inline. + Box::pin(app.handle_event( &mut tui, &mut app_server, AppEvent::DynamicToolCallCompleted { request_id, response, }, - ) + )) .await?; let completed = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), async { loop { @@ -1321,7 +1324,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac panic!("expected background task registration before its first turn: {registration:?}") }; assert!(recorded_params(&requests, "turn/start").is_empty()); - app.handle_event( + Box::pin(app.handle_event( &mut tui, &mut app_server, AppEvent::DynamicToolThreadStarted { @@ -1329,7 +1332,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac task_tools_available, registered, }, - ) + )) .await?; assert!( app.agents_overview @@ -1409,7 +1412,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac }; assert_eq!(continued_thread_id, creation_source); assert_eq!(recorded_params(&requests, "turn/start").len(), 1); - app.handle_event( + Box::pin(app.handle_event( &mut tui, &mut app_server, AppEvent::DynamicToolThreadStarted { @@ -1417,7 +1420,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac task_tools_available, registered, }, - ) + )) .await?; let AppEvent::DynamicToolCallCompleted { response, .. } = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), events.recv()) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap index 150820831cde..62c482207865 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - │ - │ - │ - New task › task draft! - ctrl+c quit · actions paused until the list is refreshed + New task + +› task draft! + + esc tasks · dispatch paused diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap index 88929f315eb4..fdf59fdae257 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - │ - │ - │ - New task › Keep this task draft! - ctrl+c quit · actions paused until the list is refreshed + New task + +› Keep this task draft! + + esc tasks · dispatch paused diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap index 2a7f38cd3e13..c049dcc0325a 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - │ - │ - │ - New task › Keep this task draft! - ctrl+c quit · actions paused until the list is refreshed + New task + +› Keep this task draft! + + esc tasks · dispatch paused diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap index 835c3d087343..9b17c2f0c20f 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap @@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - │ - │ - New task › Describe a task and press enter to dispatch it - ↑↓ navigate ctrl+o resume enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc - back + New task + +› Describe a new task + + enter create task ctrl+j newline esc tasks diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 202d0fd0d84e..709db71caca0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -100,7 +100,8 @@ //! On submit/queue paths, the composer: //! //! - Expands pending paste placeholders so element ranges align with the final text. -//! - Trims whitespace and rebases text elements accordingly. +//! - Trims whitespace and rebases elements when `trim_submission` is enabled (the default). +//! Otherwise, preserves both. //! - Treats a leading `!` revealed only by paste expansion as literal model input, not shell input. //! - Prunes local attached images so only placeholders that survive expansion are sent. //! - Preserves remote image URLs as separate attachments even when text is empty. @@ -470,10 +471,7 @@ enum PendingPasteHandling { Preserve, } -/// Feature flags for reusing the chat composer in other bottom-pane surfaces. -/// -/// The default keeps today's behavior intact. Other call sites can opt out of -/// specific behaviors by constructing a config with those flags set to `false`. +/// Shared composer behavior; defaults match the main chat input. #[derive(Clone, Copy, Debug)] pub(crate) struct ChatComposerConfig { /// Whether command/file/skill popups are allowed to appear. @@ -484,6 +482,8 @@ pub(crate) struct ChatComposerConfig { pub(crate) shell_commands_enabled: bool, /// Whether pasting a file path can attach local images. pub(crate) image_paste_enabled: bool, + /// Strip leading and trailing whitespace from submissions. + pub(crate) trim_submission: bool, } impl Default for ChatComposerConfig { @@ -493,6 +493,7 @@ impl Default for ChatComposerConfig { slash_commands_enabled: true, shell_commands_enabled: true, image_paste_enabled: true, + trim_submission: true, } } } @@ -508,6 +509,7 @@ impl ChatComposerConfig { slash_commands_enabled: false, shell_commands_enabled: false, image_paste_enabled: false, + trim_submission: true, } } } @@ -3086,11 +3088,11 @@ impl ChatComposer { text_elements = expanded_elements; } - let expanded_input = text.clone(); - - // If there is neither text nor attachments, suppress submission entirely. - text = text.trim().to_string(); - text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); + if self.config.trim_submission { + let expanded_input = text.clone(); + text = text.trim().to_string(); + text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); + } if slash_validation == SlashValidation::Immediate && let SubmissionValidation::UnknownCommand(name) = self @@ -3146,7 +3148,7 @@ impl ChatComposer { } self.attachments .prune_local_images_for_submission(&text, &text_elements); - if text.is_empty() && self.attachments.is_empty() { + if text.trim().is_empty() && self.attachments.is_empty() { return None; } self.draft.recent_submission_mention_bindings = original_mention_bindings.clone(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs index 55a50ef460f7..c0669203d558 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -1,10 +1,16 @@ -//! Offline editing retains the draft, with shared paste Enter handling for submission. +//! Offline editing and event-channel rebinding retain the draft in place. +//! Paste Enter handling is shared with normal submission so buffered newlines survive both paths. use super::*; impl ChatComposer { + /// Rebind retained editors after the app replaces its event channel. + pub(crate) fn set_app_event_sender(&mut self, sender: AppEventSender) { + self.app_event_tx = sender; + } + /// Preserve Enter inside a paste burst without attempting submission. - pub(super) fn handle_paste_enter(&mut self, now: Instant) -> bool { + pub(crate) fn handle_paste_enter(&mut self, now: Instant) -> bool { let in_slash_context = self.slash_commands_enabled() && !self.draft.is_bash_mode && (matches!(self.popups.active, ActivePopup::Command(_)) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dd3a23f24f0a..8cda1b818ac0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1719,6 +1719,10 @@ impl ChatWidget { self.bottom_pane.composer_is_empty() && !self.bottom_pane.is_in_paste_burst() } + pub(crate) fn composer_is_vim_enabled(&self) -> bool { + self.bottom_pane.composer_is_vim_enabled() + } + #[cfg(test)] pub(crate) fn is_task_running_for_test(&self) -> bool { self.bottom_pane.is_task_running() diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap index 83d04e8e2b47..745e7f3b22b4 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap @@ -20,9 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ │ - │ - │ - │ - New task › Describe a task and press enter to dispatch it - ↑↓ navigate ctrl+o resume enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop - esc back + New task + +› First line + Second line + + enter create task ctrl+j newline esc tasks diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap index 25e7df76b311..f790e818b3b8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap @@ -19,10 +19,10 @@ expression: "render_bottom_popup(&app.chat_widget, 48)" + New task +› Describe a new task - - New task › Describe a task and press enter t - ↑↓ navigate ctrl+o resume enter open ctrl+f - search ctrl+s group ctrl+r rename ctrl+x - stop esc back + ↑↓ navigate ctrl+o resume enter open ctrl+n + new task ctrl+f search ctrl+s group ctrl+r + rename ctrl+x stop esc back