Compare commits

...

9 Commits

Author SHA1 Message Date
Roy Han
6b560a46be clippy 2026-02-26 21:26:51 -08:00
Roy Han
83726aebe6 cleanup 2026-02-26 21:14:43 -08:00
Roy Han
dda7973531 cleanup 2026-02-26 21:08:33 -08:00
Roy Han
d927cea570 cleanup 2026-02-26 20:59:07 -08:00
Roy Han
bee23c7917 cleanup 2026-02-26 20:53:36 -08:00
Roy Han
0ed71a0c3b cleanup 2026-02-26 20:50:51 -08:00
Roy Han
e89f442a57 cleanup 2026-02-26 20:45:23 -08:00
Roy Han
311bc6660d cleanup 2026-02-26 20:41:16 -08:00
Roy Han
c800db5cd5 working draft 2026-02-26 19:36:13 -08:00
8 changed files with 197 additions and 198 deletions

View File

@@ -79,6 +79,8 @@ use color_eyre::eyre::WrapErr;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
@@ -712,6 +714,41 @@ impl App {
.add_info_message(format!("Opened {url} in your browser."), None);
}
fn insert_history_cell(&mut self, tui: &mut tui::Tui, cell: Arc<dyn HistoryCell>) {
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
if !display.is_empty() {
if !cell.is_stream_continuation() {
if self.has_emitted_history_lines {
display.insert(0, Line::from(""));
} else {
self.has_emitted_history_lines = true;
}
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {
tui.insert_history_lines(display);
}
}
}
fn insert_startup_header(&mut self, tui: &mut tui::Tui) {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
let header = Arc::new(history_cell::SessionHeaderHistoryCell::new_with_style(
"loading".to_string(),
placeholder_style,
None,
self.config.cwd.clone(),
CODEX_CLI_VERSION,
)) as Arc<dyn HistoryCell>;
self.insert_history_cell(tui, header);
}
fn clear_ui_header_lines_with_version(
&self,
width: u16,
@@ -1125,6 +1162,7 @@ impl App {
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.reset_thread_event_state();
self.insert_startup_header(tui);
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
@@ -1237,7 +1275,8 @@ impl App {
use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
emit_project_config_warnings(&app_event_tx, &config);
let should_insert_startup_header =
matches!(&session_selection, SessionSelection::StartFresh);
tui.set_notification_method(config.tui_notification_method);
let harness_overrides =
@@ -1405,6 +1444,7 @@ impl App {
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
emit_project_config_warnings(&app_event_tx, &config);
let mut app = Self {
server: thread_manager.clone(),
@@ -1443,6 +1483,10 @@ impl App {
pending_primary_events: VecDeque::new(),
};
if should_insert_startup_header {
app.insert_startup_header(tui);
}
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
#[cfg(target_os = "windows")]
{
@@ -1793,29 +1837,7 @@ impl App {
}
AppEvent::InsertHistoryCell(cell) => {
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
if !display.is_empty() {
// Only insert a separating blank line for new cells that are not
// part of an ongoing stream. Streaming continuations should not
// accrue extra blank lines between chunks.
if !cell.is_stream_continuation() {
if self.has_emitted_history_lines {
display.insert(0, Line::from(""));
} else {
self.has_emitted_history_lines = true;
}
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {
tui.insert_history_lines(display);
}
}
self.insert_history_cell(tui, cell);
}
AppEvent::ApplyThreadRollback { num_turns } => {
if self.apply_non_pending_thread_rollback(num_turns) {
@@ -2906,6 +2928,30 @@ impl App {
// thread, so unrelated shutdowns cannot consume this marker.
self.pending_shutdown_exit_thread_id = None;
}
if let EventMsg::SessionConfigured(session) = &event.msg
&& let Some(first_cell) = self.transcript_cells.first_mut()
&& matches!(
first_cell
.as_ref()
.as_any()
.downcast_ref::<history_cell::SessionHeaderHistoryCell>(),
Some(startup_header) if startup_header.is_loading_placeholder()
)
{
let cell = Arc::new(history_cell::SessionHeaderHistoryCell::new(
session.model.clone(),
session.reasoning_effort,
session.cwd.clone(),
CODEX_CLI_VERSION,
)) as Arc<dyn HistoryCell>;
*first_cell = cell.clone();
if matches!(&self.overlay, Some(Overlay::Transcript(_))) {
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
}
let display = cell.display_lines(tui.terminal.last_known_screen_size.width);
tui.replace_top_visible_history_lines(display)?;
}
self.handle_codex_event_now(event);
if self.backtrack_render_pending {
tui.frame_requester().schedule_frame();
@@ -3244,8 +3290,8 @@ mod tests {
use crate::file_search::FileSearchManager;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::SessionHeaderHistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_session_info;
use codex_core::CodexAuth;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
@@ -3609,7 +3655,7 @@ mod tests {
true,
)) as Arc<dyn HistoryCell>
};
let make_header = |is_first| -> Arc<dyn HistoryCell> {
let make_header = |_is_first| -> Arc<dyn HistoryCell> {
let event = SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
@@ -3626,12 +3672,11 @@ mod tests {
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
app.chat_widget.current_model(),
event,
is_first,
None,
Arc::new(SessionHeaderHistoryCell::new(
event.model,
event.reasoning_effort,
event.cwd,
crate::version::CODEX_CLI_VERSION,
)) as Arc<dyn HistoryCell>
};
@@ -4106,7 +4151,7 @@ mod tests {
)) as Arc<dyn HistoryCell>
};
let make_header = |is_first| {
let make_header = |_is_first| {
let event = SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
@@ -4123,12 +4168,11 @@ mod tests {
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
app.chat_widget.current_model(),
event,
is_first,
None,
Arc::new(SessionHeaderHistoryCell::new(
event.model,
event.reasoning_effort,
event.cwd,
crate::version::CODEX_CLI_VERSION,
)) as Arc<dyn HistoryCell>
};

View File

@@ -29,7 +29,7 @@ use std::sync::Arc;
use crate::app::App;
use crate::app_event::AppEvent;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::SessionHeaderHistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::pager_overlay::Overlay;
use crate::tui;
@@ -639,7 +639,7 @@ fn nth_user_position(
fn user_positions_iter(
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
) -> impl Iterator<Item = usize> + '_ {
let session_start_type = TypeId::of::<SessionInfoCell>();
let session_start_type = TypeId::of::<SessionHeaderHistoryCell>();
let user_type = TypeId::of::<UserHistoryCell>();
let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();

View File

@@ -143,8 +143,6 @@ use rand::Rng;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
@@ -254,8 +252,6 @@ mod agent;
use self::agent::spawn_agent;
use self::agent::spawn_agent_from_existing;
pub(crate) use self::agent::spawn_op_forwarder;
mod session_header;
use self::session_header::SessionHeader;
mod skills;
use self::skills::collect_tool_mentions;
use self::skills::find_app_mentions;
@@ -544,7 +540,6 @@ pub(crate) struct ChatWidget {
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
otel_manager: OtelManager,
session_header: SessionHeader,
initial_user_message: Option<UserMessage>,
token_info: Option<TokenUsageInfo>,
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
@@ -1129,7 +1124,6 @@ impl ChatWidget {
self.last_copyable_output = None;
let forked_from_id = event.forked_from_id;
let model_for_header = event.model.clone();
self.session_header.set_model(&model_for_header);
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
Some(model_for_header.clone()),
Some(event.reasoning_effort),
@@ -1137,16 +1131,17 @@ impl ChatWidget {
);
self.refresh_model_display();
self.sync_personality_command_enabled();
let session_info_cell = history_cell::new_session_info(
if let Some(session_info_body) = history_cell::new_session_info_body(
&self.config,
&model_for_header,
event,
&event,
self.show_welcome_banner,
self.auth_manager
.auth_cached()
.and_then(|auth| auth.account_plan_type()),
);
self.apply_session_info_cell(session_info_cell);
) {
self.add_boxed_history(session_info_body);
}
if let Some(messages) = initial_messages {
self.replay_initial_messages(messages);
@@ -2769,7 +2764,7 @@ impl ChatWidget {
.and_then(|mask| mask.model.clone())
.unwrap_or_else(|| model_for_header.clone());
let fallback_default = Settings {
model: header_model.clone(),
model: header_model,
reasoning_effort: None,
developer_instructions: None,
};
@@ -2779,7 +2774,7 @@ impl ChatWidget {
settings: fallback_default,
};
let active_cell = Some(Self::placeholder_session_header_cell(&config));
let active_cell = None;
let current_cwd = Some(config.cwd.clone());
let queued_message_edit_binding =
@@ -2808,7 +2803,6 @@ impl ChatWidget {
auth_manager,
models_manager,
otel_manager,
session_header: SessionHeader::new(header_model),
initial_user_message,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
@@ -2946,7 +2940,7 @@ impl ChatWidget {
.and_then(|mask| mask.model.clone())
.unwrap_or_else(|| model_for_header.clone());
let fallback_default = Settings {
model: header_model.clone(),
model: header_model,
reasoning_effort: None,
developer_instructions: None,
};
@@ -2956,7 +2950,7 @@ impl ChatWidget {
settings: fallback_default,
};
let active_cell = Some(Self::placeholder_session_header_cell(&config));
let active_cell = None;
let current_cwd = Some(config.cwd.clone());
let queued_message_edit_binding =
@@ -2985,7 +2979,6 @@ impl ChatWidget {
auth_manager,
models_manager,
otel_manager,
session_header: SessionHeader::new(header_model),
initial_user_message,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
@@ -3115,7 +3108,7 @@ impl ChatWidget {
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
let fallback_default = Settings {
model: header_model.clone(),
model: header_model,
reasoning_effort: None,
developer_instructions: None,
};
@@ -3151,7 +3144,6 @@ impl ChatWidget {
auth_manager,
models_manager,
otel_manager,
session_header: SessionHeader::new(header_model),
initial_user_message,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
@@ -3948,15 +3940,7 @@ impl ChatWidget {
}
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
// Keep the placeholder session header as the active cell until real session info arrives,
// so we can merge headers instead of committing a duplicate box to history.
let keep_placeholder_header_active = !self.is_session_configured()
&& self
.active_cell
.as_ref()
.is_some_and(|c| c.as_any().is::<history_cell::SessionHeaderHistoryCell>());
if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() {
if !cell.display_lines(u16::MAX).is_empty() {
// Only break exec grouping if the cell renders visible lines.
self.flush_active_cell();
self.needs_final_message_separator = true;
@@ -6759,8 +6743,6 @@ impl ChatWidget {
}
fn refresh_model_display(&mut self) {
let effective = self.effective_collaboration_mode();
self.session_header.set_model(effective.model());
// Keep composer paste affordances aligned with the currently effective model.
self.sync_image_paste_enabled();
}
@@ -6891,46 +6873,6 @@ impl ChatWidget {
}
}
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
Box::new(history_cell::SessionHeaderHistoryCell::new_with_style(
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
placeholder_style,
None,
config.cwd.clone(),
CODEX_CLI_VERSION,
))
}
/// Merge the real session info cell with any placeholder header to avoid double boxes.
fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) {
let mut session_info_cell = Some(Box::new(cell) as Box<dyn HistoryCell>);
let merged_header = if let Some(active) = self.active_cell.take() {
if active
.as_any()
.is::<history_cell::SessionHeaderHistoryCell>()
{
// Reuse the existing placeholder header to avoid rendering two boxes.
if let Some(cell) = session_info_cell.take() {
self.active_cell = Some(cell);
}
true
} else {
self.active_cell = Some(active);
false
}
} else {
false
};
self.flush_active_cell();
if !merged_header && let Some(cell) = session_info_cell {
self.add_boxed_history(cell);
}
}
pub(crate) fn add_info_message(&mut self, message: String, hint: Option<String>) {
self.add_to_history(history_cell::new_info_event(message, hint));
self.request_redraw();

View File

@@ -1,16 +0,0 @@
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

@@ -1657,7 +1657,6 @@ async fn make_chatwidget_manual(
auth_manager,
models_manager,
otel_manager,
session_header: SessionHeader::new(resolved_model.clone()),
initial_user_message: None,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),

View File

@@ -1020,46 +1020,32 @@ impl HistoryCell for TooltipHistoryCell {
}
}
#[derive(Debug)]
pub struct SessionInfoCell(CompositeHistoryCell);
pub(crate) fn new_session_info_body(
config: &Config,
requested_model: &str,
event: &SessionConfiguredEvent,
is_first_event: bool,
auth_plan: Option<PlanType>,
) -> Option<Box<dyn HistoryCell>> {
let parts = session_info_body_parts(config, requested_model, event, is_first_event, auth_plan);
impl HistoryCell for SessionInfoCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.display_lines(width)
}
fn desired_height(&self, width: u16) -> u16 {
self.0.desired_height(width)
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.transcript_lines(width)
match parts.len() {
0 => None,
1 => parts.into_iter().next(),
_ => Some(Box::new(CompositeHistoryCell::new(parts))),
}
}
pub(crate) fn new_session_info(
fn session_info_body_parts(
config: &Config,
requested_model: &str,
event: SessionConfiguredEvent,
event: &SessionConfiguredEvent,
is_first_event: bool,
auth_plan: Option<PlanType>,
) -> SessionInfoCell {
let SessionConfiguredEvent {
model,
reasoning_effort,
..
} = event;
// Header box rendered as history (so it appears at the very top)
let header = SessionHeaderHistoryCell::new(
model.clone(),
reasoning_effort,
config.cwd.clone(),
CODEX_CLI_VERSION,
);
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
) -> Vec<Box<dyn HistoryCell>> {
let mut parts: Vec<Box<dyn HistoryCell>> = Vec::new();
if is_first_event {
// Help lines below the header (new copy and list)
let help_lines: Vec<Line<'static>> = vec![
" To get started, describe a task or try one of these commands:"
.dim()
@@ -1091,7 +1077,6 @@ pub(crate) fn new_session_info(
" - review any changes and find issues".dim(),
]),
];
parts.push(Box::new(PlainHistoryCell { lines: help_lines }));
} else {
if config.show_tooltips
@@ -1099,17 +1084,17 @@ pub(crate) fn new_session_info(
{
parts.push(Box::new(tooltips));
}
if requested_model != model {
if requested_model != event.model {
let lines = vec![
"model changed:".magenta().bold().into(),
format!("requested: {requested_model}").into(),
format!("used: {model}").into(),
format!("used: {}", event.model).into(),
];
parts.push(Box::new(PlainHistoryCell { lines }));
}
}
SessionInfoCell(CompositeHistoryCell { parts })
parts
}
pub(crate) fn new_user_prompt(
@@ -1204,6 +1189,10 @@ impl SessionHeaderHistoryCell {
ReasoningEffortConfig::None => "none",
})
}
pub(crate) fn is_loading_placeholder(&self) -> bool {
self.model == "loading"
}
}
impl HistoryCell for SessionHeaderHistoryCell {

View File

@@ -126,43 +126,7 @@ where
for line in wrapped {
queue!(writer, Print("\r\n"))?;
// URL lines can be wider than the terminal and will
// character-wrap onto continuation rows. Pre-clear those rows
// so stale content from a previously longer line is erased.
let physical_rows = line.width().max(1).div_ceil(wrap_width);
if physical_rows > 1 {
queue!(writer, SavePosition)?;
for _ in 1..physical_rows {
queue!(writer, MoveDown(1), MoveToColumn(0))?;
queue!(writer, Clear(ClearType::UntilNewLine))?;
}
queue!(writer, RestorePosition)?;
}
queue!(
writer,
SetColors(Colors::new(
line.style
.fg
.map(std::convert::Into::into)
.unwrap_or(CColor::Reset),
line.style
.bg
.map(std::convert::Into::into)
.unwrap_or(CColor::Reset)
))
)?;
queue!(writer, Clear(ClearType::UntilNewLine))?;
// Merge line-level style into each span so that ANSI colors reflect
// line styles (e.g., blockquotes with green fg).
let merged_spans: Vec<Span> = line
.spans
.iter()
.map(|s| Span {
style: s.style.patch(line.style),
content: s.content.clone(),
})
.collect();
write_spans(writer, merged_spans.iter())?;
write_line(writer, &line, wrap_width)?;
}
queue!(writer, ResetScrollRegion)?;
@@ -181,6 +145,36 @@ where
Ok(())
}
pub fn replace_top_visible_history_lines<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line>,
) -> io::Result<()>
where
B: Backend + Write,
{
if lines.is_empty() {
return Ok(());
}
let top = terminal
.viewport_area
.top()
.saturating_sub(terminal.visible_history_rows());
let wrap_width = terminal.viewport_area.width.max(1) as usize;
let last_cursor_pos = terminal.last_known_cursor_pos;
let writer = terminal.backend_mut();
for (index, line) in lines.iter().enumerate() {
let y = top.saturating_add(index as u16);
queue!(writer, MoveTo(0, y))?;
write_line(writer, line, wrap_width)?;
}
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
std::io::Write::flush(writer)?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetScrollRegion(pub std::ops::Range<u16>);
@@ -329,6 +323,43 @@ where
)
}
fn write_line(mut writer: &mut impl Write, line: &Line<'_>, wrap_width: usize) -> io::Result<()> {
// URL lines can be wider than the terminal and will character-wrap onto continuation rows.
// Pre-clear those rows so stale content from a previously longer line is erased.
let physical_rows = line.width().max(1).div_ceil(wrap_width);
if physical_rows > 1 {
queue!(writer, SavePosition)?;
for _ in 1..physical_rows {
queue!(writer, MoveDown(1), MoveToColumn(0))?;
queue!(writer, Clear(ClearType::UntilNewLine))?;
}
queue!(writer, RestorePosition)?;
}
queue!(
writer,
SetColors(Colors::new(
line.style
.fg
.map(std::convert::Into::into)
.unwrap_or(CColor::Reset),
line.style
.bg
.map(std::convert::Into::into)
.unwrap_or(CColor::Reset)
))
)?;
queue!(writer, Clear(ClearType::UntilNewLine))?;
let merged_spans: Vec<Span> = line
.spans
.iter()
.map(|s| Span {
style: s.style.patch(line.style),
content: s.content.clone(),
})
.collect();
write_spans(&mut writer, merged_spans.iter())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -445,6 +445,16 @@ impl Tui {
self.frame_requester().schedule_frame();
}
pub fn replace_top_visible_history_lines(&mut self, lines: Vec<Line<'static>>) -> Result<()> {
let line_count = lines.len();
if self.terminal.visible_history_rows() >= line_count as u16 {
crate::insert_history::replace_top_visible_history_lines(&mut self.terminal, lines)?;
} else if self.pending_history_lines.len() >= line_count {
self.pending_history_lines.splice(0..line_count, lines);
}
Ok(())
}
pub fn clear_pending_history_lines(&mut self) {
self.pending_history_lines.clear();
}