diff --git a/codex-rs/tui/src/app/agents_overview_render.rs b/codex-rs/tui/src/app/agents_overview_render.rs new file mode 100644 index 0000000000..5ec8b722ff --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_render.rs @@ -0,0 +1,194 @@ +//! Layout rendering and cursor placement for the agent dashboard. + +use super::*; + +impl Renderable for AgentsOverviewView { + fn desired_height(&self, _width: u16) -> u16 { + 24 + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let state = self.state().clone(); + let (label, input) = if state.searching { + (" Search › ", &state.search) + } else if state.renaming { + (" Rename › ", &state.input) + } else { + (" New task › ", &state.input) + }; + let x = area + .x + .saturating_add((label.width() + input.width()) as u16) + .min(area.right().saturating_sub(3)); + Some((x, area.bottom().saturating_sub(2))) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width < 12 || area.height < 8 { + return; + } + Clear.render(area, buf); + 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(1), + ]) + .areas(area); + let inset = + |rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0)); + Line::from("Agent command center".bold()).render(inset(header), buf); + let (needs_you, working, ready) = self.rows.iter().fold((0, 0, 0), |counts, row| { + let (needs_you, working, ready) = counts; + match row.group { + AgentsOverviewGroup::NeedsYou => (needs_you + 1, working, ready), + AgentsOverviewGroup::Working => (needs_you, working + 1, ready), + AgentsOverviewGroup::Ready => (needs_you, working, ready + 1), + AgentsOverviewGroup::Finished => counts, + } + }); + let attention = format!("{needs_you} need input"); + if let Some(notice) = self.state().connection_notice { + Line::from(notice.cyan()).render(inset(summary), buf); + } else { + Line::from(format!("{attention} {working} working {ready} ready").dim()) + .render(inset(summary), buf); + } + Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim()) + .render(inset(divider), buf); + let body = inset(body); + if body.width >= 90 { + let [list, gap, details] = Layout::horizontal([ + Constraint::Min(46), + Constraint::Length(3), + Constraint::Length(38), + ]) + .areas(body); + for y in gap.y..gap.bottom() { + buf[(gap.x + 1, y)] + .set_symbol("│") + .set_style(Style::new().dim()); + } + self.render_rows(list, buf); + self.render_details(details, buf); + } else { + self.render_rows(body, buf); + } + let state = self.state().clone(); + 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 { + "" + }; + let available_width = usize::from(inset(prompt).width) + .saturating_sub(label.width()) + .saturating_sub(1); + let mut visible_start = input.len(); + let mut visible_width = 0; + for (index, character) in input.char_indices().rev() { + let width = character.width().unwrap_or(0); + if visible_width + width > available_width { + break; + } + 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); + if state.connection_notice.is_some() { + Line::from("ctrl+c quit · actions paused until the list is refreshed".dim()) + .render(inset(footer), buf); + return; + } + let list_hint = |action| { + self.keymap.primary_hint(action).filter(|hint| { + !matches!(hint, ShortcutHint::Single(binding) + if is_plain_text_key_event(KeyEvent::new( + binding.parts().0, + binding.parts().1, + )) || [ + &self.agents_keymap.search, + &self.agents_keymap.new_task, + &self.agents_keymap.rename, + &self.agents_keymap.stop, + &self.agents_keymap.toggle_grouping, + ] + .into_iter() + .any(|bindings| bindings.contains(binding))) + }) + }; + let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown] + .into_iter() + .filter_map(list_hint) + .map(|hint| hint.display_label().replace(" + ", "+")) + .collect::>(); + let navigation_hint = navigation_hints.join( + if navigation_hints + .iter() + .all(|hint| hint.chars().count() == 1) + { + "" + } else { + " " + }, + ); + let mut footer_spans = Vec::new(); + if !navigation_hint.is_empty() { + footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]); + } + let mut add_hint = |hint: Option, label: &'static str, enabled: bool| { + if let Some(hint) = hint { + let key = hint.display_label().replace(" + ", "+"); + footer_spans.push(if enabled { key.bold() } else { key.dim() }); + footer_spans.push(format!(" {label} ").dim()); + } + }; + add_hint(list_hint(ListAction::Accept), "open", true); + add_hint( + self.agents_keymap + .primary_hint("search", &self.agents_keymap.search), + "search", + true, + ); + add_hint( + self.agents_keymap + .primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping), + "group", + true, + ); + add_hint( + self.agents_keymap + .primary_hint("rename", &self.agents_keymap.rename), + "rename", + true, + ); + add_hint( + self.agents_keymap + .primary_hint("stop", &self.agents_keymap.stop), + "stop", + self.selected_row() + .is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })), + ); + add_hint(list_hint(ListAction::Cancel), "back", true); + let footer_area = inset(footer); + let mut footer_line: Line = footer_spans.into(); + if footer_line.width() > usize::from(footer_area.width) { + for span in &mut footer_line.spans { + if span.content.ends_with(" ") { + span.content.to_mut().pop(); + } + } + } + footer_line.render(footer_area, buf); + } +} diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 62d5f6dfd4..85fdc5325f 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. +#[path = "agents_overview_render.rs"] +mod render; + use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -555,194 +558,3 @@ impl BottomPaneView for AgentsOverviewView { } } } - -impl Renderable for AgentsOverviewView { - fn desired_height(&self, _width: u16) -> u16 { - 24 - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let state = self.state().clone(); - let (label, input) = if state.searching { - (" Search › ", &state.search) - } else if state.renaming { - (" Rename › ", &state.input) - } else { - (" New task › ", &state.input) - }; - let x = area - .x - .saturating_add((label.width() + input.width()) as u16) - .min(area.right().saturating_sub(3)); - Some((x, area.bottom().saturating_sub(2))) - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.width < 12 || area.height < 8 { - return; - } - Clear.render(area, buf); - 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(1), - ]) - .areas(area); - let inset = - |rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0)); - Line::from("Agent command center".bold()).render(inset(header), buf); - let (needs_you, working, ready) = self.rows.iter().fold((0, 0, 0), |counts, row| { - let (needs_you, working, ready) = counts; - match row.group { - AgentsOverviewGroup::NeedsYou => (needs_you + 1, working, ready), - AgentsOverviewGroup::Working => (needs_you, working + 1, ready), - AgentsOverviewGroup::Ready => (needs_you, working, ready + 1), - AgentsOverviewGroup::Finished => counts, - } - }); - let attention = format!("{needs_you} need input"); - if let Some(notice) = self.state().connection_notice { - Line::from(notice.cyan()).render(inset(summary), buf); - } else { - Line::from(format!("{attention} {working} working {ready} ready").dim()) - .render(inset(summary), buf); - } - Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim()) - .render(inset(divider), buf); - let body = inset(body); - if body.width >= 90 { - let [list, gap, details] = Layout::horizontal([ - Constraint::Min(46), - Constraint::Length(3), - Constraint::Length(38), - ]) - .areas(body); - for y in gap.y..gap.bottom() { - buf[(gap.x + 1, y)] - .set_symbol("│") - .set_style(Style::new().dim()); - } - self.render_rows(list, buf); - self.render_details(details, buf); - } else { - self.render_rows(body, buf); - } - let state = self.state().clone(); - 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 { - "" - }; - let available_width = usize::from(inset(prompt).width) - .saturating_sub(label.width()) - .saturating_sub(1); - let mut visible_start = input.len(); - let mut visible_width = 0; - for (index, character) in input.char_indices().rev() { - let width = character.width().unwrap_or(0); - if visible_width + width > available_width { - break; - } - 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); - if state.connection_notice.is_some() { - Line::from("ctrl+c quit · actions paused until the list is refreshed".dim()) - .render(inset(footer), buf); - return; - } - let list_hint = |action| { - self.keymap.primary_hint(action).filter(|hint| { - !matches!(hint, ShortcutHint::Single(binding) - if is_plain_text_key_event(KeyEvent::new( - binding.parts().0, - binding.parts().1, - )) || [ - &self.agents_keymap.search, - &self.agents_keymap.new_task, - &self.agents_keymap.rename, - &self.agents_keymap.stop, - &self.agents_keymap.toggle_grouping, - ] - .into_iter() - .any(|bindings| bindings.contains(binding))) - }) - }; - let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown] - .into_iter() - .filter_map(list_hint) - .map(|hint| hint.display_label().replace(" + ", "+")) - .collect::>(); - let navigation_hint = navigation_hints.join( - if navigation_hints - .iter() - .all(|hint| hint.chars().count() == 1) - { - "" - } else { - " " - }, - ); - let mut footer_spans = Vec::new(); - if !navigation_hint.is_empty() { - footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]); - } - let mut add_hint = |hint: Option, label: &'static str, enabled: bool| { - if let Some(hint) = hint { - let key = hint.display_label().replace(" + ", "+"); - footer_spans.push(if enabled { key.bold() } else { key.dim() }); - footer_spans.push(format!(" {label} ").dim()); - } - }; - add_hint(list_hint(ListAction::Accept), "open", true); - add_hint( - self.agents_keymap - .primary_hint("search", &self.agents_keymap.search), - "search", - true, - ); - add_hint( - self.agents_keymap - .primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping), - "group", - true, - ); - add_hint( - self.agents_keymap - .primary_hint("rename", &self.agents_keymap.rename), - "rename", - true, - ); - add_hint( - self.agents_keymap - .primary_hint("stop", &self.agents_keymap.stop), - "stop", - self.selected_row() - .is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })), - ); - add_hint(list_hint(ListAction::Cancel), "back", true); - let footer_area = inset(footer); - let mut footer_line: Line = footer_spans.into(); - if footer_line.width() > usize::from(footer_area.width) { - for span in &mut footer_line.spans { - if span.content.ends_with(" ") { - span.content.to_mut().pop(); - } - } - } - footer_line.render(footer_area, buf); - } -} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1f67d2a593..394dd42209 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3184,37 +3184,7 @@ impl ChatComposer { should_queue: bool, now: Instant, ) -> (InputResult, bool) { - // Preserve newlines that are part of a paste before applying parent-owned submission - // policy. Queued startup input still flushes the burst into its queued message below. - let in_slash_context = self.slash_commands_enabled() - && !self.draft.is_bash_mode - && (matches!(self.popups.active, ActivePopup::Command(_)) - || self - .draft - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/')); - if !should_queue - && !self.draft.disable_paste_burst - && self.draft.paste_burst.is_active() - && !in_slash_context - && self.draft.paste_burst.append_newline_if_active(now) - { - return (InputResult::None, true); - } - if !should_queue - && !in_slash_context - && !self.draft.disable_paste_burst - && self - .draft - .paste_burst - .newline_should_insert_instead_of_submit(now) - { - self.draft.textarea.insert_str("\n"); - self.draft.paste_burst.extend_window(now); + if !should_queue && self.handle_paste_enter(now) { return (InputResult::None, true); } 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 0c65ebeebc..55a50ef460 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -1,8 +1,43 @@ -//! Offline editing bypasses command dispatch and popup actions, retaining the draft in place. +//! Offline editing retains the draft, with shared paste Enter handling for submission. use super::*; impl ChatComposer { + /// Preserve Enter inside a paste burst without attempting submission. + pub(super) 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(_)) + || self + .draft + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/')); + if !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() + && !in_slash_context + && self.draft.paste_burst.append_newline_if_active(now) + { + return true; + } + if !in_slash_context + && !self.draft.disable_paste_burst + && self + .draft + .paste_burst + .newline_should_insert_instead_of_submit(now) + { + self.draft.textarea.insert_str("\n"); + self.draft.paste_burst.extend_window(now); + return true; + } + + false + } + pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { self.cancel_history_search(); self.attachments.clear_remote_image_selection();