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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
16
codex-rs/tui/src/chatwidget/session_header.rs
Normal file
16
codex-rs/tui/src/chatwidget/session_header.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 728
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 794
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 921
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user