mirror of
https://github.com/openai/codex.git
synced 2026-10-21 05:22:21 +00:00
Refresh the TUI welcome screen for new sessions (#48513)
- Show a compact session header with a randomly selected greeting, preserving the greeting and blossom state from startup into the live session.
- Center a settled blossom in unused fullscreen space and replay its animation on click. Hide it while typing or when space is insufficient, and respect `tui.animations` and `tui.effects.welcome`.
- Hide startup tips in the fullscreen transcript while retaining them in terminal scrollback, and prevent hidden entries from shifting the first visible header.
- Keep the composer visible and responsive during daemon startup, routing startup diagnostics through tracing while direct lifecycle commands retain stderr output.
Add regression tests and update snapshots for blossom placement and click replay, welcome opt-outs, greeting stability, startup transitions, tip visibility, and transcript spacing.
GitOrigin-RevId: ec7b082fbe25ced1180cad3348f5c76e48c0e92b
(cherry picked from commit 7f6c0f9387)
This commit is contained in:
committed by
Ian MacLeod
parent
ef3e89b9a6
commit
d213a29c57
@@ -11,11 +11,13 @@ use std::collections::BTreeMap;
|
||||
/// Start a missing daemon with these features, preserving other saved overrides,
|
||||
/// or leave a running daemon unchanged.
|
||||
/// Callers must check the running server's configuration before using it.
|
||||
/// Startup diagnostics go to tracing, so a caller with a live TUI can keep rendering.
|
||||
pub async fn start_with_features(features: &BTreeMap<String, bool>) -> Result<LifecycleOutput> {
|
||||
ensure_supported_platform()?;
|
||||
#[cfg(windows)]
|
||||
crate::backend::windows::ensure_not_elevated()?;
|
||||
let daemon = Daemon::from_environment()?;
|
||||
let mut daemon = Daemon::from_environment()?;
|
||||
daemon.log_diagnostics = true;
|
||||
let _operation_lock = daemon.acquire_operation_lock().await?;
|
||||
let selected = daemon.current_installation()?;
|
||||
let mut overrides = selected.load_settings().await?.feature_overrides;
|
||||
|
||||
@@ -306,6 +306,9 @@ fn ensure_supported_platform() -> Result<()> {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Daemon {
|
||||
// Feature-aware TUI startup owns a live terminal. Direct lifecycle commands
|
||||
// must still report their diagnostics to stderr.
|
||||
log_diagnostics: bool,
|
||||
socket_path: PathBuf,
|
||||
pid_file: PathBuf,
|
||||
update_pid_file: PathBuf,
|
||||
@@ -330,6 +333,7 @@ impl Daemon {
|
||||
(DAEMON_PID_FILE_NAME, DAEMON_UPDATE_PID_FILE_NAME)
|
||||
};
|
||||
Ok(Self {
|
||||
log_diagnostics: false,
|
||||
socket_path,
|
||||
pid_file: state_dir.join(pid_file),
|
||||
update_pid_file: state_dir.join(update_pid_file),
|
||||
@@ -339,6 +343,14 @@ impl Daemon {
|
||||
})
|
||||
}
|
||||
|
||||
fn diagnostic(&self, message: std::fmt::Arguments<'_>) {
|
||||
if self.log_diagnostics {
|
||||
tracing::info!("{message}");
|
||||
} else {
|
||||
eprintln!("{message}");
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_file(&self) -> Result<PathBuf> {
|
||||
Ok(codex_app_server_transport::daemon_recovery_file_path(
|
||||
self.settings_file
|
||||
@@ -411,7 +423,9 @@ impl Daemon {
|
||||
} else {
|
||||
// A fresh start must ignore snapshots left by older stop clients.
|
||||
if let Err(err) = thread_recovery::discard_pending(self) {
|
||||
eprintln!("warning: failed to clear stale daemon recovery before start: {err}");
|
||||
self.diagnostic(format_args!(
|
||||
"warning: failed to clear stale daemon recovery before start: {err}"
|
||||
));
|
||||
}
|
||||
prepare_install::prepare(self, &settings).await?;
|
||||
managed.managed_codex_bin = self.current_managed_codex_bin()?;
|
||||
@@ -433,7 +447,9 @@ impl Daemon {
|
||||
if backend.is_some()
|
||||
&& let Err(err) = managed.ensure_managed_updater(&settings).await
|
||||
{
|
||||
eprintln!("warning: failed to ensure managed updater after app-server start: {err:#}");
|
||||
self.diagnostic(format_args!(
|
||||
"warning: failed to ensure managed updater after app-server start: {err:#}"
|
||||
));
|
||||
}
|
||||
Ok(managed
|
||||
.output(status, backend, pid, Some(info.app_server_version))
|
||||
@@ -1252,6 +1268,7 @@ mod tests {
|
||||
let legacy = home.path().join("packages/standalone/current");
|
||||
std::fs::create_dir_all(&legacy).expect("legacy selection");
|
||||
let daemon = Daemon {
|
||||
log_diagnostics: false,
|
||||
socket_path: home.path().join("server.sock"),
|
||||
pid_file: state.join(super::LEGACY_PID_FILE_NAME),
|
||||
update_pid_file: state.join(super::LEGACY_UPDATE_PID_FILE_NAME),
|
||||
@@ -1285,6 +1302,7 @@ mod tests {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let state = temp.path().join("missing-home").join("daemon-state");
|
||||
let daemon = Daemon {
|
||||
log_diagnostics: false,
|
||||
socket_path: state.join("server.sock"),
|
||||
pid_file: state.join("server.pid"),
|
||||
update_pid_file: state.join("updater.pid"),
|
||||
@@ -1310,6 +1328,7 @@ mod tests {
|
||||
.await
|
||||
.expect("private state directory");
|
||||
let daemon = Daemon {
|
||||
log_diagnostics: false,
|
||||
socket_path: home.path().join("server.sock"),
|
||||
pid_file: state.join("server.pid"),
|
||||
update_pid_file: state.join("updater.pid"),
|
||||
@@ -1364,6 +1383,7 @@ mod tests {
|
||||
.expect("current local build");
|
||||
let state = home.path().join("app-server-daemon");
|
||||
let daemon = Daemon {
|
||||
log_diagnostics: false,
|
||||
socket_path: home
|
||||
.path()
|
||||
.join("app-server-control/app-server-control.sock"),
|
||||
@@ -1391,6 +1411,7 @@ mod tests {
|
||||
async fn not_ready_context_reports_daemon_app_server_before_stderr() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let daemon = Daemon {
|
||||
log_diagnostics: false,
|
||||
socket_path: temp_dir.path().join("app-server-control.sock"),
|
||||
pid_file: temp_dir.path().join("app-server.pid"),
|
||||
update_pid_file: temp_dir.path().join("app-server-updater.pid"),
|
||||
|
||||
@@ -184,10 +184,10 @@ async fn prepare_from_package(
|
||||
return Ok(false);
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
daemon.diagnostic(format_args!(
|
||||
"Installing daemon from CLI version {version} into {}...",
|
||||
root.display()
|
||||
);
|
||||
));
|
||||
}
|
||||
// Confirmation must not block lifecycle commands. Recheck the approved
|
||||
// selection and running state once this operation owns both locks.
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::path::PathBuf;
|
||||
fn daemon(home: &std::path::Path) -> crate::Daemon {
|
||||
let state = home.join("app-server-daemon");
|
||||
crate::Daemon {
|
||||
log_diagnostics: false,
|
||||
socket_path: state.join("app-server.sock"),
|
||||
pid_file: state.join("app-server.pid"),
|
||||
update_pid_file: state.join("app-server-updater.pid"),
|
||||
|
||||
@@ -301,6 +301,7 @@ fn manual_update_daemon(home: &TempDir) -> (Daemon, String) {
|
||||
std::fs::write(state.join("app-server.stderr.log"), b"").unwrap();
|
||||
(
|
||||
Daemon {
|
||||
log_diagnostics: false,
|
||||
socket_path: home.path().join("app-server-control/server.sock"),
|
||||
pid_file: state.join("app-server.pid"),
|
||||
update_pid_file: state.join("app-server-updater.pid"),
|
||||
|
||||
@@ -74,6 +74,10 @@ impl App {
|
||||
.await;
|
||||
if self.current_displayed_thread_id() != previous_thread {
|
||||
draft.flush_pending_paste_newline(tui).await?;
|
||||
self.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.continue_from(&mut draft.blossom.borrow_mut());
|
||||
self.chat_widget.restore_startup_draft(draft.take_draft());
|
||||
} else {
|
||||
// Retain edits if setup fails so retrying `n` does not lose the draft.
|
||||
|
||||
@@ -5,18 +5,12 @@ use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
|
||||
fn animation_is_eligible(app: &App) -> bool {
|
||||
app.empty_state_presentation(
|
||||
crate::motion::MotionMode::from_animations_enabled(
|
||||
app.local_settings.tui.animations && app.local_settings.tui.effects.welcome,
|
||||
),
|
||||
/*focused*/ true,
|
||||
) == crate::empty_state_animation::Presentation::Animated
|
||||
&& app
|
||||
.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow()
|
||||
.is_eligible_for_test()
|
||||
fn has_blossom(tui: &tui::Tui) -> bool {
|
||||
text(crate::custom_terminal::test_support::last_rendered_buffer(
|
||||
&tui.terminal,
|
||||
))
|
||||
.chars()
|
||||
.any(|ch| ('\u{2801}'..='\u{28ff}').contains(&ch))
|
||||
}
|
||||
|
||||
fn text(buffer: &Buffer) -> String {
|
||||
@@ -56,37 +50,37 @@ async fn fresh_logo_returns_only_to_the_ordinary_composer() -> Result<()> {
|
||||
.start_fresh();
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
let cursor = tui.terminal.last_known_cursor_pos;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
|
||||
app.open_transcript_overlay(&mut tui);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(!animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
|
||||
app.close_transcript_overlay(&mut tui);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
|
||||
app.transcript_view.begin_search();
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(!animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
app.transcript_view.handle_key(
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
&app.transcript_cells,
|
||||
);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
|
||||
for enabled in [true, false] {
|
||||
app.apply_raw_output_mode(&mut tui, enabled, /*notify*/ false);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert_eq!(animation_is_eligible(&app), !enabled);
|
||||
assert_eq!(has_blossom(&tui), !enabled);
|
||||
}
|
||||
app.chat_widget.apply_external_edit(String::new());
|
||||
for (key, visible) in [(KeyCode::Char('?'), false), (KeyCode::Right, true)] {
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert_eq!(animation_is_eligible(&app), visible);
|
||||
assert_eq!(has_blossom(&tui), visible);
|
||||
}
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
@@ -96,7 +90,8 @@ async fn fresh_logo_returns_only_to_the_ordinary_composer() -> Result<()> {
|
||||
async fn non_startup_history_dismisses_logo_until_a_new_thread() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.local_settings.tui.animations = true;
|
||||
let size = Size::new(/*width*/ 120, /*height*/ 44);
|
||||
// Leave room for the full release-note card above the centered logo.
|
||||
let size = Size::new(/*width*/ 120, /*height*/ 64);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
app.chat_widget
|
||||
@@ -142,7 +137,7 @@ async fn non_startup_history_dismisses_logo_until_a_new_thread() -> Result<()> {
|
||||
)),
|
||||
);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
|
||||
crate::chatwidget::tests::set_chatgpt_auth(&mut app.chat_widget);
|
||||
let request = app.chat_widget.start_rate_limit_reset_startup_check();
|
||||
@@ -157,10 +152,10 @@ async fn non_startup_history_dismisses_logo_until_a_new_thread() -> Result<()> {
|
||||
// The same startup notice stays eligible both pending and committed to history.
|
||||
assert!(app.chat_widget.empty_state_composer().is_some());
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
app.insert_pending_usage_output_if_ready(&mut tui);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
|
||||
// Even invisible non-startup content ends eligibility before a frame or clear can erase it.
|
||||
app.insert_history_cell(
|
||||
@@ -170,13 +165,13 @@ async fn non_startup_history_dismisses_logo_until_a_new_thread() -> Result<()> {
|
||||
app.reset_transcript_state_after_clear();
|
||||
app.queue_clear_ui_header(&mut tui);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(!animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
app.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.start_fresh();
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -188,6 +183,14 @@ async fn empty_state_animation_preserves_header_cursor_and_footer() -> Result<()
|
||||
let size = Size::new(/*width*/ 120, /*height*/ 44);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
app.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow()
|
||||
.greeting
|
||||
.set(crate::empty_state_animation::Greeting {
|
||||
phrase: "Pull up a prompt.",
|
||||
})
|
||||
.expect("initial greeting");
|
||||
app.queue_clear_ui_header(&mut tui);
|
||||
app.transcript_cells
|
||||
.push(Arc::new(history_cell::StartupWarningsCell::mcp(
|
||||
@@ -199,32 +202,54 @@ async fn empty_state_animation_preserves_header_cursor_and_footer() -> Result<()
|
||||
let history_len = app.transcript_cells.len();
|
||||
let before = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
|
||||
let cursor = tui.terminal.last_known_cursor_pos;
|
||||
assert!(!animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
app.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.start_fresh();
|
||||
assert_eq!(draw(&mut app, &mut tui, size)?, before_bottom);
|
||||
let after = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
|
||||
for (a, b) in before.content.iter().zip(&after.content) {
|
||||
if a.symbol() != " " {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
assert!(text(&before).contains("Pull up a prompt."));
|
||||
assert_eq!(
|
||||
&after.content[after.index_of(/*x*/ 0, before_bottom.y)..],
|
||||
&before.content[before.index_of(/*x*/ 0, before_bottom.y)..]
|
||||
);
|
||||
insta::assert_snapshot!("fresh_thread_header", text(after));
|
||||
insta::assert_snapshot!(
|
||||
"fresh_thread_header",
|
||||
format!(
|
||||
"enabled:\n{}\n---\ndisabled:\n{}",
|
||||
text(after),
|
||||
text(&before)
|
||||
)
|
||||
);
|
||||
// Editing, clearing, and resizing must never choose another phrase.
|
||||
let phrase_pos = after
|
||||
.content
|
||||
.iter()
|
||||
.position(|cell| cell.symbol() == "P")
|
||||
.expect("the phrase is visible above the composer");
|
||||
assert_eq!(after.content[phrase_pos].fg, crate::style::accent_color());
|
||||
|
||||
let short = Size::new(/*width*/ 120, /*height*/ 12);
|
||||
draw(&mut app, &mut tui, short)?;
|
||||
assert!(!has_blossom(&tui));
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(has_blossom(&tui));
|
||||
|
||||
app.chat_widget.apply_external_edit("/m".to_string());
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(!animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
app.chat_widget.apply_external_edit(String::new());
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
assert!(
|
||||
text(crate::custom_terminal::test_support::last_rendered_buffer(
|
||||
&tui.terminal
|
||||
))
|
||||
.contains("Pull up a prompt.")
|
||||
);
|
||||
assert_eq!(app.transcript_cells.len(), history_len);
|
||||
app.local_settings.tui.animations = false;
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
@@ -240,13 +265,13 @@ async fn empty_state_animation_preserves_header_cursor_and_footer() -> Result<()
|
||||
app.local_settings.tui.effects.welcome = true;
|
||||
app.local_settings.tui.effects.shimmer = false;
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submitting_a_draft_dismisses_logo_even_after_clear() -> Result<()> {
|
||||
async fn submitting_a_draft_keeps_greeting_and_dismisses_logo_even_after_clear() -> Result<()> {
|
||||
let (mut app, _events, _ops) = crate::app::tests::make_test_app_with_channels().await;
|
||||
app.local_settings.tui.animations = true;
|
||||
let size = Size::new(/*width*/ 120, /*height*/ 44);
|
||||
@@ -258,20 +283,42 @@ async fn submitting_a_draft_dismisses_logo_even_after_clear() -> Result<()> {
|
||||
.borrow_mut()
|
||||
.start_fresh();
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(has_blossom(&tui));
|
||||
let greeting = *app
|
||||
.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow()
|
||||
.greeting
|
||||
.get()
|
||||
.expect("choose a greeting for the fresh thread");
|
||||
app.chat_widget
|
||||
.apply_external_edit("first prompt".to_string());
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
let drafting = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||||
assert!(!animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
assert!(text(drafting).contains("first prompt"));
|
||||
assert!(text(drafting).contains(greeting.phrase));
|
||||
app.chat_widget.apply_external_edit(String::new());
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(has_blossom(&tui));
|
||||
app.chat_widget
|
||||
.apply_external_edit("first prompt".to_string());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(!animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
let submitted = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||||
assert_eq!(text(submitted).matches(greeting.phrase).count(), 1);
|
||||
// The header is also stable when it scrolls out and back into the viewport.
|
||||
let short = Size::new(/*width*/ 120, /*height*/ 8);
|
||||
draw(&mut app, &mut tui, short)?;
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
let restored = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||||
assert!(text(restored).contains(greeting.phrase));
|
||||
assert!(!has_blossom(&tui));
|
||||
app.reset_transcript_state_after_clear();
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(!animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -311,7 +358,7 @@ async fn empty_state_animation_survives_plain_transcript_clicks() -> Result<()>
|
||||
&app.transcript_cells,
|
||||
);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert_eq!(animation_is_eligible(&app), visible);
|
||||
assert_eq!(has_blossom(&tui), visible);
|
||||
if visible {
|
||||
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
|
||||
}
|
||||
@@ -323,7 +370,10 @@ async fn empty_state_animation_survives_plain_transcript_clicks() -> Result<()>
|
||||
);
|
||||
app.transcript_view.end_selection(&app.transcript_cells);
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(animation_is_eligible(&app));
|
||||
assert!(!has_blossom(&tui));
|
||||
app.transcript_view.jump_to_latest();
|
||||
draw(&mut app, &mut tui, size)?;
|
||||
assert!(has_blossom(&tui));
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
//! View changes hide it temporarily; non-startup content dismisses it until a new thread.
|
||||
|
||||
use super::*;
|
||||
use crate::empty_state_animation::Presentation;
|
||||
use crate::empty_state_animation::ComposerState;
|
||||
use crate::empty_state_animation::is_startup_cell;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::motion::MotionMode;
|
||||
|
||||
impl App {
|
||||
pub(super) fn empty_state_presentation(
|
||||
&self,
|
||||
motion: MotionMode,
|
||||
focused: bool,
|
||||
) -> Presentation {
|
||||
pub(super) fn first_screen_composer(&self) -> Option<ComposerState> {
|
||||
if !self
|
||||
.transcript_cells
|
||||
.iter()
|
||||
@@ -26,7 +21,7 @@ impl App {
|
||||
// Evaluate live content even while a different view temporarily owns the screen.
|
||||
let composer = self.chat_widget.empty_state_composer();
|
||||
let view = &self.transcript_view;
|
||||
let composer = match self.chat_widget.history_render_mode() {
|
||||
match self.chat_widget.history_render_mode() {
|
||||
HistoryRenderMode::Raw => None,
|
||||
HistoryRenderMode::Rich => match (
|
||||
self.overlay.as_ref(),
|
||||
@@ -36,10 +31,19 @@ impl App {
|
||||
view.has_selection_range(),
|
||||
) {
|
||||
// A refocus click without selected text remains an ordinary conversation.
|
||||
(None, false, false, false, false) => composer,
|
||||
(None, false, false, false, false)
|
||||
if (view.is_following() || view.has_pending_latest_selection())
|
||||
&& self.local_settings.tui.effects.welcome
|
||||
&& self
|
||||
.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow()
|
||||
.is_eligible() =>
|
||||
{
|
||||
composer
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
Presentation::for_composer(composer, motion, focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ impl App {
|
||||
&self,
|
||||
version: &'static str,
|
||||
) -> history_cell::SessionHeaderHistoryCell {
|
||||
history_cell::SessionHeaderHistoryCell::new(
|
||||
let mut header = history_cell::SessionHeaderHistoryCell::new(
|
||||
self.chat_widget.model_display_name().to_string(),
|
||||
self.chat_widget.current_reasoning_effort(),
|
||||
self.chat_widget.should_show_fast_status(
|
||||
@@ -325,7 +325,12 @@ impl App {
|
||||
self.config.cwd.to_path_buf(),
|
||||
version,
|
||||
)
|
||||
.with_yolo_mode(history_cell::is_yolo_mode(&self.config))
|
||||
.with_yolo_mode(history_cell::is_yolo_mode(&self.config));
|
||||
history_cell::set_session_greeting(
|
||||
&mut header,
|
||||
&self.chat_widget.empty_state_animation.borrow().greeting,
|
||||
);
|
||||
header
|
||||
}
|
||||
|
||||
pub(super) fn clear_ui_header_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
|
||||
@@ -72,13 +72,7 @@ impl App {
|
||||
let motion = MotionMode::from_animations_enabled(
|
||||
self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer,
|
||||
);
|
||||
let focused = tui.is_terminal_focused();
|
||||
self.empty_state_presentation(
|
||||
MotionMode::from_animations_enabled(
|
||||
self.local_settings.tui.animations && self.local_settings.tui.effects.welcome,
|
||||
),
|
||||
focused,
|
||||
);
|
||||
let composer = self.first_screen_composer();
|
||||
let latest_navigation = if self.enter_returns_to_latest() {
|
||||
"enter/esc latest"
|
||||
} else {
|
||||
@@ -148,6 +142,7 @@ impl App {
|
||||
let mut rendered_cursor = None;
|
||||
let mut footer_height_changed = false;
|
||||
let mut feedback_tick = None;
|
||||
let mut blossom_tick = None;
|
||||
let mut transcript_bottom = available.saturating_sub(u16::from(composer_gap.is_none()));
|
||||
tui.draw(screen_size.height, |frame| {
|
||||
ratatui::widgets::Clear.render(
|
||||
@@ -223,6 +218,18 @@ impl App {
|
||||
);
|
||||
footer_height_changed = false;
|
||||
}
|
||||
blossom_tick = chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.render_first_screen(
|
||||
Rect {
|
||||
width: screen_size.width,
|
||||
..view.remaining_area()
|
||||
},
|
||||
frame.buffer,
|
||||
composer,
|
||||
MotionMode::from_animations_enabled(self.local_settings.tui.animations),
|
||||
);
|
||||
bottom.render(bottom_area, frame.buffer);
|
||||
if let (Some(tip), Some(area)) = (completion_tip, completion_tip_area) {
|
||||
tip.render(area, frame.buffer);
|
||||
@@ -263,6 +270,9 @@ impl App {
|
||||
if let Some(delay) = feedback_tick {
|
||||
tui.frame_requester().schedule_frame_in(delay);
|
||||
}
|
||||
if let Some(delay) = blossom_tick {
|
||||
tui.frame_requester().schedule_frame_in(delay);
|
||||
}
|
||||
let animating =
|
||||
view.is_following() && active_key.is_some_and(|key| key.animation_tick.is_some());
|
||||
let loading = view.is_loading_history() && motion == MotionMode::Animated;
|
||||
@@ -297,6 +307,10 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
if matches!(event, TuiEvent::FocusLost) {
|
||||
self.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.cancel_replay();
|
||||
// Show the static, faded decoration immediately when the terminal loses focus.
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
@@ -320,6 +334,16 @@ impl App {
|
||||
let size = tui.prepare_draw_size()?;
|
||||
self.render_owned_transcript(tui, size)?;
|
||||
}
|
||||
if self.chat_widget.no_modal_or_popup_active()
|
||||
&& self
|
||||
.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.handle_mouse(*mouse)
|
||||
{
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(true);
|
||||
}
|
||||
if composer_ready
|
||||
&& self.handle_composer_copy_event(tui, event, |tui, text| {
|
||||
tui.copy_transcript_selection(
|
||||
|
||||
@@ -1097,10 +1097,13 @@ impl App {
|
||||
ThreadAttachPresentation::Fresh | ThreadAttachPresentation::FreshWithDraft
|
||||
) {
|
||||
self.chat_widget.mark_fresh_task_for_sparkle(&started);
|
||||
self.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.start_fresh();
|
||||
// FreshWithDraft inherits its provisional greeting and replay at the handoff.
|
||||
if matches!(presentation, ThreadAttachPresentation::Fresh) {
|
||||
self.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.start_fresh();
|
||||
}
|
||||
}
|
||||
self.chat_widget
|
||||
.set_task_mentions_enabled(started.task_tools_available);
|
||||
|
||||
+54
-7
@@ -1,13 +1,60 @@
|
||||
---
|
||||
source: tui/src/app/empty_state_animation_tests.rs
|
||||
expression: text(after)
|
||||
expression: "format!(\"enabled:\\n{}\\n---\\ndisabled:\\n{}\", text(after), text(&before))"
|
||||
---
|
||||
╭───────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ model: GPT-5.6-Sol /model to change │
|
||||
│ directory: /tmp/project │
|
||||
╰───────────────────────────────────────────╯
|
||||
enabled:
|
||||
|
||||
>_ OpenAI Codex (v0.0.0)
|
||||
/tmp/project
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
⣀⣤⣤⣤⣀⣀
|
||||
⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀
|
||||
⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄
|
||||
⣀⣾⣿⣿⠃ ⣀⣴⣾⣿⣿⡿⠟⠋ ⣀⡀ ⠈⠙⢿⣿⣿⣧
|
||||
⣀⣶⣿⣿⣿⣿⡇ ⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇
|
||||
⣰⣿⣿⡿⢿⣿⣿⡇ ⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷
|
||||
⢰⣿⣿⡟⠁⢸⣿⣿⡇ ⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀
|
||||
⣾⣿⣿⠃ ⢸⣿⣿⡇ ⢸⣿⣿⠋⠁ ⠈⠙⢻⣿⣿⣿⣶⣤⡀ ⠹⢿⣿⣷⡄
|
||||
⢸⣿⣿⣇ ⠸⣿⣿⣷⣦⣼⣿⣿ ⢸⣿⣿⠻⢿⣿⣿⡇ ⠘⣿⣿⣿
|
||||
⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀ ⢀⣠⣼⣿⣿ ⣿⣿⡇ ⣿⣿⣿⠇
|
||||
⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿ ⣿⣿⡇ ⣠⣿⣿⣿
|
||||
⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿ ⣿⣿⣷⣴⣿⣿⣿⠃
|
||||
⠘⣿⣿⣷ ⠈⠛⢿⣿⣿⣿⠿⠋⠉ ⣠⣴⣿⣿⣿ ⣿⣿⣿⣿⣿⠟⠁
|
||||
⠻⣿⣿⣿⣤⣀ ⠈⠉ ⢀⣤⣶⣿⣿⣿⠿⠟⠁ ⣰⣿⣿⡟⠋⠁
|
||||
⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁ ⣠⣼⣿⣿⡟
|
||||
⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏
|
||||
⠈⠙⠛⠿⠿⠿⠿⠛⠉
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
? for shortcuts ⚠ 1 warning · f2 to view
|
||||
---
|
||||
disabled:
|
||||
|
||||
>_ OpenAI Codex (v0.0.0)
|
||||
/tmp/project
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -698,7 +698,10 @@ impl App {
|
||||
}
|
||||
chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width);
|
||||
if pending_startup_thread_start && !start_in_agents_overview {
|
||||
chat_widget.empty_state_animation.borrow_mut().start_fresh();
|
||||
chat_widget
|
||||
.empty_state_animation
|
||||
.borrow_mut()
|
||||
.continue_from(&mut startup_draft.blossom.borrow_mut());
|
||||
}
|
||||
chat_widget.remote_connection = remote_connection;
|
||||
chat_widget.snapshot_local_images = app_server_target.uses_remote_workspace();
|
||||
|
||||
@@ -75,6 +75,16 @@ async fn command_center_new_keeps_startup_draft_visible_through_handoff() -> Res
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"draft during startup"
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.empty_state_animation
|
||||
.borrow()
|
||||
.greeting
|
||||
.get()
|
||||
.unwrap()
|
||||
.phrase,
|
||||
"Pull up a prompt."
|
||||
);
|
||||
|
||||
while let Ok(event) = events.try_recv() {
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
|
||||
+16
-15
@@ -1,16 +1,14 @@
|
||||
---
|
||||
source: tui/src/app/tests/startup_frame_tests.rs
|
||||
assertion_line: 72
|
||||
expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots.join(\"\\n\\n\")))"
|
||||
---
|
||||
80 columns
|
||||
╭────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ model: gpt-test /model to change │
|
||||
│ directory: /tmp/project │
|
||||
╰────────────────────────────────────────╯
|
||||
|
||||
Tip: Use /mcp to list configured MCP tools.
|
||||
>_ OpenAI Codex (v0.0.0)
|
||||
/tmp/project
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
• Conversation continues here.
|
||||
|
||||
@@ -19,21 +17,20 @@ expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
? for shortcuts
|
||||
|
||||
32 columns
|
||||
╭──────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ model: gpt-test /mode… │
|
||||
│ directory: /tmp/project │
|
||||
╰──────────────────────────────╯
|
||||
|
||||
Tip: Use /mcp to list
|
||||
configured MCP tools.
|
||||
>_ OpenAI Codex (v0.0.0)
|
||||
/tmp/project
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
• Conversation continues here.
|
||||
|
||||
@@ -41,6 +38,10 @@ expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
GPT-5.6-Sol default · /tmp/pr…
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::*;
|
||||
use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot;
|
||||
use crate::chatwidget::tests::helpers::normalize_snapshot_paths;
|
||||
use crate::custom_terminal::test_support::last_rendered_buffer;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::startup_draft::tests::quiet_startup_test_pump;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -16,23 +17,31 @@ fn frame_text(tui: &tui::Tui) -> String {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_startup_tip_follows_splash_in_transcript() -> Result<()> {
|
||||
async fn owned_startup_hides_tip_in_transcript() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.local_settings.tui.show_tooltips = true;
|
||||
app.local_settings.tui.animations = false;
|
||||
let greeting = Arc::new(std::sync::OnceLock::new());
|
||||
greeting
|
||||
.set(crate::empty_state_animation::Greeting {
|
||||
phrase: "Pull up a prompt.",
|
||||
})
|
||||
.unwrap();
|
||||
let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf());
|
||||
let mut session_info = new_session_info(
|
||||
&app.config,
|
||||
&app.local_settings,
|
||||
&session.model,
|
||||
&session.model,
|
||||
&session,
|
||||
/*is_first_event*/ false,
|
||||
Some("Use /mcp to list configured MCP tools.".into()),
|
||||
/*auth_plan*/ None,
|
||||
/*show_fast_status*/ false,
|
||||
);
|
||||
history_cell::set_session_greeting(&mut session_info, &greeting);
|
||||
app.transcript_cells = vec![
|
||||
Arc::new(new_session_info(
|
||||
&app.config,
|
||||
&app.local_settings,
|
||||
&session.model,
|
||||
&session.model,
|
||||
&session,
|
||||
/*is_first_event*/ false,
|
||||
Some("Use /mcp to list configured MCP tools.".into()),
|
||||
/*auth_plan*/ None,
|
||||
/*show_fast_status*/ false,
|
||||
)),
|
||||
Arc::new(session_info),
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec!["Conversation continues here.".into()],
|
||||
/*is_first_line*/ true,
|
||||
@@ -64,6 +73,28 @@ async fn owned_startup_tip_follows_splash_in_transcript() -> Result<()> {
|
||||
"startup_tip_in_transcript",
|
||||
normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots.join("\n\n"))),
|
||||
);
|
||||
// The source remains available to terminal scrollback in both presentation modes.
|
||||
for mode in [HistoryRenderMode::Rich, HistoryRenderMode::Raw] {
|
||||
let lines = app.transcript_cells[0].display_lines_for_mode(/*width*/ 80, mode);
|
||||
let text = lines
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("Pull up a prompt."));
|
||||
assert!(text.contains("Use /mcp"));
|
||||
assert!(!text.contains("model:"));
|
||||
assert!(!text.contains('╭'));
|
||||
}
|
||||
app.transcript_view.begin_search();
|
||||
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 20))?;
|
||||
assert!(!frame_text(&tui).contains("Use /mcp"));
|
||||
app.open_transcript_overlay(&mut tui);
|
||||
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 20))?;
|
||||
assert!(!frame_text(&tui).contains("Use /mcp"));
|
||||
app.apply_raw_output_mode(&mut tui, /*enabled*/ true, /*notify*/ false);
|
||||
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 20))?;
|
||||
assert!(!frame_text(&tui).contains("Use /mcp"));
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ impl super::ChatComposer {
|
||||
use crate::empty_state_animation::ComposerState;
|
||||
|
||||
let composer = match self.footer_mode() {
|
||||
FooterMode::ComposerEmpty if self.is_in_paste_burst() => Some(ComposerState::Draft),
|
||||
FooterMode::ComposerEmpty => Some(ComposerState::Empty),
|
||||
FooterMode::ComposerHasDraft => Some(ComposerState::Draft),
|
||||
FooterMode::HistorySearch
|
||||
|
||||
@@ -21,7 +21,9 @@ impl ChatWidget {
|
||||
let active = self.transcript.active_cell.as_deref();
|
||||
let mode = self.history_render_mode();
|
||||
let render_compact = |cell: &dyn HistoryCell, width| {
|
||||
if mode == HistoryRenderMode::Raw {
|
||||
if cell.as_any().is::<history_cell::SessionInfoCell>() {
|
||||
history_cell::fullscreen_session_lines(cell, width, /*detailed*/ false, mode)
|
||||
} else if mode == HistoryRenderMode::Raw {
|
||||
cell.display_hyperlink_lines_for_mode(width, mode)
|
||||
} else {
|
||||
cell.compact_hyperlink_lines(width)
|
||||
|
||||
@@ -63,7 +63,10 @@ impl ChatWidget {
|
||||
settings: fallback_default,
|
||||
};
|
||||
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
let empty_state_animation = crate::empty_state_animation::EmptyStateAnimation::default();
|
||||
let mut header = Self::placeholder_session_header_cell(&config);
|
||||
history_cell::set_session_greeting(header.as_mut(), &empty_state_animation.greeting);
|
||||
let active_cell = Some(header);
|
||||
|
||||
let current_cwd = Some(config.cwd.to_path_buf());
|
||||
let effective_service_tier = crate::service_tier_resolution::effective_service_tier(
|
||||
@@ -94,7 +97,7 @@ impl ChatWidget {
|
||||
pet_http_client.clone(),
|
||||
);
|
||||
let mut widget = Self {
|
||||
empty_state_animation: Default::default(),
|
||||
empty_state_animation: std::cell::RefCell::new(empty_state_animation),
|
||||
cyber_policy_notice: Default::default(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
|
||||
@@ -203,7 +203,7 @@ impl ChatWidget {
|
||||
let startup_tooltip_override = self.startup_tooltip_override.take();
|
||||
let show_fast_status = self
|
||||
.should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref());
|
||||
let session_info_cell = history_cell::new_session_info(
|
||||
let mut session_info_cell = history_cell::new_session_info(
|
||||
&self.config,
|
||||
&self.local_settings,
|
||||
&model_for_header,
|
||||
@@ -214,6 +214,10 @@ impl ChatWidget {
|
||||
self.plan_type,
|
||||
show_fast_status,
|
||||
);
|
||||
history_cell::set_session_greeting(
|
||||
&mut session_info_cell,
|
||||
&self.empty_state_animation.borrow().greeting,
|
||||
);
|
||||
self.apply_session_info_cell(session_info_cell);
|
||||
} else if self
|
||||
.transcript
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
//! The blossom welcome animation for onboarding, with a full-color final pose.
|
||||
//! Visible time pauses while hidden; conversation lifecycle tracking is retained for the header.
|
||||
//! Visible time pauses while hidden. Fresh conversations start settled and can replay on a click.
|
||||
|
||||
mod geometry;
|
||||
mod greetings;
|
||||
mod lighting;
|
||||
mod paths;
|
||||
mod policy;
|
||||
mod renderer;
|
||||
mod sequence;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::motion::MotionMode;
|
||||
use crate::terminal_palette;
|
||||
pub(crate) use greetings::Greeting;
|
||||
use lighting::Lighting;
|
||||
pub(crate) use policy::Presentation;
|
||||
pub(crate) use policy::is_startup_cell;
|
||||
@@ -25,6 +33,8 @@ use renderer::MAX_ROWS;
|
||||
use renderer::Renderer;
|
||||
|
||||
pub(crate) const FRAME_INTERVAL: Duration = Duration::from_millis(/*millis*/ 50);
|
||||
// The preview's first visible pose (10.8s / 7.2s), also the start of an idle-screen replay.
|
||||
const SETTLED_BLOSSOM: f64 = 0.5;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ComposerState {
|
||||
@@ -35,6 +45,9 @@ pub(crate) enum ComposerState {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EmptyStateAnimation {
|
||||
eligible: bool,
|
||||
// Initialized once for fresh threads; never initialized for a resumed or forked thread.
|
||||
// Headers retain the selection after the temporary blossom is dismissed.
|
||||
pub(crate) greeting: Arc<OnceLock<Greeting>>,
|
||||
spin_elapsed: Duration,
|
||||
last_frame: Option<Instant>,
|
||||
fade_elapsed: Duration,
|
||||
@@ -42,11 +55,19 @@ pub(crate) struct EmptyStateAnimation {
|
||||
opacity: f32,
|
||||
fade_from: f32,
|
||||
renderer: Option<Renderer>,
|
||||
stage: Option<Rect>,
|
||||
replaying: bool,
|
||||
}
|
||||
|
||||
impl EmptyStateAnimation {
|
||||
pub(crate) fn is_eligible(&self) -> bool {
|
||||
self.eligible
|
||||
}
|
||||
|
||||
pub(crate) fn start_fresh(&mut self) {
|
||||
self.cancel_replay();
|
||||
self.eligible = true;
|
||||
self.greeting.get_or_init(Greeting::choose);
|
||||
self.spin_elapsed = Duration::ZERO;
|
||||
self.last_frame = None;
|
||||
self.fade_elapsed = Duration::ZERO;
|
||||
@@ -54,8 +75,48 @@ impl EmptyStateAnimation {
|
||||
self.opacity = 1.0;
|
||||
}
|
||||
|
||||
/// Keep the provisional pose and phrase in headers already bound to the live thread.
|
||||
pub(crate) fn continue_from(&mut self, source: &mut Self) {
|
||||
let mut previous = std::mem::take(source);
|
||||
if !previous.is_eligible() {
|
||||
previous.start_fresh();
|
||||
}
|
||||
if let Some(greeting) = previous.greeting.get() {
|
||||
let _ = self.greeting.set(*greeting);
|
||||
}
|
||||
previous.greeting = Arc::clone(&self.greeting);
|
||||
*self = previous;
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss(&mut self) {
|
||||
self.eligible = false;
|
||||
self.cancel_replay();
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_replay(&mut self) {
|
||||
self.stage = None;
|
||||
self.replaying = false;
|
||||
self.pause_clock();
|
||||
}
|
||||
|
||||
/// Replay only on the drawn blossom; unrelated gestures keep their normal owner.
|
||||
pub(crate) fn handle_mouse(&mut self, mouse: MouseEvent) -> bool {
|
||||
let inside = self
|
||||
.stage
|
||||
.is_some_and(|stage| stage.contains(Position::new(mouse.column, mouse.row)))
|
||||
&& mouse.modifiers.is_empty();
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) if inside => {
|
||||
self.start_fresh();
|
||||
self.spin_elapsed =
|
||||
Duration::from_secs_f64(SETTLED_BLOSSOM * sequence::LOOP_SECONDS);
|
||||
self.opacity = sequence::STATIC_OPACITY;
|
||||
self.static_mark = Some(true);
|
||||
self.replaying = true;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop visible time at the last painted frame, including when no hidden frame was drawn.
|
||||
@@ -126,6 +187,55 @@ impl EmptyStateAnimation {
|
||||
+ (self.opacity - self.fade_from)
|
||||
* sequence::progress(self.fade_elapsed, sequence::STATIC_FADE) as f32;
|
||||
}
|
||||
self.paint_frame(area, buffer, phase, self.opacity);
|
||||
(!finished && (!static_mark || settling)).then_some(FRAME_INTERVAL)
|
||||
}
|
||||
|
||||
/// Draw only in space the caller has cleared and owns. The centered blossom returns
|
||||
/// on clear, without restarting or scheduling animation.
|
||||
pub(crate) fn render_first_screen(
|
||||
&mut self,
|
||||
available: Rect,
|
||||
buffer: &mut Buffer,
|
||||
composer: Option<ComposerState>,
|
||||
motion: MotionMode,
|
||||
) -> Option<Duration> {
|
||||
const STAGE_ROWS: u16 = 21;
|
||||
const MIN_STAGE_ROWS: u16 = 14;
|
||||
let screen = buffer.area;
|
||||
let width = screen.width.saturating_sub(/*rhs*/ 4).min(MAX_COLUMNS);
|
||||
let height = width * STAGE_ROWS / MAX_COLUMNS;
|
||||
if motion == MotionMode::Reduced
|
||||
|| composer != Some(ComposerState::Empty)
|
||||
|| height < MIN_STAGE_ROWS
|
||||
|| height > screen.height
|
||||
{
|
||||
self.cancel_replay();
|
||||
return None;
|
||||
}
|
||||
let stage = Rect::new(
|
||||
screen.x + (screen.width - width) / 2,
|
||||
screen.y + (screen.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
if stage.intersection(available) != stage {
|
||||
self.cancel_replay();
|
||||
return None;
|
||||
}
|
||||
self.stage = Some(stage);
|
||||
if self.replaying {
|
||||
if let Some(delay) = self.render_in(stage, buffer, Presentation::Animated) {
|
||||
return Some(delay);
|
||||
}
|
||||
// Paint the idle pose over the final full-color frame in the same update.
|
||||
self.replaying = false;
|
||||
}
|
||||
self.paint_frame(stage, buffer, SETTLED_BLOSSOM, sequence::STATIC_OPACITY);
|
||||
None
|
||||
}
|
||||
|
||||
fn paint_frame(&mut self, area: Rect, buffer: &mut Buffer, phase: f64, opacity: f32) {
|
||||
let background = terminal_palette::default_bg();
|
||||
let color_level = if background.is_some() {
|
||||
terminal_palette::effective_stdout_color_level()
|
||||
@@ -150,7 +260,7 @@ impl EmptyStateAnimation {
|
||||
area.y + i as u16 / area.width,
|
||||
)];
|
||||
target.set_char(char::from_u32(0x2800 + u32::from(cell.dots)).unwrap_or(' '));
|
||||
let color = crate::color::blend((r, g, b), background, self.opacity);
|
||||
let color = crate::color::blend((r, g, b), background, opacity);
|
||||
let color = if color_level == terminal_palette::StdoutColorLevel::Ansi256 {
|
||||
// Four bits per channel bound the cache and avoid palette searches each frame.
|
||||
static COLORS: [OnceLock<Color>; 4096] = [const { OnceLock::new() }; 4096];
|
||||
@@ -167,7 +277,7 @@ impl EmptyStateAnimation {
|
||||
};
|
||||
target.set_fg(color);
|
||||
// Default-color terminals can only step between normal and dim intensity.
|
||||
if self.opacity < 0.5
|
||||
if opacity < 0.5
|
||||
&& matches!(
|
||||
color_level,
|
||||
terminal_palette::StdoutColorLevel::Ansi16
|
||||
@@ -177,7 +287,6 @@ impl EmptyStateAnimation {
|
||||
target.set_style(target.style().dim());
|
||||
}
|
||||
}
|
||||
(!finished && (!static_mark || settling)).then_some(FRAME_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Startup phrases retained from the approved greeting list (PR #10248).
|
||||
//! One selection is shared by the provisional, live, and committed session headers.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Greeting {
|
||||
pub(crate) phrase: &'static str,
|
||||
}
|
||||
|
||||
impl Greeting {
|
||||
pub(super) fn choose() -> Self {
|
||||
Self {
|
||||
phrase: GREETINGS[rand::rng().random_range(0..GREETINGS.len())],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const GREETINGS: &[&str] = &[
|
||||
"Whoa, fancy meeting you here!",
|
||||
"Look who’s at the keyboard.",
|
||||
"Hello, you. Got an idea?",
|
||||
"Nice to see you in these parts.",
|
||||
"Pull up a prompt.",
|
||||
"What brings you to this corner of the terminal?",
|
||||
"Hey there. Big plans or little fixes?",
|
||||
"What are we getting into today?",
|
||||
"Got something you want to try?",
|
||||
"What’s today’s little adventure?",
|
||||
"Anything interesting on the docket?",
|
||||
"Take your time. The cursor can wait.",
|
||||
"A half-formed idea will do.",
|
||||
"Come on in. There’s room for an idea.",
|
||||
"You, me, and a blinking cursor.",
|
||||
"It takes two to tango. What’s our first step?",
|
||||
"You bring the idea. I’ll bring the brackets.",
|
||||
"Your move, teammate.",
|
||||
"What are we cooking up?",
|
||||
"Shall we turn “what if” into something?",
|
||||
"What’s the first thing on our napkin sketch?",
|
||||
"Bring your unfinished thoughts.",
|
||||
"You set the direction. We’ll work out the steps.",
|
||||
"Ah, the terminal. A classic meeting spot.",
|
||||
"Nice place you’ve got here. Very monospace.",
|
||||
"Welcome to our little rectangle of possibility.",
|
||||
"A cursor blinks. The plot thickens.",
|
||||
"The prompt is yours.",
|
||||
"Fancy a little quality terminal time?",
|
||||
"How’s life between the brackets?",
|
||||
"Shall we give this cursor a purpose?",
|
||||
"A fresh prompt. An open question.",
|
||||
"Your keyboard has entered the chat.",
|
||||
"Hello, world. Hello, you.",
|
||||
"Welcome to the blinking edge of possibility.",
|
||||
"Any loose ends? Semicolons count.",
|
||||
"Shall we make this terminal earn its keep?",
|
||||
"Got an itch to fix a thing?",
|
||||
"Show me the bit that’s being weird.",
|
||||
"Greetings, fellow tinkerer.",
|
||||
"Well, well, well. An idea approaches.",
|
||||
"What’s today’s side quest?",
|
||||
"Is this a plan day or a poke-around day?",
|
||||
"Practical, peculiar, or a little of both?",
|
||||
"Rough sketches welcome.",
|
||||
"Shall we see where this goes?",
|
||||
"All right. What have you got?",
|
||||
"Back for another round?",
|
||||
"Same terminal, new possibilities.",
|
||||
"Welcome back. Familiar territory or a fresh adventure?",
|
||||
"Follow the white cursor.",
|
||||
"Welcome to the command line, Neo.",
|
||||
"A glitch in the Matrix, or just a missing semicolon?",
|
||||
"How deep does this codebase go?",
|
||||
"Speak, friend, and enter a prompt.",
|
||||
"One prompt to begin the journey.",
|
||||
"May the source be with you.",
|
||||
"These might be the bugs you’re looking for.",
|
||||
"A new prompt awakens.",
|
||||
"Forty-two is an answer. What’s the question?",
|
||||
"It’s dangerous to code alone. Take a prompt.",
|
||||
"The code must flow.",
|
||||
"You rang? Metaphorically. You typed.",
|
||||
"Hello again, carbon-based collaborator.",
|
||||
"This looks like the start of a perfectly reasonable rabbit hole.",
|
||||
"Shall we turn “huh?” into “aha!”?",
|
||||
"Well, this terminal just got interesting.",
|
||||
"The source is strong with this one.",
|
||||
"There’s a perfectly good prompt with your name on it.",
|
||||
"Here for a quick fix or the extended edition?",
|
||||
"What’s the latest from your side of the keyboard?",
|
||||
"Got a minute and a mildly unreasonable idea?",
|
||||
"A hunch is a perfectly respectable starting point.",
|
||||
"Hello again. What’s the plot this time?",
|
||||
"Shall we make a little something out of nothing?",
|
||||
"Welcome to the neighborhood. Lots of characters here.",
|
||||
"Nice terminal. Does it come in widescreen?",
|
||||
"Your cursor called. It wants a plot.",
|
||||
"Shall we put some verbs after that cursor?",
|
||||
"Welcome. There’s no dress code, just code.",
|
||||
"This terminal has excellent conversational potential.",
|
||||
"Got an idea that won’t stay on the napkin?",
|
||||
"What are we building in this episode?",
|
||||
"Bring a question. Bonus points if it’s a weird one.",
|
||||
"What are we poking with a metaphorical stick?",
|
||||
"Shall we make the thing that makes the other thing easier?",
|
||||
"Welcome to the part where the idea gets interesting.",
|
||||
"A long time ago, in a directory not so far away…",
|
||||
];
|
||||
@@ -1,10 +1,8 @@
|
||||
//! Positive eligibility for the fresh-conversation decoration.
|
||||
//! Unknown history cell types are activity, even when they render no visible text.
|
||||
|
||||
use super::ComposerState;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::motion::MotionMode;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Presentation {
|
||||
@@ -13,22 +11,6 @@ pub(crate) enum Presentation {
|
||||
Faded,
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
/// Only an explicitly available ordinary composer may display a fresh-thread logo.
|
||||
pub(crate) fn for_composer(
|
||||
composer: Option<ComposerState>,
|
||||
motion: MotionMode,
|
||||
focused: bool,
|
||||
) -> Self {
|
||||
match (composer, motion, focused) {
|
||||
(None, _, _) | (_, MotionMode::Reduced, _) => Self::Hidden,
|
||||
(Some(ComposerState::Empty), MotionMode::Animated, true) => Self::Animated,
|
||||
(Some(ComposerState::Empty), MotionMode::Animated, false)
|
||||
| (Some(ComposerState::Draft), MotionMode::Animated, _) => Self::Faded,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup metadata is the entire allowlist; new content types opt out by default.
|
||||
pub(crate) fn is_startup_cell(cell: &dyn HistoryCell) -> bool {
|
||||
cell.as_any().is::<history_cell::SessionHeaderHistoryCell>()
|
||||
|
||||
@@ -1,13 +1,159 @@
|
||||
//! Logo visibility, theme contrast, placement, and redraw lifecycle tests.
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
impl EmptyStateAnimation {
|
||||
// App integration tests inspect lifecycle eligibility without advancing the render clock.
|
||||
pub(crate) fn is_eligible_for_test(&self) -> bool {
|
||||
self.eligible
|
||||
#[test]
|
||||
fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() {
|
||||
let mut animation = EmptyStateAnimation::default();
|
||||
animation.start_fresh();
|
||||
let screen = Rect::new(
|
||||
/*x*/ 7, /*y*/ 3, /*width*/ 80, /*height*/ 44,
|
||||
);
|
||||
let mut buffer = Buffer::empty(screen);
|
||||
for header_rows in [5, 7, 11] {
|
||||
buffer.reset();
|
||||
let free = Rect::new(
|
||||
screen.x,
|
||||
screen.y + header_rows,
|
||||
screen.width,
|
||||
screen.height - header_rows - 3,
|
||||
);
|
||||
assert_eq!(
|
||||
animation.render_first_screen(
|
||||
free,
|
||||
&mut buffer,
|
||||
Some(ComposerState::Empty),
|
||||
MotionMode::Animated
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
animation.stage,
|
||||
Some(Rect::new(
|
||||
/*x*/ 17, /*y*/ 14, /*width*/ 60, /*height*/ 21
|
||||
))
|
||||
);
|
||||
}
|
||||
// Content which reaches the center must win; it must not move the stage down.
|
||||
let free = Rect::new(screen.x, /*y*/ 20, screen.width, /*height*/ 24);
|
||||
animation.render_first_screen(
|
||||
free,
|
||||
&mut buffer,
|
||||
Some(ComposerState::Empty),
|
||||
MotionMode::Animated,
|
||||
);
|
||||
assert_eq!(animation.stage, None);
|
||||
// Both sides of the minimum size, and a terminal that is too short for the full logo.
|
||||
for (width, height, expected) in [(44, 36, true), (43, 36, false), (80, 20, false)] {
|
||||
let screen = Rect::new(/*x*/ 7, /*y*/ 3, width, height);
|
||||
let mut buffer = Buffer::empty(screen);
|
||||
animation.render_first_screen(
|
||||
screen,
|
||||
&mut buffer,
|
||||
Some(ComposerState::Empty),
|
||||
MotionMode::Animated,
|
||||
);
|
||||
assert_eq!(animation.stage.is_some(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_screen_single_click_replays_the_visible_blossom() {
|
||||
let mut animation = EmptyStateAnimation::default();
|
||||
animation.start_fresh();
|
||||
let screen = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 44,
|
||||
);
|
||||
let mut buffer = Buffer::empty(screen);
|
||||
let click = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 40,
|
||||
row: 22,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
let draw = |animation: &mut EmptyStateAnimation, buffer: &mut Buffer, composer| {
|
||||
buffer.reset();
|
||||
animation.render_first_screen(screen, buffer, composer, MotionMode::Animated)
|
||||
};
|
||||
assert_eq!(
|
||||
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
|
||||
None
|
||||
);
|
||||
let static_frame = buffer.clone();
|
||||
for other in [
|
||||
MouseEvent {
|
||||
column: 0,
|
||||
row: 0,
|
||||
..click
|
||||
},
|
||||
MouseEvent {
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..click
|
||||
},
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Right),
|
||||
..click
|
||||
},
|
||||
] {
|
||||
assert!(!animation.handle_mouse(other));
|
||||
}
|
||||
assert_eq!(
|
||||
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
|
||||
None
|
||||
);
|
||||
assert_eq!(buffer, static_frame);
|
||||
assert!(animation.handle_mouse(click));
|
||||
// Release belongs to the transcript if it started a selection outside the logo.
|
||||
assert!(!animation.handle_mouse(MouseEvent {
|
||||
kind: MouseEventKind::Up(MouseButton::Left),
|
||||
..click
|
||||
}));
|
||||
assert_eq!(
|
||||
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
|
||||
Some(FRAME_INTERVAL)
|
||||
);
|
||||
assert_eq!(buffer, static_frame);
|
||||
let started = animation.last_frame.unwrap();
|
||||
let stage = animation.stage.unwrap();
|
||||
buffer.reset();
|
||||
assert_eq!(
|
||||
animation.render_in_at(
|
||||
stage,
|
||||
&mut buffer,
|
||||
Presentation::Animated,
|
||||
started + sequence::STATIC_FADE,
|
||||
),
|
||||
Some(FRAME_INTERVAL)
|
||||
);
|
||||
assert_ne!(buffer, static_frame);
|
||||
assert!(animation.handle_mouse(click));
|
||||
assert_eq!(
|
||||
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
|
||||
Some(FRAME_INTERVAL)
|
||||
);
|
||||
assert_eq!(buffer, static_frame);
|
||||
assert_eq!(
|
||||
draw(&mut animation, &mut buffer, Some(ComposerState::Draft)),
|
||||
None
|
||||
);
|
||||
assert!(!animation.handle_mouse(click));
|
||||
assert_eq!(
|
||||
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
|
||||
None
|
||||
);
|
||||
assert_eq!(buffer, static_frame);
|
||||
// Disabling motion removes both the logo and its click target.
|
||||
buffer.reset();
|
||||
animation.render_first_screen(
|
||||
screen,
|
||||
&mut buffer,
|
||||
Some(ComposerState::Empty),
|
||||
MotionMode::Reduced,
|
||||
);
|
||||
assert_eq!(buffer, Buffer::empty(screen));
|
||||
assert!(!animation.handle_mouse(click));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Session headers, onboarding guidance, and transcript cards.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::*;
|
||||
use crate::empty_state_animation::Greeting;
|
||||
use crate::line_truncation::line_width;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::style::accent_color;
|
||||
@@ -121,6 +125,46 @@ impl HistoryCell for SessionNoticeCell {
|
||||
#[derive(Debug)]
|
||||
pub struct SessionInfoCell(CompositeHistoryCell);
|
||||
|
||||
/// Bind provisional and configured banners to the thread's chosen greeting.
|
||||
pub(crate) fn set_session_greeting(cell: &mut dyn HistoryCell, greeting: &Arc<OnceLock<Greeting>>) {
|
||||
if let Some(header) = cell.as_any_mut().downcast_mut::<SessionHeaderHistoryCell>() {
|
||||
header.greeting = Arc::clone(greeting);
|
||||
} else if let Some(info) = cell.as_any_mut().downcast_mut::<SessionInfoCell>() {
|
||||
for part in &mut info.0.parts {
|
||||
set_session_greeting(part.as_mut(), greeting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fullscreen transcript presentation omits tips; scrollback retains the original cells.
|
||||
pub(crate) fn fullscreen_session_lines(
|
||||
cell: &dyn HistoryCell,
|
||||
width: u16,
|
||||
detailed: bool,
|
||||
mode: HistoryRenderMode,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
if let Some(info) = cell.as_any().downcast_ref::<SessionInfoCell>() {
|
||||
let mut lines = Vec::new();
|
||||
for part in &info.0.parts {
|
||||
if part.as_any().is::<TooltipHistoryCell>() {
|
||||
continue;
|
||||
}
|
||||
let part_lines = fullscreen_session_lines(part.as_ref(), width, detailed, mode);
|
||||
if !part_lines.is_empty() {
|
||||
if !lines.is_empty() {
|
||||
lines.push(HyperlinkLine::from(""));
|
||||
}
|
||||
lines.extend(part_lines);
|
||||
}
|
||||
}
|
||||
lines
|
||||
} else if detailed || mode == HistoryRenderMode::Rich {
|
||||
cell.retained_hyperlink_lines(width, detailed)
|
||||
} else {
|
||||
cell.display_hyperlink_lines_for_mode(width, mode)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for SessionInfoCell {
|
||||
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.0.compact_hyperlink_lines(width)
|
||||
@@ -269,6 +313,7 @@ pub(crate) struct SessionHeaderHistoryCell {
|
||||
show_fast_status: bool,
|
||||
directory: PathBuf,
|
||||
yolo_mode: bool,
|
||||
greeting: Arc<OnceLock<Greeting>>,
|
||||
}
|
||||
|
||||
impl SessionHeaderHistoryCell {
|
||||
@@ -305,6 +350,7 @@ impl SessionHeaderHistoryCell {
|
||||
show_fast_status,
|
||||
directory,
|
||||
yolo_mode: false,
|
||||
greeting: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +394,50 @@ impl SessionHeaderHistoryCell {
|
||||
}
|
||||
|
||||
impl HistoryCell for SessionHeaderHistoryCell {
|
||||
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
if self.greeting.get().is_none() {
|
||||
return self.display_hyperlink_lines(width);
|
||||
}
|
||||
let width = usize::from(width);
|
||||
let mut lines = vec![
|
||||
Line::default(),
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
">_ ".fg(accent_color()),
|
||||
"OpenAI Codex".bold(),
|
||||
format!(" (v{})", self.version).dim(),
|
||||
]),
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
self.format_directory(Some(width.saturating_sub(/*rhs*/ 5)))
|
||||
.dim(),
|
||||
]),
|
||||
];
|
||||
if self.yolo_mode {
|
||||
lines.push(Line::from(vec![
|
||||
" permissions: ".dim(),
|
||||
"YOLO mode".magenta().bold(),
|
||||
]));
|
||||
}
|
||||
if let Some(greeting) = self.greeting.get() {
|
||||
// The tip/help that follows has its own normal composite separator.
|
||||
lines.extend([
|
||||
Line::default(),
|
||||
Line::from(vec![" ".into(), greeting.phrase.fg(accent_color())]),
|
||||
]);
|
||||
}
|
||||
plain_hyperlink_lines(
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| truncate_line_with_ellipsis_if_overflow(line, width))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if self.greeting.get().is_some() {
|
||||
return visible_lines(self.compact_hyperlink_lines(width));
|
||||
}
|
||||
let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -428,6 +517,12 @@ impl HistoryCell for SessionHeaderHistoryCell {
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
if self.greeting.get().is_some() {
|
||||
return visible_lines(self.compact_hyperlink_lines(u16::MAX))
|
||||
.into_iter()
|
||||
.map(|line| Line::from(line.to_string()))
|
||||
.collect();
|
||||
}
|
||||
let mut lines = vec![
|
||||
Line::from(format!("OpenAI Codex (v{})", self.version)),
|
||||
Line::from(format!(
|
||||
|
||||
+7
-7
@@ -1,14 +1,14 @@
|
||||
---
|
||||
source: tui/src/startup_draft_layout_tests.rs
|
||||
expression: startup_layout
|
||||
expression: "format!(\"cursor={:?}\\n{frame}\",\nlayout.cursor_pos(area)).replace(crate::version::CODEX_CLI_VERSION,\n\"<VERSION>\")"
|
||||
---
|
||||
cursor=Some((13, 12))
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+31
-36
@@ -1,27 +1,26 @@
|
||||
---
|
||||
source: tui/src/startup_draft_tests.rs
|
||||
assertion_line: 285
|
||||
expression: "snapshots.join(\"\\n---\\n\")"
|
||||
---
|
||||
resume lookup fallback:
|
||||
before:
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
Resuming session…
|
||||
|
||||
› draft while loading
|
||||
|
||||
|
||||
after:
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
› draft while loading
|
||||
@@ -30,24 +29,22 @@ after:
|
||||
---
|
||||
fork lookup fallback:
|
||||
before:
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
Forking session…
|
||||
|
||||
› draft while loading
|
||||
|
||||
|
||||
after:
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
› draft while loading
|
||||
@@ -58,12 +55,11 @@ resume picker cancellation:
|
||||
before:
|
||||
hidden while picker owns input
|
||||
after:
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
@@ -74,12 +70,11 @@ fork picker cancellation:
|
||||
before:
|
||||
hidden while picker owns input
|
||||
after:
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
+26
-30
@@ -1,14 +1,14 @@
|
||||
---
|
||||
source: tui/src/startup_draft_tests.rs
|
||||
assertion_line: 148
|
||||
expression: "snapshots.join(\"\\n---\\n\")"
|
||||
---
|
||||
empty (48 columns):
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
@@ -16,12 +16,11 @@ empty (48 columns):
|
||||
? for shortcuts
|
||||
---
|
||||
resuming (48 columns):
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
Resuming session…
|
||||
|
||||
› Ask Codex to do anything
|
||||
@@ -29,12 +28,11 @@ resuming (48 columns):
|
||||
? for shortcuts
|
||||
---
|
||||
forking (48 columns):
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
Forking session…
|
||||
|
||||
› draft while loading
|
||||
@@ -42,12 +40,11 @@ forking (48 columns):
|
||||
|
||||
---
|
||||
multiline (48 columns):
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
› first startup line
|
||||
@@ -56,12 +53,11 @@ multiline (48 columns):
|
||||
|
||||
---
|
||||
narrow (18 columns):
|
||||
╭────────────────╮
|
||||
│ >_ OpenAI Cod… │
|
||||
│ │
|
||||
│ model: lo… │
|
||||
│ directory: …ng │
|
||||
╰────────────────╯
|
||||
|
||||
>_ OpenAI Codex…
|
||||
loading
|
||||
|
||||
Pull up a promp…
|
||||
|
||||
|
||||
› first startup
|
||||
|
||||
+6
-6
@@ -1,16 +1,16 @@
|
||||
---
|
||||
source: tui/src/startup_draft_tests.rs
|
||||
assertion_line: 802
|
||||
expression: frames
|
||||
---
|
||||
before onboarding: hidden (0x0 viewport)
|
||||
---
|
||||
after onboarding:
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
+12
-13
@@ -1,15 +1,15 @@
|
||||
---
|
||||
source: tui/src/startup_draft_tests.rs
|
||||
assertion_line: 162
|
||||
assertion_line: 194
|
||||
expression: "frames.join(\"\\n---\\n\").replace(crate::version::CODEX_CLI_VERSION, \"<VERSION>\")"
|
||||
---
|
||||
terminal_app_over_ssh=false, owned=true
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,12 +22,11 @@ terminal_app_over_ssh=false, owned=true
|
||||
? for shortcuts
|
||||
---
|
||||
terminal_app_over_ssh=true, owned=false
|
||||
╭───────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v<VERSION>) │
|
||||
│ │
|
||||
│ model: loading /model to change │
|
||||
│ directory: loading │
|
||||
╰───────────────────────────────────────╯
|
||||
|
||||
>_ OpenAI Codex (v<VERSION>)
|
||||
loading
|
||||
|
||||
Pull up a prompt.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) struct StartupScreen {
|
||||
pub(crate) use_alt_screen: bool,
|
||||
pub(crate) transcript_mode: crate::transcript_mode::TranscriptMode,
|
||||
pub(crate) status_line_enabled: bool,
|
||||
pub(crate) welcome_motion: crate::motion::MotionMode,
|
||||
pub(crate) keymap: RuntimeKeymap,
|
||||
pub(crate) disable_paste_burst: bool,
|
||||
}
|
||||
@@ -103,6 +104,8 @@ pub(crate) struct StartupDraft {
|
||||
/// Keeps terminal input responsive and carries one submission intent into the live session.
|
||||
pub(crate) struct StartupDraftPump {
|
||||
header: Box<dyn HistoryCell>,
|
||||
pub(crate) blossom: std::cell::RefCell<crate::empty_state_animation::EmptyStateAnimation>,
|
||||
motion: crate::motion::MotionMode,
|
||||
bottom_pane: BottomPane,
|
||||
events: Pin<Box<dyn Stream<Item = TuiEvent> + Send>>,
|
||||
app_event_rx: UnboundedReceiver<AppEvent>,
|
||||
@@ -135,6 +138,7 @@ impl StartupDraft {
|
||||
tui.terminal_app_over_ssh = initialized_terminal.terminal_app_over_ssh;
|
||||
tui.set_alt_screen_enabled(screen.use_alt_screen);
|
||||
let mut pump = StartupDraftPump::new(&tui, initial_screen, session_action);
|
||||
pump.motion = screen.welcome_motion;
|
||||
pump.bottom_pane
|
||||
.set_status_line_enabled(screen.status_line_enabled);
|
||||
pump.bottom_pane.set_keymap_bindings(&screen.keymap);
|
||||
@@ -193,8 +197,19 @@ impl StartupDraftPump {
|
||||
session_action: StartupDraftSessionAction,
|
||||
) -> Self {
|
||||
let (app_event_tx, app_event_rx) = unbounded_channel();
|
||||
let mut blossom = crate::empty_state_animation::EmptyStateAnimation::default();
|
||||
if matches!(
|
||||
session_action,
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter
|
||||
) {
|
||||
blossom.start_fresh();
|
||||
}
|
||||
let mut header = startup_session_header(/*config*/ None);
|
||||
history_cell::set_session_greeting(header.as_mut(), &blossom.greeting);
|
||||
Self {
|
||||
header: startup_session_header(/*config*/ None),
|
||||
header,
|
||||
blossom: std::cell::RefCell::new(blossom),
|
||||
motion: crate::system_motion::mode(),
|
||||
bottom_pane: startup_draft_bottom_pane(
|
||||
AppEventSender::new(app_event_tx),
|
||||
tui.frame_requester(),
|
||||
@@ -245,7 +260,11 @@ impl StartupDraftPump {
|
||||
}
|
||||
self.configured_cwd = Some(config.cwd.to_path_buf());
|
||||
let local_settings = crate::local_settings::LocalSettings::from(config);
|
||||
self.motion = crate::motion::MotionMode::from_animations_enabled(
|
||||
local_settings.tui.animations && local_settings.tui.effects.welcome,
|
||||
);
|
||||
self.header = startup_session_header(Some(config));
|
||||
history_cell::set_session_greeting(self.header.as_mut(), &self.blossom.borrow().greeting);
|
||||
self.bottom_pane.set_status_line_enabled(
|
||||
local_settings
|
||||
.tui
|
||||
@@ -307,6 +326,11 @@ impl StartupDraftPump {
|
||||
}
|
||||
self.resolved_selection = Some(session_selection.clone());
|
||||
self.session_action = session_action;
|
||||
if matches!(session_selection, SessionSelection::StartFresh)
|
||||
&& !self.blossom.borrow().is_eligible()
|
||||
{
|
||||
self.blossom.borrow_mut().start_fresh();
|
||||
}
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.draw(tui, tui.terminal.last_known_screen_size)?;
|
||||
}
|
||||
@@ -417,8 +441,7 @@ impl StartupDraftPump {
|
||||
}
|
||||
self.bottom_pane.pre_draw_tick();
|
||||
let owned = tui.is_owned_screen();
|
||||
let owned_layout =
|
||||
layout::OwnedStartupLayout::new(&self.header, &self.bottom_pane, self.session_action);
|
||||
let owned_layout = layout::OwnedStartupLayout::new(self);
|
||||
let renderable = if owned {
|
||||
RenderableItem::Borrowed(&owned_layout)
|
||||
} else {
|
||||
@@ -437,6 +460,9 @@ impl StartupDraftPump {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
})?;
|
||||
if let Some(delay) = owned_layout.next_frame.get() {
|
||||
tui.frame_requester().schedule_frame_in(delay);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,9 +161,26 @@ impl StartupDraftPump {
|
||||
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {}
|
||||
TuiEvent::FocusLost => {
|
||||
self.key_chord_matcher.cancel();
|
||||
self.blossom.borrow_mut().cancel_replay();
|
||||
return Ok(());
|
||||
}
|
||||
TuiEvent::Mouse(_) => return Ok(()),
|
||||
TuiEvent::Mouse(mouse) => {
|
||||
if !tui.is_owned_screen()
|
||||
|| self.initial_screen != StartupDraftInitialScreen::Composer
|
||||
|| !matches!(
|
||||
self.session_action,
|
||||
super::StartupDraftSessionAction::New
|
||||
| super::StartupDraftSessionAction::NewFromCommandCenter
|
||||
)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Size, composer, and modal ownership must be current before hit testing.
|
||||
self.draw(tui, screen_size)?;
|
||||
if !self.blossom.borrow_mut().handle_mouse(mouse) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot());
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
//! First-frame owned layout: the banner stays at the top and the normal composer at the bottom.
|
||||
//! Measurement, paint, and cursor placement use the same bottom rectangle, including both footers.
|
||||
//! Only a new conversation paints decoration in the unused area, so it cannot enter scrollback.
|
||||
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::cell::Cell;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::StartupDraftPump;
|
||||
use super::StartupDraftSessionAction;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::CommandPopupPlacement;
|
||||
use crate::bottom_pane::ComposerRenderOptions;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
||||
|
||||
pub(super) struct OwnedStartupLayout<'a> {
|
||||
header: &'a dyn Renderable,
|
||||
pump: &'a StartupDraftPump,
|
||||
bottom: RenderableItem<'a>,
|
||||
session_action: StartupDraftSessionAction,
|
||||
pub(super) next_frame: Cell<Option<Duration>>,
|
||||
}
|
||||
|
||||
impl<'a> OwnedStartupLayout<'a> {
|
||||
pub(super) fn new(
|
||||
header: &'a dyn Renderable,
|
||||
bottom_pane: &'a BottomPane,
|
||||
session_action: StartupDraftSessionAction,
|
||||
) -> Self {
|
||||
pub(super) fn new(pump: &'a StartupDraftPump) -> Self {
|
||||
Self {
|
||||
header,
|
||||
bottom: bottom_pane.as_renderable_with_options(ComposerRenderOptions {
|
||||
separate_status_line: true,
|
||||
command_popup_placement: CommandPopupPlacement::Overlay,
|
||||
..ComposerRenderOptions::default()
|
||||
}),
|
||||
session_action,
|
||||
pump,
|
||||
next_frame: Cell::new(/*value*/ None),
|
||||
bottom: pump
|
||||
.bottom_pane
|
||||
.as_renderable_with_options(ComposerRenderOptions {
|
||||
separate_status_line: true,
|
||||
command_popup_placement: CommandPopupPlacement::Overlay,
|
||||
..ComposerRenderOptions::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn header_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
match self.pump.session_action {
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => {
|
||||
self.pump.header.compact_hyperlink_lines(width)
|
||||
}
|
||||
StartupDraftSessionAction::Resume | StartupDraftSessionAction::Fork => {
|
||||
self.pump.header.display_hyperlink_lines(width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,16 +65,31 @@ impl<'a> OwnedStartupLayout<'a> {
|
||||
impl Renderable for OwnedStartupLayout<'_> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let bottom = self.bottom_area(area);
|
||||
let lines = self.header_lines(area.width);
|
||||
let paragraph = HyperlinkParagraph::new(&lines, Style::default());
|
||||
let header = Rect {
|
||||
height: self
|
||||
.header
|
||||
.desired_height(area.width)
|
||||
height: u16::try_from(paragraph.line_count(area.width))
|
||||
.unwrap_or(u16::MAX)
|
||||
.min(bottom.y.saturating_sub(area.y)),
|
||||
..area
|
||||
};
|
||||
self.header.render(header, buf);
|
||||
let message = match self.session_action {
|
||||
paragraph.render(header, buf);
|
||||
let message = match self.pump.session_action {
|
||||
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => {
|
||||
let composer = (!self.pump.submission_pending)
|
||||
.then(|| self.pump.bottom_pane.empty_state_composer())
|
||||
.flatten();
|
||||
self.next_frame
|
||||
.set(self.pump.blossom.borrow_mut().render_first_screen(
|
||||
Rect {
|
||||
y: header.bottom(),
|
||||
height: bottom.y.saturating_sub(header.bottom()),
|
||||
..area
|
||||
},
|
||||
buf,
|
||||
composer,
|
||||
self.pump.motion,
|
||||
));
|
||||
None
|
||||
}
|
||||
StartupDraftSessionAction::Resume => Some(" Resuming session…"),
|
||||
@@ -80,8 +111,9 @@ impl Renderable for OwnedStartupLayout<'_> {
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.header
|
||||
.desired_height(width)
|
||||
let lines = self.header_lines(width);
|
||||
u16::try_from(HyperlinkParagraph::new(&lines, Style::default()).line_count(width))
|
||||
.unwrap_or(u16::MAX)
|
||||
.saturating_add(self.bottom.desired_height(width))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,7 @@ fn owned_startup_keeps_the_live_bottom_geometry() {
|
||||
("Waiting for startup".into(), String::new()),
|
||||
("esc".into(), "cancel".into()),
|
||||
]));
|
||||
let layout = OwnedStartupLayout::new(
|
||||
&pump.header,
|
||||
&pump.bottom_pane,
|
||||
StartupDraftSessionAction::New,
|
||||
);
|
||||
let layout = OwnedStartupLayout::new(&pump);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 16,
|
||||
);
|
||||
@@ -36,3 +32,106 @@ fn owned_startup_keeps_the_live_bottom_geometry() {
|
||||
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_startup_decoration_tracks_draft_and_session_action() {
|
||||
let mut pump = crate::startup_draft::tests::quiet_startup_test_pump();
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 32,
|
||||
);
|
||||
let mut blossom_visible = Vec::new();
|
||||
for (draft, action, pending) in [
|
||||
("", StartupDraftSessionAction::New, false),
|
||||
(" ", StartupDraftSessionAction::New, false),
|
||||
("", StartupDraftSessionAction::New, false),
|
||||
("", StartupDraftSessionAction::New, true),
|
||||
("", StartupDraftSessionAction::Resume, false),
|
||||
("", StartupDraftSessionAction::Fork, false),
|
||||
("", StartupDraftSessionAction::NewFromCommandCenter, false),
|
||||
] {
|
||||
pump.session_action = action;
|
||||
pump.submission_pending = pending;
|
||||
pump.bottom_pane
|
||||
.set_composer_text(draft.into(), Vec::new(), Vec::new());
|
||||
let layout = OwnedStartupLayout::new(&pump);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
layout.render(area, &mut buffer);
|
||||
blossom_visible.push(buffer.content.iter().any(|cell| {
|
||||
cell.symbol()
|
||||
.chars()
|
||||
.any(|ch| ('\u{2801}'..='\u{28ff}').contains(&ch))
|
||||
}));
|
||||
}
|
||||
assert_eq!(
|
||||
blossom_visible,
|
||||
[true, false, true, false, false, false, true]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_welcome_opt_out_disables_blossom_and_clicks() -> anyhow::Result<()> {
|
||||
use clap::Parser;
|
||||
use codex_config::LoaderOverrides;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
let codex_home = tempfile::tempdir()?;
|
||||
let cli = crate::Cli::try_parse_from(["codex"])?;
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 44,
|
||||
);
|
||||
let click = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 40,
|
||||
row: 22,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
for (settings, enabled) in [
|
||||
("", true),
|
||||
("[tui]\nanimations = false\n", false),
|
||||
("[tui.effects]\nwelcome = false\n", false),
|
||||
(
|
||||
"[tui]\nanimations = false\n[tui.effects]\nwelcome = false\n",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
std::fs::write(codex_home.path().join("config.toml"), settings)?;
|
||||
let presentation = crate::startup_presentation::load(
|
||||
&cli,
|
||||
codex_home.path(),
|
||||
LoaderOverrides {
|
||||
ignore_project_config: true,
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
},
|
||||
Vec::new(),
|
||||
/*config_cwd*/ None,
|
||||
)
|
||||
.await?;
|
||||
let config = crate::legacy_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
let mut pump = crate::startup_draft::tests::quiet_startup_test_pump();
|
||||
// The provisional frame uses the bootstrap value; subsequent frames use effective config.
|
||||
pump.motion = presentation.screen.welcome_motion;
|
||||
for effective in [false, true] {
|
||||
if effective {
|
||||
pump.apply_config(&config);
|
||||
}
|
||||
let mut buffer = Buffer::empty(area);
|
||||
OwnedStartupLayout::new(&pump).render(area, &mut buffer);
|
||||
let visible = buffer.content.iter().any(|cell| {
|
||||
cell.symbol()
|
||||
.chars()
|
||||
.any(|ch| ('\u{2801}'..='\u{28ff}').contains(&ch))
|
||||
});
|
||||
assert_eq!(
|
||||
visible, enabled,
|
||||
"settings: {settings}, effective: {effective}"
|
||||
);
|
||||
assert_eq!(pump.blossom.borrow_mut().handle_mouse(click), enabled);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -28,8 +28,20 @@ where
|
||||
I: Iterator<Item = TuiEvent> + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let mut blossom = crate::empty_state_animation::EmptyStateAnimation::default();
|
||||
blossom
|
||||
.greeting
|
||||
.set(crate::empty_state_animation::Greeting {
|
||||
phrase: "Pull up a prompt.",
|
||||
})
|
||||
.expect("initial greeting");
|
||||
blossom.start_fresh();
|
||||
let mut header = startup_session_header(/*config*/ None);
|
||||
crate::history_cell::set_session_greeting(header.as_mut(), &blossom.greeting);
|
||||
StartupDraftPump {
|
||||
header: startup_session_header(/*config*/ None),
|
||||
header,
|
||||
blossom: std::cell::RefCell::new(blossom),
|
||||
motion: crate::motion::MotionMode::Animated,
|
||||
bottom_pane: startup_draft_bottom_pane(
|
||||
AppEventSender::new(tx),
|
||||
FrameRequester::test_dummy(),
|
||||
@@ -138,11 +150,7 @@ fn startup_draft_renders_full_empty_and_multiline_composer_frames() {
|
||||
#[test]
|
||||
fn terminal_app_ssh_fallback_renders_inline_startup() {
|
||||
let pump = startup_test_pump(std::iter::empty());
|
||||
let owned_layout = super::layout::OwnedStartupLayout::new(
|
||||
&pump.header,
|
||||
&pump.bottom_pane,
|
||||
StartupDraftSessionAction::New,
|
||||
);
|
||||
let owned_layout = super::layout::OwnedStartupLayout::new(&pump);
|
||||
let mut frames = Vec::new();
|
||||
for terminal_app_over_ssh in [false, true] {
|
||||
let owned = crate::determine_alt_screen_mode(
|
||||
@@ -168,6 +176,12 @@ fn terminal_app_ssh_fallback_renders_inline_startup() {
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
renderable.render(area, &mut buffer);
|
||||
assert!(
|
||||
buffer
|
||||
.content
|
||||
.iter()
|
||||
.any(|cell| { cell.symbol() == ">" && cell.fg == crate::style::accent_color() })
|
||||
);
|
||||
let frame = (0..height)
|
||||
.map(|y| {
|
||||
(0..width)
|
||||
@@ -243,6 +257,7 @@ async fn startup_draft_clears_loading_status_when_starting_fresh() {
|
||||
let mut pump = startup_test_pump(std::iter::empty());
|
||||
pump.initial_screen = initial_screen;
|
||||
pump.session_action = session_action;
|
||||
pump.blossom.borrow_mut().dismiss();
|
||||
if initial_screen == StartupDraftInitialScreen::Composer {
|
||||
pump.bottom_pane.insert_str("draft while loading");
|
||||
}
|
||||
@@ -257,6 +272,7 @@ async fn startup_draft_clears_loading_status_when_starting_fresh() {
|
||||
|
||||
pump.update_session_selection(&mut tui, &SessionSelection::StartFresh)
|
||||
.expect("clear the loading status after a fresh-session selection");
|
||||
assert!(pump.blossom.borrow().is_eligible());
|
||||
if initial_screen == StartupDraftInitialScreen::SessionPicker {
|
||||
assert!(tui.terminal.viewport_area.is_empty());
|
||||
pump.show(&mut tui)
|
||||
@@ -291,21 +307,15 @@ async fn startup_draft_hydrates_its_header_without_moving_the_composer() {
|
||||
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),
|
||||
Some("directory: loading".to_string())
|
||||
);
|
||||
assert_eq!(pump.header.raw_lines()[2].to_string().trim(), "loading");
|
||||
pump.apply_config(&config);
|
||||
let expected_directory = format!(
|
||||
"directory: {}",
|
||||
crate::history_cell::SessionHeaderHistoryCell::format_directory_inner(
|
||||
config.cwd.as_path(),
|
||||
/*max_width*/ None,
|
||||
)
|
||||
let expected_directory = crate::history_cell::SessionHeaderHistoryCell::format_directory_inner(
|
||||
config.cwd.as_path(),
|
||||
/*max_width*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
pump.header.raw_lines().last().map(ToString::to_string),
|
||||
Some(expected_directory)
|
||||
pump.header.raw_lines()[2].to_string().trim(),
|
||||
expected_directory
|
||||
);
|
||||
assert_eq!(
|
||||
startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action)
|
||||
|
||||
@@ -523,12 +523,10 @@ pub(super) async fn run_main_inner(
|
||||
daemon_features.retain(|_, enabled| *enabled);
|
||||
let mut managed_daemon = false;
|
||||
if auto_start_daemon && daemon_exclusion.is_none() {
|
||||
startup_draft.flush_pending_events().await?;
|
||||
let output = startup_draft
|
||||
.tui_mut()
|
||||
.with_restored(|| async {
|
||||
// Package installation may print progress; keep ordinary Ctrl+C handling.
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
.run_until(async {
|
||||
// Daemon startup needs no terminal input. Keep the composer visible and
|
||||
// responsive while it checks the running server or prepares an installation.
|
||||
let result = codex_app_server_daemon::start_with_features(&daemon_features).await;
|
||||
daemon_telemetry::record_start(&config, &result).await;
|
||||
match result {
|
||||
@@ -543,7 +541,7 @@ pub(super) async fn run_main_inner(
|
||||
))),
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
.await??;
|
||||
if let Some(output) = output {
|
||||
managed_daemon = output.backend.is_some();
|
||||
app_server_target = AppServerTarget::LocalDaemon {
|
||||
|
||||
@@ -81,6 +81,13 @@ pub(super) async fn load(
|
||||
.disable_paste_burst
|
||||
.or(config_toml.disable_paste_burst)
|
||||
.unwrap_or(/*default*/ false);
|
||||
let welcome_motion = crate::motion::MotionMode::from_animations_enabled(
|
||||
config_toml
|
||||
.tui
|
||||
.as_ref()
|
||||
.is_none_or(|tui| tui.animations && tui.effects.welcome)
|
||||
&& crate::system_motion::mode() == crate::motion::MotionMode::Animated,
|
||||
);
|
||||
Ok(StartupPresentation {
|
||||
bootstrap_config,
|
||||
config_cwd,
|
||||
@@ -88,6 +95,7 @@ pub(super) async fn load(
|
||||
use_alt_screen,
|
||||
transcript_mode,
|
||||
status_line_enabled,
|
||||
welcome_motion,
|
||||
keymap,
|
||||
disable_paste_burst,
|
||||
},
|
||||
|
||||
@@ -149,6 +149,19 @@ impl Default for TranscriptView {
|
||||
}
|
||||
|
||||
impl TranscriptView {
|
||||
/// Rows left after the last render, including all startup notices and live entries.
|
||||
/// Callers can paint temporary UI here without changing selection or saved history.
|
||||
pub(crate) fn remaining_area(&self) -> Rect {
|
||||
let used = u16::try_from(self.visible.len())
|
||||
.unwrap_or(u16::MAX)
|
||||
.min(self.area.height);
|
||||
Rect {
|
||||
y: self.area.y + used,
|
||||
height: self.area.height - used,
|
||||
..self.area
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cells: &[Arc<dyn HistoryCell>]) {
|
||||
self.composer_tip = None;
|
||||
self.cache.begin_frame();
|
||||
@@ -543,6 +556,15 @@ impl TranscriptView {
|
||||
.layout(cells, index)
|
||||
.map_or(/*default*/ 0, |l| l.row_count());
|
||||
}
|
||||
// At the beginning, hidden entries are not preceding content. Keep the first
|
||||
// visible entry at the same position whether it is live or committed.
|
||||
if index == 0
|
||||
&& remaining >= row
|
||||
&& let Some(first) = self.next_nonempty(cells, index)
|
||||
&& let Some(layout) = self.layout(cells, first)
|
||||
{
|
||||
return (first, usize::from(layout.separated));
|
||||
}
|
||||
return (index, row.saturating_sub(remaining));
|
||||
}
|
||||
let mut remaining = row.saturating_add(rows as usize);
|
||||
|
||||
@@ -156,10 +156,16 @@ impl TranscriptView {
|
||||
width,
|
||||
expanded,
|
||||
)
|
||||
} else if detailed || mode == HistoryRenderMode::Rich {
|
||||
TextLayout::new(cell.retained_hyperlink_lines(width, detailed), width)
|
||||
} else {
|
||||
TextLayout::new(cell.display_hyperlink_lines_for_mode(width, mode), width)
|
||||
TextLayout::new(
|
||||
crate::history_cell::fullscreen_session_lines(
|
||||
cell.as_ref(),
|
||||
width,
|
||||
detailed,
|
||||
mode,
|
||||
),
|
||||
width,
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -319,15 +319,19 @@ impl TranscriptView {
|
||||
Some(pending.follow && completion.1 == Ok(crate::clipboard_copy::CopyStatus::Confirmed))
|
||||
}
|
||||
|
||||
/// End the pointer gesture without discarding selected text when input ownership changes.
|
||||
pub(crate) fn end_drag(&mut self) {
|
||||
self.follow_control = Default::default();
|
||||
if !self.has_selection_range()
|
||||
/// A stationary click begun at Latest still belongs to the fresh screen until it selects text.
|
||||
pub(crate) fn has_pending_latest_selection(&self) -> bool {
|
||||
!self.has_selection_range()
|
||||
&& self
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.dragging && selection.resume_on_empty)
|
||||
{
|
||||
}
|
||||
|
||||
/// End the pointer gesture without discarding selected text when input ownership changes.
|
||||
pub(crate) fn end_drag(&mut self) {
|
||||
self.follow_control = Default::default();
|
||||
if self.has_pending_latest_selection() {
|
||||
self.position = Position::Latest;
|
||||
self.selection = None;
|
||||
self.release_live_reading();
|
||||
|
||||
@@ -63,6 +63,60 @@ pub(super) fn text(buffer: &Buffer) -> String {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_warning_keeps_first_visible_header_in_place() {
|
||||
let header: Arc<dyn HistoryCell> =
|
||||
Arc::new(crate::history_cell::SessionHeaderHistoryCell::new(
|
||||
"gpt-test".into(),
|
||||
/*reasoning_effort*/ None,
|
||||
/*show_fast_status*/ false,
|
||||
crate::test_support::test_path_buf("/project"),
|
||||
"test",
|
||||
));
|
||||
let mut view = TranscriptView::default();
|
||||
view.sync_live_tail(/*width*/ 80, /*key*/ None, |width| {
|
||||
Some(header.compact_hyperlink_lines(width))
|
||||
});
|
||||
let mut cells = Vec::new();
|
||||
let before = render(&mut view, &cells, /*width*/ 80, /*height*/ 24);
|
||||
cells.push(Arc::new(crate::history_cell::StartupWarningsCell::new(vec![
|
||||
"Skill manifest is invalid.".into(),
|
||||
])) as Arc<dyn HistoryCell>);
|
||||
assert_eq!(
|
||||
render(&mut view, &cells, /*width*/ 80, /*height*/ 24),
|
||||
before
|
||||
);
|
||||
view.sync_live_tail(/*width*/ 80, /*key*/ None, |_| None);
|
||||
cells.insert(/*index*/ 0, header);
|
||||
assert_eq!(
|
||||
render(&mut view, &cells, /*width*/ 80, /*height*/ 24),
|
||||
before
|
||||
);
|
||||
assert_eq!(crate::history_cell::warning_count(&cells), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_initial_entries_do_not_add_a_separator_but_visible_entries_do() {
|
||||
let cells = vec![cell(""), cell(""), cell("First"), cell(""), cell("Second")];
|
||||
let mut view = TranscriptView::default();
|
||||
view.sync_live_tail(
|
||||
/*width*/ 20,
|
||||
/*key*/ None,
|
||||
|_| Some(vec![HyperlinkLine::from("Live")]),
|
||||
);
|
||||
let frame = render(&mut view, &cells, /*width*/ 20, /*height*/ 5);
|
||||
insta::assert_snapshot!(text(&frame), @"
|
||||
First
|
||||
|
||||
Second
|
||||
|
||||
Live
|
||||
");
|
||||
// Scrolling past the first visible entry must stop at its content, not its separator.
|
||||
view.scroll(&cells, /*rows*/ -100);
|
||||
assert_eq!(render(&mut view, &cells, /*width*/ 20, /*height*/ 5), frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_survives_prepend_and_new_output_then_returns_to_latest() {
|
||||
let mut cells = vec![cell("older\nline two"), cell("current\nlast line")];
|
||||
|
||||
@@ -211,6 +211,7 @@ async fn startup_typeahead_pty_child() {
|
||||
use_alt_screen: false,
|
||||
transcript_mode: crate::transcript_mode::TranscriptMode::Terminal,
|
||||
status_line_enabled: true,
|
||||
welcome_motion: crate::motion::MotionMode::Animated,
|
||||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||||
disable_paste_burst: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user