mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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(_)
|
||||
|
||||
+87
@@ -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
|
||||
+26
@@ -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):
|
||||
╭───────────────────────────────────────╮
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user