Show resume and fork status during TUI startup (#38788)

## What changed

- Display a dimmed `Resuming session…` or `Forking session…` status above the provisional composer based on the requested session action.
- Update or clear the status after session selection resolves, while preserving the composer height and any startup draft text.

## Testing

- Add snapshot coverage for resume and fork loading states and transitions back to a fresh session.

GitOrigin-RevId: 9d37b2d0e390d28dee310dd7a5b9fa992db2c25c
This commit is contained in:
Charlie Marsh
2026-08-15 17:30:13 +00:00
committed by copyberry
parent 00f6a8a60e
commit a95a6fe333
7 changed files with 311 additions and 12 deletions
+5
View File
@@ -1413,6 +1413,11 @@ async fn run_ratatui_app(
resume_picker::SessionSelection::StartFresh
};
if let Err(err) = startup_draft.update_session_selection(&mut tui, &session_selection) {
shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await;
return Err(err.into());
}
if matches!(
&session_selection,
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_)
@@ -0,0 +1,87 @@
---
source: tui/src/startup_draft_tests.rs
expression: "snapshots.join(\"\\n---\\n\")"
---
resume lookup fallback:
before:
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
Resuming session…
› draft while loading
after:
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› draft while loading
---
fork lookup fallback:
before:
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
Forking session…
› draft while loading
after:
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› draft while loading
---
resume picker cancellation:
before:
hidden while picker owns input
after:
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› Ask Codex to do anything
? for shortcuts
---
fork picker cancellation:
before:
hidden while picker owns input
after:
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› Ask Codex to do anything
? for shortcuts
@@ -14,6 +14,32 @@ empty (48 columns):
› Ask Codex to do anything
? for shortcuts
---
resuming (48 columns):
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
Resuming session…
› Ask Codex to do anything
? for shortcuts
---
forking (48 columns):
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
Forking session…
› draft while loading
---
multiline (48 columns):
╭───────────────────────────────────────╮
+55 -3
View File
@@ -15,6 +15,7 @@ use crossterm::event::KeyModifiers;
use ratatui::layout::Size;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
use tokio_stream::Stream;
@@ -38,6 +39,7 @@ use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt;
use crate::render::renderable::RenderableItem;
use crate::resume_picker::SessionSelection;
use crate::tui;
use crate::tui::FrameRequester;
use crate::tui::Tui;
@@ -55,6 +57,14 @@ pub(crate) enum StartupDraftInitialScreen {
SessionPicker,
}
/// Describes the session being prepared behind the provisional composer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StartupDraftSessionAction {
New,
Resume,
Fork,
}
/// Marks intentional startup cancellation so unrelated I/O interrupts remain errors.
#[derive(Debug, thiserror::Error)]
#[error("startup cancelled")]
@@ -82,12 +92,16 @@ pub(crate) struct StartupDraftPump {
events: Pin<Box<dyn Stream<Item = TuiEvent> + Send>>,
app_event_rx: UnboundedReceiver<AppEvent>,
initial_screen: StartupDraftInitialScreen,
session_action: StartupDraftSessionAction,
pending_paste_newline: Option<(Instant, String)>,
}
impl StartupDraft {
/// Initialize the terminal without showing a composer before an expected session picker.
pub(crate) fn new(initial_screen: StartupDraftInitialScreen) -> io::Result<Self> {
pub(crate) fn new(
initial_screen: StartupDraftInitialScreen,
session_action: StartupDraftSessionAction,
) -> io::Result<Self> {
let mut initialized_terminal = tui::init()?;
let terminal_restore_guard = TerminalRestoreGuard::new();
initialized_terminal.terminal.clear()?;
@@ -113,6 +127,7 @@ impl StartupDraft {
events,
app_event_rx,
initial_screen,
session_action,
pending_paste_newline: None,
},
};
@@ -161,6 +176,27 @@ impl StartupDraftPump {
}
}
/// Align the provisional loading message with the resolved session selection.
pub(crate) fn update_session_selection(
&mut self,
tui: &mut Tui,
session_selection: &SessionSelection,
) -> io::Result<()> {
let session_action = match session_selection {
SessionSelection::StartFresh | SessionSelection::Exit => StartupDraftSessionAction::New,
SessionSelection::Resume(_) => StartupDraftSessionAction::Resume,
SessionSelection::Fork(_) => StartupDraftSessionAction::Fork,
};
if self.session_action == session_action {
return Ok(());
}
self.session_action = session_action;
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.draw(tui, tui.terminal.last_known_screen_size)?;
}
Ok(())
}
/// Poll one existing startup future alongside the original terminal input stream.
pub(crate) async fn run_until<F>(&mut self, tui: &mut Tui, future: F) -> io::Result<F::Output>
where
@@ -336,7 +372,8 @@ impl StartupDraftPump {
.schedule_frame_in(ChatComposer::recommended_paste_flush_delay());
}
self.bottom_pane.pre_draw_tick();
let renderable = startup_draft_renderable(&self.header, &self.bottom_pane);
let renderable =
startup_draft_renderable(&self.header, &self.bottom_pane, self.session_action);
let desired_height = renderable.desired_height(screen_size.width);
tui.draw_with_resize_reflow(desired_height, screen_size, |frame| {
let area = frame.area();
@@ -427,15 +464,30 @@ fn startup_session_header(config: Option<&Config>) -> Box<dyn HistoryCell> {
fn startup_draft_renderable<'a>(
header: &'a dyn Renderable,
bottom_pane: &'a BottomPane,
session_action: StartupDraftSessionAction,
) -> RenderableItem<'a> {
let mut renderable = FlexRenderable::new();
renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header));
let loading_message = match session_action {
StartupDraftSessionAction::New => None,
StartupDraftSessionAction::Resume => Some(" Resuming session…"),
StartupDraftSessionAction::Fork => Some(" Forking session…"),
};
if let Some(loading_message) = loading_message {
renderable.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(loading_message.dim())),
);
}
renderable.push(
/*flex*/ 0,
bottom_pane
.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0)
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
/*top*/ u16::from(loading_message.is_none()),
/*left*/ 0,
/*bottom*/ 0,
/*right*/ 0,
)),
);
RenderableItem::Owned(Box::new(renderable))
+129 -8
View File
@@ -9,6 +9,7 @@ use tokio::sync::mpsc::unbounded_channel;
use super::StartupDraftInitialScreen;
use super::StartupDraftPump;
use super::StartupDraftSessionAction;
use super::handle_startup_draft_key;
use super::startup_draft_bottom_pane;
use super::startup_draft_renderable;
@@ -16,6 +17,7 @@ use super::startup_session_header;
use crate::app_event_sender::AppEventSender;
use crate::legacy_core::config::ConfigBuilder;
use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
use crate::tui::FrameRequester;
use crate::tui::TuiEvent;
@@ -34,6 +36,7 @@ where
events: Box::pin(futures::stream::iter(events)),
app_event_rx: rx,
initial_screen: StartupDraftInitialScreen::Composer,
session_action: StartupDraftSessionAction::New,
pending_paste_newline: None,
}
}
@@ -43,14 +46,43 @@ fn startup_draft_renders_full_empty_and_multiline_composer_frames() {
let mut pump = startup_test_pump(std::iter::empty());
let mut snapshots = Vec::new();
for (label, width, text) in [
("empty", 48, ""),
("multiline", 48, "first startup line\nsecond startup line"),
("narrow", 18, "first startup line\nsecond startup line"),
for (label, width, text, session_action) in [
("empty", 48, "", StartupDraftSessionAction::New),
("resuming", 48, "", StartupDraftSessionAction::Resume),
(
"forking",
48,
"draft while loading",
StartupDraftSessionAction::Fork,
),
(
"multiline",
48,
"first startup line\nsecond startup line",
StartupDraftSessionAction::New,
),
(
"narrow",
18,
"first startup line\nsecond startup line",
StartupDraftSessionAction::New,
),
] {
pump.session_action = session_action;
pump.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane);
let renderable =
startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action);
assert_eq!(
renderable.desired_height(width),
startup_draft_renderable(
&pump.header,
&pump.bottom_pane,
StartupDraftSessionAction::New,
)
.desired_height(width),
"loading status should reuse the existing gap above the composer"
);
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
@@ -84,6 +116,93 @@ fn startup_draft_renders_full_empty_and_multiline_composer_frames() {
insta::assert_snapshot!("startup_draft_full_frames", snapshots.join("\n---\n"));
}
#[tokio::test]
async fn startup_draft_clears_loading_status_when_starting_fresh() {
let mut snapshots = Vec::new();
let render_frame = |pump: &StartupDraftPump| {
let width = 48;
let renderable =
startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action);
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
renderable.desired_height(width),
);
let mut buffer = Buffer::empty(area);
renderable.render(area, &mut buffer);
(0..area.height)
.map(|row| {
(0..area.width)
.map(|column| buffer[(column, row)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>")
};
for (label, initial_screen, session_action) in [
(
"resume lookup fallback",
StartupDraftInitialScreen::Composer,
StartupDraftSessionAction::Resume,
),
(
"fork lookup fallback",
StartupDraftInitialScreen::Composer,
StartupDraftSessionAction::Fork,
),
(
"resume picker cancellation",
StartupDraftInitialScreen::SessionPicker,
StartupDraftSessionAction::Resume,
),
(
"fork picker cancellation",
StartupDraftInitialScreen::SessionPicker,
StartupDraftSessionAction::Fork,
),
] {
let mut pump = startup_test_pump(std::iter::empty());
pump.initial_screen = initial_screen;
pump.session_action = session_action;
if initial_screen == StartupDraftInitialScreen::Composer {
pump.bottom_pane.insert_str("draft while loading");
}
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.show_initial_screen(&mut tui)
.expect("respect the initial composer or picker screen");
let before = if tui.terminal.viewport_area.is_empty() {
"hidden while picker owns input".to_string()
} else {
render_frame(&pump)
};
pump.update_session_selection(&mut tui, &SessionSelection::StartFresh)
.expect("clear the loading status after a fresh-session selection");
if initial_screen == StartupDraftInitialScreen::SessionPicker {
assert!(tui.terminal.viewport_area.is_empty());
pump.show(&mut tui)
.expect("reveal the fresh-session composer after the picker");
}
let after = render_frame(&pump);
assert!(!after.contains("Resuming session"));
assert!(!after.contains("Forking session"));
if initial_screen == StartupDraftInitialScreen::Composer {
assert_eq!(pump.bottom_pane.composer_text(), "draft while loading");
}
snapshots.push(format!("{label}:\nbefore:\n{before}\nafter:\n{after}"));
}
insta::assert_snapshot!(
"startup_draft_fresh_session_transitions",
snapshots.join("\n---\n")
);
}
#[tokio::test]
async fn startup_draft_hydrates_its_header_without_moving_the_composer() {
let codex_home = tempfile::tempdir().expect("create temporary Codex home");
@@ -95,7 +214,8 @@ async fn startup_draft_hydrates_its_header_without_moving_the_composer() {
let mut pump = startup_test_pump(std::iter::empty());
let width = 80;
let initial_height =
startup_draft_renderable(&pump.header, &pump.bottom_pane).desired_height(width);
startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action)
.desired_height(width);
assert_eq!(
pump.header.raw_lines().last().map(ToString::to_string),
@@ -114,7 +234,8 @@ async fn startup_draft_hydrates_its_header_without_moving_the_composer() {
Some(expected_directory)
);
assert_eq!(
startup_draft_renderable(&pump.header, &pump.bottom_pane).desired_height(width),
startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action)
.desired_height(width),
initial_height
);
}
@@ -558,7 +679,7 @@ async fn startup_draft_waits_for_onboarding_before_accepting_input() {
.expect("show the composer after onboarding finishes");
assert!(!tui.terminal.viewport_area.is_empty());
let area = tui.terminal.viewport_area;
let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane);
let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action);
let mut buffer = Buffer::empty(area);
renderable.render(area, &mut buffer);
let visible_frame = (area.top()..area.bottom())
+8 -1
View File
@@ -165,7 +165,14 @@ pub(super) async fn run_main_inner(
} else {
startup_draft::StartupDraftInitialScreen::Composer
};
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?;
let session_action = if cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some() {
startup_draft::StartupDraftSessionAction::Fork
} else if cli.resume_picker || cli.resume_last || cli.resume_session_id.is_some() {
startup_draft::StartupDraftSessionAction::Resume
} else {
startup_draft::StartupDraftSessionAction::New
};
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen, session_action)?;
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
startup_draft
+1
View File
@@ -202,6 +202,7 @@ async fn startup_typeahead_pty_child() {
let signals = PathBuf::from(signals);
let mut startup_draft = crate::startup_draft::StartupDraft::new(
crate::startup_draft::StartupDraftInitialScreen::Composer,
crate::startup_draft::StartupDraftSessionAction::New,
)
.expect("initialize the real startup composer");