Render the initial TUI session header before input (#38639)

## What changed

- Drain queued app events until the initial session header is installed before
  handling terminal input.
- Render the active session header without a leading separator so it starts at
  the top of the viewport.

## Testing

- Add a rendering snapshot that verifies the initial header begins on the first
  viewport row.

GitOrigin-RevId: 070417776b629c681b3f94e93ba619dfefe764b1
This commit is contained in:
Charlie Marsh
2026-08-14 20:19:42 +00:00
committed by copyberry
parent ccee70f813
commit 7e65e4f330
3 changed files with 57 additions and 3 deletions
+16 -2
View File
@@ -1181,6 +1181,7 @@ See the Codex keymap documentation for supported actions and examples."
let mut listen_for_app_server_events = true;
let mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
let mut waiting_for_initial_session_header = true;
#[cfg(not(debug_assertions))]
let pre_loop_exit_reason = if let Some(latest_version) = upgrade_version {
@@ -1207,10 +1208,23 @@ See the Codex keymap documentation for supported actions and examples."
Ok(exit_reason)
} else {
loop {
let initial_session_header_pending = waiting_for_initial_session_header
&& app.primary_session_configured.is_some()
&& !app_event_rx.is_empty();
let control = select! {
Some(event) = app_event_rx.recv() => {
let is_initial_session_header = matches!(
&event,
AppEvent::InsertHistoryCell(cell)
if cell.as_any().is::<history_cell::SessionInfoCell>()
);
match Box::pin(app.handle_event(tui, &mut app_server, event)).await {
Ok(control) => control,
Ok(control) => {
if is_initial_session_header {
waiting_for_initial_session_header = false;
}
control
}
Err(err) => break Err(err),
}
}
@@ -1233,7 +1247,7 @@ See the Codex keymap documentation for supported actions and examples."
}
AppRunControl::Continue
}
event = tui_events.next() => {
event = tui_events.next(), if !initial_session_header_pending => {
if let Some(event) = event {
match app.handle_tui_event(tui, &mut app_server, event).await {
Ok(control) => control,
+6 -1
View File
@@ -11,7 +11,12 @@ impl ChatWidget {
let active_cell_renderable = match &self.transcript.active_cell {
Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell.as_ref(),
top: 1,
// The initial header becomes the first history cell, which has no leading separator.
top: if cell.as_any().is::<history_cell::SessionHeaderHistoryCell>() {
0
} else {
1
},
right: active_cell_right_reserve,
// Externally backed transcript cells can also change viewport height without an
// active-cell revision. Spinner cells remain safe because their indicator width
@@ -75,6 +75,41 @@ fn contains_text(buffer: &Buffer, text: &str) -> bool {
})
}
#[tokio::test]
async fn initial_session_header_starts_at_the_top_of_the_viewport() {
let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await;
widget.transcript.active_cell =
Some(ChatWidget::placeholder_session_header_cell(&widget.config));
let frame = render_frame(&widget, /*width*/ 48);
let header = frame
.content
.chunks(usize::from(frame.area.width))
.take(/*n*/ 6)
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>");
let cwd = widget.config.cwd.as_path().display().to_string();
let normalized_cwd = format!("{:<width$}", "/tmp/project", width = cwd.len());
insta::assert_snapshot!(header.replace(&cwd, &normalized_cwd), @r"
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: /tmp/project │
╰───────────────────────────────────────╯
");
}
#[tokio::test]
async fn active_cell_layout_reuses_heights_without_freezing_animation() {
let (widget, desired_height_calls, display_lines_calls) = widget_with_counting_cell(