diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 7c9f0f0e75..4c45fcd05f 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -104,6 +104,8 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a // Terminal setup use crossterm::ExecutableCommand; + use crossterm::event::DisableBracketedPaste; + use crossterm::event::EnableBracketedPaste; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; use crossterm::event::PushKeyboardEnhancementFlags; @@ -116,6 +118,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let mut stdout = std::io::stdout(); enable_raw_mode()?; stdout.execute(EnterAlternateScreen)?; + stdout.execute(EnableBracketedPaste)?; // Enable enhanced key reporting so Shift+Enter is distinguishable from Enter. // Some terminals may not support these flags; ignore errors if enabling fails. let _ = crossterm::execute!( @@ -648,6 +651,27 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a } maybe_event = events.next() => { match maybe_event { + Some(Ok(Event::Paste(pasted))) => { + if app.env_modal.is_some() { + if let Some(m) = app.env_modal.as_mut() { + for ch in pasted.chars() { + match ch { + '\r' | '\n' => continue, + '\t' => m.query.push(' '), + _ => m.query.push(ch), + } + } + } + needs_redraw = true; + } else if let Some(page) = app.new_task.as_mut() { + if !page.submitting { + if page.composer.handle_paste(pasted) { + needs_redraw = true; + } + let _ = frame_tx.send(Instant::now()); + } + } + } Some(Ok(Event::Key(key))) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { // Treat Ctrl-C like pressing 'q' in the current context. if key.modifiers.contains(KeyModifiers::CONTROL) @@ -1313,6 +1337,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a // Restore terminal disable_raw_mode().ok(); terminal.show_cursor().ok(); + let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste); // Best-effort restore of keyboard enhancement flags before leaving alt screen. let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags); let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen); diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index 6a266d2103..d7056156a1 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -2,6 +2,7 @@ use ratatui::layout::Constraint; use ratatui::layout::Direction; use ratatui::layout::Layout; use ratatui::prelude::*; +use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; @@ -20,6 +21,7 @@ use crate::app::App; use crate::app::AttemptView; use chrono::Local; use chrono::Utc; +use codex_cloud_tasks_client::AttemptStatus; use codex_cloud_tasks_client::TaskStatus; pub fn draw(frame: &mut Frame, app: &mut App) { @@ -385,7 +387,9 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { { spans.extend(vec![ " ".into(), - format!("Attempt {}/{}", ov.selected_attempt + 1, total).dim(), + format!("Attempt {}/{}", ov.selected_attempt + 1, total) + .bold() + .dim(), " ".into(), "(Tab/Shift-Tab or [ ] to cycle attempts)".dim(), ]); @@ -415,29 +419,10 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { .map(|l| style_diff_line(l)) .collect() } else { - let mut in_code = false; - let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); - raw.unwrap_or(&[]) - .iter() - .map(|raw| { - if raw.trim_start().starts_with("```") { - in_code = !in_code; - return Line::from(raw.to_string().cyan()); - } - if in_code { - return Line::from(raw.to_string().cyan()); - } - let s = raw.trim_start(); - if s.starts_with("### ") || s.starts_with("## ") || s.starts_with("# ") { - return Line::from(raw.to_string().magenta().bold()); - } - if s.starts_with("- ") || s.starts_with("* ") { - let rest = &s[2..]; - return Line::from(vec!["• ".into(), rest.to_string().into()]); - } - Line::from(raw.to_string()) - }) - .collect() + app.diff_overlay + .as_ref() + .map(|o| style_conversation_lines(&o.sd, o.current_attempt())) + .unwrap_or_default() }; let raw_empty = app .diff_overlay @@ -540,6 +525,197 @@ pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConversationSpeaker { + User, + Assistant, +} + +fn style_conversation_lines( + sd: &crate::scrollable_diff::ScrollableDiff, + attempt: Option<&AttemptView>, +) -> Vec> { + use ratatui::text::Span; + + let wrapped = sd.wrapped_lines(); + if wrapped.is_empty() { + return Vec::new(); + } + + let indices = sd.wrapped_src_indices(); + let mut styled: Vec> = Vec::new(); + let mut speaker: Option = None; + let mut in_code = false; + let mut last_src: Option = None; + let mut bullet_indent: Option = None; + + for (display, &src_idx) in wrapped.iter().zip(indices.iter()) { + let raw = sd.raw_line_at(src_idx); + let trimmed = raw.trim(); + let is_new_raw = last_src.map(|prev| prev != src_idx).unwrap_or(true); + + if trimmed.eq_ignore_ascii_case("user:") { + speaker = Some(ConversationSpeaker::User); + in_code = false; + bullet_indent = None; + styled.push(conversation_header_line(ConversationSpeaker::User, None)); + last_src = Some(src_idx); + continue; + } + if trimmed.eq_ignore_ascii_case("assistant:") { + speaker = Some(ConversationSpeaker::Assistant); + in_code = false; + bullet_indent = None; + styled.push(conversation_header_line( + ConversationSpeaker::Assistant, + attempt, + )); + last_src = Some(src_idx); + continue; + } + if raw.is_empty() { + let mut spans: Vec = Vec::new(); + if let Some(role) = speaker { + spans.push(conversation_gutter_span(role)); + } else { + spans.push(Span::raw(String::new())); + } + styled.push(Line::from(spans)); + last_src = Some(src_idx); + bullet_indent = None; + continue; + } + + if is_new_raw { + let trimmed_start = raw.trim_start(); + if trimmed_start.starts_with("```") { + in_code = !in_code; + bullet_indent = None; + } else if !in_code + && (trimmed_start.starts_with("- ") || trimmed_start.starts_with("* ")) + { + let indent = raw.chars().take_while(|c| c.is_whitespace()).count(); + bullet_indent = Some(indent); + } else if !in_code { + bullet_indent = None; + } + } + + let mut spans: Vec = Vec::new(); + if let Some(role) = speaker { + spans.push(conversation_gutter_span(role)); + } + + spans.extend(conversation_text_spans( + display, + in_code, + is_new_raw, + bullet_indent, + )); + + styled.push(Line::from(spans)); + last_src = Some(src_idx); + } + + if styled.is_empty() { + wrapped.iter().map(|l| Line::from(l.to_string())).collect() + } else { + styled + } +} + +fn conversation_header_line( + speaker: ConversationSpeaker, + attempt: Option<&AttemptView>, +) -> Line<'static> { + use ratatui::text::Span; + + let mut spans: Vec = vec!["╭ ".dim()]; + match speaker { + ConversationSpeaker::User => { + spans.push("User".cyan().bold()); + spans.push(" prompt".dim()); + } + ConversationSpeaker::Assistant => { + spans.push("Assistant".magenta().bold()); + spans.push(" response".dim()); + if let Some(attempt) = attempt { + if let Some(status_span) = attempt_status_span(attempt.status) { + spans.push(" • ".dim()); + spans.push(status_span); + } + } + } + } + Line::from(spans) +} + +fn conversation_gutter_span(speaker: ConversationSpeaker) -> ratatui::text::Span<'static> { + match speaker { + ConversationSpeaker::User => "│ ".cyan().dim(), + ConversationSpeaker::Assistant => "│ ".magenta().dim(), + } +} + +fn conversation_text_spans( + display: &str, + in_code: bool, + is_new_raw: bool, + bullet_indent: Option, +) -> Vec> { + use ratatui::text::Span; + + if in_code { + return vec![Span::styled( + display.to_string(), + Style::default().fg(Color::Cyan), + )]; + } + + let trimmed = display.trim_start(); + + if let Some(indent) = bullet_indent { + if is_new_raw { + let rest = trimmed.get(2..).unwrap_or("").trim_start(); + let mut spans: Vec = Vec::new(); + if indent > 0 { + spans.push(Span::raw(" ".repeat(indent))); + } + spans.push("• ".into()); + spans.push(Span::raw(rest.to_string())); + return spans; + } + let mut continuation = String::new(); + continuation.push_str(&" ".repeat(indent + 2)); + continuation.push_str(trimmed); + return vec![Span::raw(continuation)]; + } + + if is_new_raw + && (trimmed.starts_with("### ") || trimmed.starts_with("## ") || trimmed.starts_with("# ")) + { + return vec![Span::styled( + display.to_string(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )]; + } + + vec![Span::raw(display.to_string())] +} + +fn attempt_status_span(status: AttemptStatus) -> Option> { + match status { + AttemptStatus::Completed => Some("Completed".green()), + AttemptStatus::Failed => Some("Failed".red().bold()), + AttemptStatus::InProgress => Some("In progress".magenta()), + AttemptStatus::Pending => Some("Pending".yellow()), + AttemptStatus::Cancelled => Some("Cancelled".yellow().dim()), + AttemptStatus::Unknown => None, + } +} + fn style_diff_line(raw: &str) -> Line<'static> { use ratatui::style::Color; use ratatui::style::Modifier; diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index c79c8de3c2..7fc2ca8147 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -58,6 +58,10 @@ impl ComposerInput { } } + pub fn handle_paste(&mut self, pasted: String) -> bool { + self.inner.handle_paste(pasted) + } + /// Override the footer hint items displayed under the composer. /// Each tuple is rendered as "