Add session header to chat widget (#3592)

<img width="570" height="332" alt="image"
src="https://github.com/user-attachments/assets/ca6dfcb0-f3a1-4b3e-978d-4f844ba77527"
/>
This commit is contained in:
Ahmed Ibrahim
2025-09-14 20:53:50 -04:00
committed by GitHub
parent c47febf221
commit ce984b2c71
8 changed files with 227 additions and 63 deletions

View File

@@ -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;

View File

@@ -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<ExecCell>,
config: Config,
session_header: SessionHeader,
initial_user_message: Option<UserMessage>,
token_info: Option<TokenUsageInfo>,
// 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,
@@ -1282,8 +1298,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<String>) {
@@ -1404,14 +1421,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

View File

@@ -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();
}
}
}

View File

@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 728
expression: terminal.backend()
---
" "

View File

@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 794
expression: terminal.backend()
---
" "

View File

@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 921
expression: terminal.backend()
---
" "

View File

@@ -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.

View File

@@ -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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>> {
let mut out: Vec<Line<'static>> = 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<Span<'static>> = 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: <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<Span<'static>> = 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: <cwd>"
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<Span<'static>> =
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<Box<dyn HistoryCell>>,
}
impl HistoryCell for CompositeHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = 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<Instant>) -> Span<'static> {
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let idx = start_time