diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 728659918f..7abf5eede4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -295,7 +295,7 @@ impl App { self.on_update_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { - self.chat_widget.set_model(model.clone()); + self.chat_widget.set_model(&model); self.config.model = model.clone(); if let Some(family) = find_family_for_model(&model) { self.config.model_family = family; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 008cc777ed..6e59e987c2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -74,6 +74,8 @@ use self::interrupts::InterruptManager; mod agent; use self::agent::spawn_agent; use self::agent::spawn_agent_from_existing; +mod session_header; +use self::session_header::SessionHeader; use crate::streaming::controller::AppEventHistorySink; use crate::streaming::controller::StreamController; use codex_common::approval_presets::ApprovalPreset; @@ -109,6 +111,7 @@ pub(crate) struct ChatWidget { bottom_pane: BottomPane, active_exec_cell: Option, config: Config, + session_header: SessionHeader, initial_user_message: Option, token_info: Option, // Stream lifecycle controller @@ -165,9 +168,11 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.conversation_id = Some(event.session_id); let initial_messages = event.initial_messages.clone(); + let model_for_header = event.model.clone(); if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } + self.session_header.set_model(&model_for_header); self.add_to_history(history_cell::new_session_info( &self.config, event, @@ -609,14 +614,23 @@ impl ChatWidget { )); } - fn layout_areas(&self, area: Rect) -> [Rect; 2] { + fn layout_areas(&self, area: Rect) -> [Rect; 3] { + let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height); + let remaining = area.height.saturating_sub(bottom_min); + + let active_desired = self + .active_exec_cell + .as_ref() + .map_or(0, |c| c.desired_height(area.width) + 1); + let active_height = active_desired.min(remaining); + // Note: no header area; remaining is not used beyond computing active height. + + let header_height = 0u16; + Layout::vertical([ - Constraint::Max( - self.active_exec_cell - .as_ref() - .map_or(0, |c| c.desired_height(area.width) + 1), - ), - Constraint::Min(self.bottom_pane.desired_height(area.width)), + Constraint::Length(header_height), + Constraint::Length(active_height), + Constraint::Min(bottom_min), ]) .areas(area) } @@ -651,6 +665,7 @@ impl ChatWidget { }), active_exec_cell: None, config: config.clone(), + session_header: SessionHeader::new(config.model.clone()), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -703,6 +718,7 @@ impl ChatWidget { }), active_exec_cell: None, config: config.clone(), + session_header: SessionHeader::new(config.model.clone()), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -1281,8 +1297,9 @@ impl ChatWidget { } /// Set the model in the widget's config copy. - pub(crate) fn set_model(&mut self, model: String) { - self.config.model = model; + pub(crate) fn set_model(&mut self, model: &str) { + self.session_header.set_model(model); + self.config.model = model.to_string(); } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { @@ -1403,14 +1420,14 @@ impl ChatWidget { } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let [_, bottom_pane_area] = self.layout_areas(area); + let [_, _, bottom_pane_area] = self.layout_areas(area); self.bottom_pane.cursor_pos(bottom_pane_area) } } impl WidgetRef for &ChatWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [active_cell_area, bottom_pane_area] = self.layout_areas(area); + let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); if !active_cell_area.is_empty() && let Some(cell) = &self.active_exec_cell diff --git a/codex-rs/tui/src/chatwidget/session_header.rs b/codex-rs/tui/src/chatwidget/session_header.rs new file mode 100644 index 0000000000..32e31b6682 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/session_header.rs @@ -0,0 +1,16 @@ +pub(crate) struct SessionHeader { + model: String, +} + +impl SessionHeader { + pub(crate) fn new(model: String) -> Self { + Self { model } + } + + /// Updates the header's model text. + pub(crate) fn set_model(&mut self, model: &str) { + if self.model != model { + self.model = model.to_string(); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index f306381f4a..6087853902 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 728 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 0b9b10f309..183a295063 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 794 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 63fb213e1b..6087853902 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 921 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2cb0685ffc..9d49c9edb5 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -79,7 +79,9 @@ fn final_answer_without_newline_is_flushed_immediately() { // Set up a VT100 test terminal to capture ANSI visual output let width: u16 = 80; - let height: u16 = 2000; + // Increased height to keep the initial banner/help lines in view even if + // the session renders an extra header line or minor layout changes occur. + let height: u16 = 2500; let viewport = Rect::new(0, height - 1, width, 1); let backend = ratatui::backend::TestBackend::new(width, height); let mut terminal = crate::custom_terminal::Terminal::with_options(backend) @@ -230,6 +232,7 @@ fn make_chatwidget_manual() -> ( bottom_pane: bottom, active_exec_cell: None, config: cfg.clone(), + session_header: SessionHeader::new(cfg.model.clone()), initial_user_message: None, token_info: None, stream: StreamController::new(cfg), @@ -773,10 +776,11 @@ async fn binary_size_transcript_snapshot() { // Consider content only after the last session banner marker. Skip the transient // 'thinking' header if present, and start from the first non-empty content line // that follows. This keeps the snapshot stable across sessions. - const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in "; + const MARKER_PREFIX: &str = + "Describe a task to get started or try one of the following commands:"; let last_marker_line_idx = lines .iter() - .rposition(|l| l.starts_with(MARKER_PREFIX)) + .rposition(|l| l.trim_start().starts_with(MARKER_PREFIX)) .expect("marker not found in visible output"); // Prefer the first assistant content line (blockquote '>' prefix) after the marker; // fallback to the first non-empty, non-'thinking' line. diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c4c019dbe0..fee9da952c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -5,7 +5,6 @@ use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; -use crate::slash_command::SlashCommand; use crate::text_formatting::format_and_truncate_tool_result; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::wrapping::RtOptions; @@ -581,6 +580,7 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { } const TOOL_CALL_MAX_LINES: usize = 5; +const SESSION_HEADER_MAX_INNER_WIDTH: usize = 70; fn title_case(s: &str) -> String { if s.is_empty() { @@ -613,7 +613,7 @@ pub(crate) fn new_session_info( config: &Config, event: SessionConfiguredEvent, is_first_event: bool, -) -> PlainHistoryCell { +) -> CompositeHistoryCell { let SessionConfiguredEvent { model, reasoning_effort: _, @@ -624,58 +624,53 @@ pub(crate) fn new_session_info( rollout_path: _, } = event; if is_first_event { - let cwd_str = match relativize_to_home(&config.cwd) { - Some(rel) if !rel.as_os_str().is_empty() => { - let sep = std::path::MAIN_SEPARATOR; - format!("~{sep}{}", rel.display()) - } - Some(_) => "~".to_string(), - None => config.cwd.display().to_string(), - }; - // Discover AGENTS.md files to decide whether to suggest `/init`. - let has_agents_md = discover_project_doc_paths(config) - .map(|v| !v.is_empty()) - .unwrap_or(false); + // Header box rendered as history (so it appears at the very top) + let header = SessionHeaderHistoryCell::new( + model, + config.cwd.clone(), + crate::version::CODEX_CLI_VERSION, + ); - let mut lines: Vec> = Vec::new(); - lines.push(Line::from(vec![ - ">_ ".dim(), - "You are using OpenAI Codex in".bold(), - format!(" {cwd_str}").dim(), - ])); - lines.push(Line::from("".dim())); - lines.push(Line::from( - " To get started, describe a task or try one of these commands:".dim(), - )); - lines.push(Line::from("".dim())); - if !has_agents_md { - lines.push(Line::from(vec![ - " /init".bold(), - format!(" - {}", SlashCommand::Init.description()).dim(), - ])); + // Help lines below the header (new copy and list) + let help_lines: Vec> = vec![ + "Describe a task to get started or try one of the following commands:" + .dim() + .into(), + Line::from("".dim()), + Line::from(vec![ + "1. ".into(), + "/status".bold(), + " - show current session configuration and token usage".dim(), + ]), + Line::from(vec![ + "2. ".into(), + "/compact".bold(), + " - compact the chat history to avoid context limits".dim(), + ]), + Line::from(vec![ + "3. ".into(), + "/prompts".bold(), + " - explore starter prompts to get to know Codex".dim(), + ]), + ]; + + CompositeHistoryCell { + parts: vec![ + Box::new(header), + Box::new(PlainHistoryCell { lines: help_lines }), + ], } - lines.push(Line::from(vec![ - " /status".bold(), - format!(" - {}", SlashCommand::Status.description()).dim(), - ])); - lines.push(Line::from(vec![ - " /approvals".bold(), - format!(" - {}", SlashCommand::Approvals.description()).dim(), - ])); - lines.push(Line::from(vec![ - " /model".bold(), - format!(" - {}", SlashCommand::Model.description()).dim(), - ])); - PlainHistoryCell { lines } } else if config.model == model { - PlainHistoryCell { lines: Vec::new() } + CompositeHistoryCell { parts: vec![] } } else { let lines = vec![ "model changed:".magenta().bold().into(), format!("requested: {}", config.model).into(), format!("used: {model}").into(), ]; - PlainHistoryCell { lines } + CompositeHistoryCell { + parts: vec![Box::new(PlainHistoryCell { lines })], + } } } @@ -702,6 +697,141 @@ pub(crate) fn new_active_exec_command( }) } +#[derive(Debug)] +struct SessionHeaderHistoryCell { + version: &'static str, + model: String, + directory: PathBuf, +} + +impl SessionHeaderHistoryCell { + fn new(model: String, directory: PathBuf, version: &'static str) -> Self { + Self { + version, + model, + directory, + } + } + + fn format_directory(&self) -> String { + if let Some(rel) = relativize_to_home(&self.directory) { + if rel.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) + } + } else { + self.directory.display().to_string() + } + } +} + +impl HistoryCell for SessionHeaderHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut out: Vec> = Vec::new(); + if width < 4 { + return out; + } + + let inner_width = std::cmp::min( + width.saturating_sub(2) as usize, + SESSION_HEADER_MAX_INNER_WIDTH, + ); + // Top border without a title on the border + let mut top = String::with_capacity(inner_width + 2); + top.push('╭'); + top.push_str(&"─".repeat(inner_width)); + top.push('╮'); + out.push(Line::from(top)); + + // Title line rendered inside the box: " >_ OpenAI Codex (vX)" + let title_text = format!(" >_ OpenAI Codex (v{})", self.version); + let title_w = UnicodeWidthStr::width(title_text.as_str()); + let pad_w = inner_width.saturating_sub(title_w); + let mut title_spans: Vec> = vec![ + "│".into(), + " ".into(), + ">_ ".into(), + "OpenAI Codex".bold(), + " ".into(), + format!("(v{})", self.version).dim(), + ]; + if pad_w > 0 { + title_spans.push(" ".repeat(pad_w).into()); + } + title_spans.push("│".into()); + out.push(Line::from(title_spans)); + + // Spacer row between title and details + out.push(Line::from(vec![ + "│".into(), + " ".repeat(inner_width).into(), + "│".into(), + ])); + + // Model line: " Model: (change with /model)" + const CHANGE_MODEL_HINT: &str = "(change with /model)"; + let model_text = format!(" Model: {} {}", self.model, CHANGE_MODEL_HINT); + let model_w = UnicodeWidthStr::width(model_text.as_str()); + let pad_w = inner_width.saturating_sub(model_w); + let mut spans: Vec> = vec![ + "│".into(), + " ".into(), + "Model: ".bold(), + self.model.clone().into(), + " ".into(), + CHANGE_MODEL_HINT.dim(), + ]; + if pad_w > 0 { + spans.push(" ".repeat(pad_w).into()); + } + spans.push("│".into()); + out.push(Line::from(spans)); + + // Directory line: " Directory: " + let dir = self.format_directory(); + let dir_text = format!(" Directory: {dir}"); + let dir_w = UnicodeWidthStr::width(dir_text.as_str()); + let pad_w = inner_width.saturating_sub(dir_w); + let mut spans: Vec> = + vec!["│".into(), " ".into(), "Directory: ".bold(), dir.into()]; + if pad_w > 0 { + spans.push(" ".repeat(pad_w).into()); + } + spans.push("│".into()); + out.push(Line::from(spans)); + + // Bottom border + let bottom = format!("╰{}╯", "─".repeat(inner_width)); + out.push(Line::from(bottom)); + + out + } +} + +#[derive(Debug)] +pub(crate) struct CompositeHistoryCell { + parts: Vec>, +} + +impl HistoryCell for CompositeHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.display_lines(width); + if !lines.is_empty() { + if !first { + out.push(Line::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } +} + fn spinner(start_time: Option) -> Span<'static> { const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let idx = start_time