Compare commits

...

3 Commits

Author SHA1 Message Date
Felipe Coury
ce870fad20 docs(tui): explain startup mascot lifecycle 2026-05-30 20:13:40 -03:00
Felipe Coury
055482eac7 fix(tui): preserve header fallback directory width 2026-05-30 20:05:09 -03:00
Felipe Coury
bb76d3ed69 feat(tui): animate startup header mascot 2026-05-30 19:12:58 -03:00
15 changed files with 868 additions and 61 deletions

View File

@@ -56,6 +56,7 @@ use crate::bottom_pane::StatusSurfacePreviewData;
use crate::bottom_pane::StatusSurfacePreviewItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::codex_logo;
use crate::diff_model::FileChange;
use crate::git_action_directives::parse_assistant_markdown;
use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME;
@@ -632,6 +633,9 @@ pub(crate) struct ChatWidget {
forked_from: Option<ThreadId>,
interrupted_turn_notice_mode: InterruptedTurnNoticeMode,
frame_requester: FrameRequester,
// Shared by the placeholder and configured headers so startup motion does not restart while
// session metadata arrives.
startup_logo_animation: Option<codex_logo::StartupAnimation>,
// Whether to include the initial welcome banner on session configured
show_welcome_banner: bool,
// One-shot tooltip override for the primary startup session.
@@ -1157,6 +1161,31 @@ impl ChatWidget {
if let Some(pet) = self.ambient_pet.as_ref() {
pet.schedule_next_frame();
}
// Session-header cells expose a tick only while the one-shot mascot motion is active.
// Once it expires, settle configured session info into stable transcript history.
if self.startup_logo_animation.is_some() {
if self
.transcript
.active_cell
.as_ref()
.and_then(|cell| cell.transcript_animation_tick())
.is_some()
{
self.frame_requester
.schedule_frame_in(codex_logo::animation_frame_interval());
} else {
self.startup_logo_animation = None;
if self
.transcript
.active_cell
.as_ref()
.is_some_and(|cell| cell.as_any().is::<history_cell::SessionInfoCell>())
{
self.flush_active_cell();
}
self.request_redraw();
}
}
self.refresh_plan_mode_nudge();
self.refresh_goal_status_indicator_for_time_tick();
if self.terminal_title_shows_action_required() != self.last_terminal_title_requires_action {
@@ -1170,7 +1199,8 @@ impl ChatWidget {
}
fn flush_active_cell(&mut self) {
if let Some(active) = self.transcript.active_cell.take() {
if let Some(mut active) = self.transcript.active_cell.take() {
active.finalize_for_history();
self.transcript.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
}
@@ -1449,22 +1479,30 @@ impl ChatWidget {
}
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
fn placeholder_session_header_cell(
config: &Config,
startup_logo_animation: Option<codex_logo::StartupAnimation>,
) -> Box<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
Box::new(
history_cell::SessionHeaderHistoryCell::new_with_style(
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
placeholder_style,
/*reasoning_effort*/ None,
/*show_fast_status*/ false,
config.cwd.to_path_buf(),
CODEX_CLI_VERSION,
)
.with_yolo_mode(history_cell::is_yolo_mode(config)),
let mut header = history_cell::SessionHeaderHistoryCell::new_with_style(
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
placeholder_style,
/*reasoning_effort*/ None,
/*show_fast_status*/ false,
config.cwd.to_path_buf(),
CODEX_CLI_VERSION,
)
.with_yolo_mode(history_cell::is_yolo_mode(config));
if let Some(animation) = startup_logo_animation {
header = header.with_startup_logo_animation(animation);
}
Box::new(header)
}
/// Merge the real session info cell with any placeholder header to avoid double boxes.
/// Merge configured session info with the placeholder while preserving startup mascot motion.
///
/// The configured header remains active until the shared startup animation settles, avoiding a
/// second header box and keeping transcript history static after the eventual flush.
fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) {
let mut session_info_cell = Some(Box::new(cell) as Box<dyn HistoryCell>);
let merged_header = if let Some(active) = self.transcript.active_cell.take() {
@@ -1485,6 +1523,21 @@ impl ChatWidget {
false
};
if self.startup_logo_animation.is_some()
&& self
.transcript
.active_cell
.as_ref()
.and_then(|cell| cell.transcript_animation_tick())
.is_some()
{
self.frame_requester
.schedule_frame_in(codex_logo::animation_frame_interval());
self.request_redraw();
return;
}
self.startup_logo_animation = None;
self.flush_active_cell();
if !merged_header && let Some(cell) = session_info_cell {

View File

@@ -61,7 +61,12 @@ impl ChatWidget {
settings: fallback_default,
};
let active_cell = Some(Self::placeholder_session_header_cell(&config));
// Select startup motion once so the placeholder and configured headers share one clock.
let startup_logo_animation = config.animations.then(codex_logo::startup_animation);
let active_cell = Some(Self::placeholder_session_header_cell(
&config,
startup_logo_animation,
));
let current_cwd = Some(config.cwd.to_path_buf());
let effective_service_tier = crate::service_tier_resolution::effective_service_tier(
@@ -181,6 +186,7 @@ impl ChatWidget {
side_placeholder_text: side_placeholder,
forked_from: None,
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
startup_logo_animation,
input_queue: InputQueueState::default(),
chat_keymap,
queued_message_edit_hint_binding,

View File

@@ -111,7 +111,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,
&model_for_header,
&session,
@@ -120,6 +120,10 @@ impl ChatWidget {
self.plan_type,
show_fast_status,
);
if let Some(animation) = self.startup_logo_animation {
// Continue the placeholder's original motion instead of restarting at handoff.
session_info_cell = session_info_cell.with_startup_logo_animation(animation);
}
self.apply_session_info_cell(session_info_cell);
} else if self
.transcript

View File

@@ -449,6 +449,19 @@ async fn configured_pet_load_is_deferred_until_after_construction() {
);
}
#[tokio::test]
async fn placeholder_session_header_animates_while_session_is_configuring() {
let mut cfg = test_config().await;
cfg.animations = true;
let cell = ChatWidget::placeholder_session_header_cell(
&cfg,
/*startup_logo_animation*/ Some(codex_logo::startup_animation()),
);
assert!(cell.transcript_animation_tick().is_some());
}
#[tokio::test]
async fn prefetch_rate_limits_is_gated_on_chatgpt_auth_provider() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -0,0 +1,232 @@
//! Unicode mascot rendering primitives for the startup session header.
//!
//! This module owns the small amount of runtime state needed to animate the generated frame
//! tables in `codex_logo_frames`. It does not decide where the mascot is rendered or schedule
//! redraws; the session-header cell renders the selected frame and `ChatWidget` drives the
//! lifecycle while the header remains active.
//!
//! A startup animation is intentionally copyable. The placeholder header created before session
//! configuration and the configured session header must share the same selected motion and
//! `Instant`, otherwise the mascot can restart or switch motions when session metadata arrives.
//! Once the fixed startup window expires, callers render `STATIC_FRAME` and stop scheduling
//! mascot redraws.
use crate::codex_logo_frames::BLINK_FRAMES;
use crate::codex_logo_frames::READ_BELOW_FRAMES;
use crate::codex_logo_frames::THINKING_FRAMES;
use crate::codex_logo_frames::WORKING_FRAMES;
use crate::color;
use rand::Rng;
use std::time::Duration;
use std::time::Instant;
pub(crate) use crate::codex_logo_frames::HEIGHT;
pub(crate) use crate::codex_logo_frames::LogoFrame;
pub(crate) use crate::codex_logo_frames::WIDTH;
/// Number of columns reserved between the mascot and session text.
pub(crate) const GAP_WIDTH: usize = 1;
const ANIMATION_FRAME_MILLIS: u64 = 200;
const STARTUP_ANIMATION_LOOPS: u64 = 2;
const FRAME_COUNT: usize = 8;
const HIGHLIGHT_PERIOD_FRAMES: u64 = HEIGHT as u64 + 4;
const BRIGHT_GRADIENT: [(u8, u8, u8); HEIGHT] = [
(153, 161, 255),
(136, 157, 255),
(119, 148, 255),
(100, 130, 255),
(72, 92, 253),
(61, 78, 249),
];
const DARK_GRADIENT: [(u8, u8, u8); HEIGHT] = [
(70, 84, 202),
(58, 96, 214),
(47, 108, 222),
(40, 101, 218),
(30, 72, 194),
(27, 57, 176),
];
/// Motion sequences eligible for the one-shot startup animation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StartupAnimationKind {
ReadBelow,
Thinking,
Working,
}
/// Selected startup motion and the clock origin shared across header handoffs.
///
/// Keep this value intact when replacing the placeholder header with configured session
/// information. Constructing another value during that handoff would restart the animation and
/// could choose a different motion.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct StartupAnimation {
kind: StartupAnimationKind,
start: Instant,
}
const STARTUP_ANIMATION_KINDS: [StartupAnimationKind; 3] = [
StartupAnimationKind::Working,
StartupAnimationKind::Thinking,
StartupAnimationKind::ReadBelow,
];
/// Reference frame rendered after startup motion settles or when animations are disabled.
pub(crate) const STATIC_FRAME: LogoFrame = BLINK_FRAMES[0];
/// Starts one randomly selected mascot motion at the current instant.
pub(crate) fn startup_animation() -> StartupAnimation {
let mut rng = rand::rng();
StartupAnimation {
kind: STARTUP_ANIMATION_KINDS[rng.random_range(0..STARTUP_ANIMATION_KINDS.len())],
start: Instant::now(),
}
}
/// Returns the total lifetime of the one-shot startup motion.
pub(crate) fn startup_animation_duration() -> Duration {
Duration::from_millis(ANIMATION_FRAME_MILLIS * FRAME_COUNT as u64 * STARTUP_ANIMATION_LOOPS)
}
/// Returns the redraw cadence expected while startup motion is active.
pub(crate) fn animation_frame_interval() -> Duration {
Duration::from_millis(ANIMATION_FRAME_MILLIS)
}
/// Returns the current frame tick while startup motion remains active.
///
/// `None` means the caller should settle the header to `STATIC_FRAME` and stop scheduling redraws.
pub(crate) fn animation_tick(animation: StartupAnimation) -> Option<u64> {
let elapsed = animation.start.elapsed();
if elapsed >= startup_animation_duration() {
None
} else {
Some(elapsed.as_millis() as u64 / ANIMATION_FRAME_MILLIS)
}
}
/// Resolves the generated mascot frame for an active animation tick.
pub(crate) fn frame_for_tick(animation: StartupAnimation, tick: u64) -> &'static LogoFrame {
&animation_frames(animation.kind)[tick as usize % FRAME_COUNT]
}
/// Selects a readable static mascot gradient for the terminal background.
pub(crate) fn gradient_for_bg(bg: Option<(u8, u8, u8)>) -> [(u8, u8, u8); HEIGHT] {
if bg.is_some_and(color::is_light) {
DARK_GRADIENT
} else {
BRIGHT_GRADIENT
}
}
/// Selects the mascot gradient for an active tick, including the moving highlight row.
pub(crate) fn gradient_for_animation_tick(
bg: Option<(u8, u8, u8)>,
tick: u64,
) -> [(u8, u8, u8); HEIGHT] {
let mut gradient = gradient_for_bg(bg);
let highlight_row = tick % HIGHLIGHT_PERIOD_FRAMES;
for (row, rgb) in gradient.iter_mut().enumerate() {
let distance = row.abs_diff(highlight_row as usize);
if distance <= 1 {
*rgb = brighten(
*rgb,
if distance == 0 {
/*amount*/
36
} else {
/*amount*/
16
},
);
}
}
gradient
}
fn animation_frames(kind: StartupAnimationKind) -> &'static [LogoFrame; FRAME_COUNT] {
match kind {
StartupAnimationKind::ReadBelow => &READ_BELOW_FRAMES,
StartupAnimationKind::Thinking => &THINKING_FRAMES,
StartupAnimationKind::Working => &WORKING_FRAMES,
}
}
fn brighten((r, g, b): (u8, u8, u8), amount: u8) -> (u8, u8, u8) {
(
r.saturating_add(amount),
g.saturating_add(amount),
b.saturating_add(amount),
)
}
#[cfg(test)]
mod tests {
use super::*;
use unicode_width::UnicodeWidthStr;
#[test]
fn frames_keep_14_by_6_geometry() {
for kind in STARTUP_ANIMATION_KINDS {
for frame in animation_frames(kind) {
assert_eq!(frame.len(), HEIGHT);
for line in frame {
assert_eq!(UnicodeWidthStr::width(*line), WIDTH);
}
}
}
for line in STATIC_FRAME {
assert_eq!(UnicodeWidthStr::width(line), WIDTH);
}
}
#[test]
fn source_animations_have_distinct_frames() {
for kind in STARTUP_ANIMATION_KINDS {
let frames = animation_frames(kind);
assert!(frames.windows(2).any(|frames| frames[0] != frames[1]));
}
}
#[test]
fn animation_wraps_and_settles() {
let animation = StartupAnimation {
kind: StartupAnimationKind::Working,
start: Instant::now(),
};
assert_eq!(
frame_for_tick(animation, /*tick*/ 0),
frame_for_tick(animation, FRAME_COUNT as u64)
);
let completed_animation = StartupAnimation {
kind: StartupAnimationKind::Working,
start: Instant::now()
.checked_sub(startup_animation_duration())
.expect("duration should fit"),
};
assert_eq!(animation_tick(completed_animation), None);
}
#[test]
fn copied_animation_preserves_kind_and_origin() {
let animation = StartupAnimation {
kind: StartupAnimationKind::Thinking,
start: Instant::now(),
};
let copied_animation = animation;
assert_eq!(copied_animation, animation);
}
#[test]
fn animation_tick_changes_gradient() {
assert_ne!(
gradient_for_animation_tick(/*bg*/ None, /*tick*/ 0),
gradient_for_animation_tick(/*bg*/ None, /*tick*/ 1)
);
}
}

View File

@@ -0,0 +1,273 @@
// @generated by scripts/export-ansi-mascot-frames.mjs; do not edit.
pub(crate) const HEIGHT: usize = 6;
pub(crate) const WIDTH: usize = 14;
pub(crate) type LogoFrame = [&'static str; HEIGHT];
pub(crate) const BLINK_FRAMES: [LogoFrame; 8] = [
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣇⠙⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⡏⣠⣿⣟⠛⠛⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⠈⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⣿⢀⣿⣟⠛⠛⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⠁⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⣿⡀⣿⣟⠛⠛⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⣿⡇⣿⣟⠛⠛⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⠃⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⣿⡄⣿⣟⠛⠛⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⠈⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⣿⢀⣿⣟⠛⠛⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣧⠙⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⡟⣠⣿⣟⠛⠛⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣇⠙⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⡏⣠⣿⣟⠛⠛⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
];
pub(crate) const READ_BELOW_FRAMES: [LogoFrame; 8] = [
[
" ⢀⣴⣶⣶⣶⣤⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⡇⢸⣿ ⣿⣿⣿⣷⡀",
" ⢹⣿⣿⣿⣧⣼⣿⣴⣿⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⣠⣴⣶⣦⣄⣀⣀ ",
" ⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⡄ ",
" ⣾⣿⣿⣿⡏⢹⣿⠉⣿⣿⣿⣧ ",
" ⢻⣿⣿⣿⣧⣼⣿⣤⣿⣿⣿⣿⡇",
" ⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟ ",
" ⠈⠛⠻⠿⢿⣿⣿⡿⠋ ",
],
[
" ⣠⣤⣶⣦⣄⣀⣀ ",
" ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣆ ",
" ⣾⣿⣿⣿⣿⡟⢻⣿⠛⣿⣿⣿⡀",
" ⠹⣿⣿⣿⣿⣇⣸⣿⣀⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁",
" ⠈⠙⠻⠿⢿⣿⣿⡿⠟ ",
],
[
" ⣠⣤⣶⣦⣄⣀⣀ ",
" ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣆ ",
" ⣾⣿⣿⣿⣿⡟⢻⣿⠛⣿⣿⣿⡀",
" ⠹⣿⣿⣿⣿⣇⣸⣿⣀⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁",
" ⠈⠙⠻⠿⢿⣿⣿⡿⠟ ",
],
[
" ⣠⣤⣶⣦⣄⣀⣀ ",
" ⢀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⡄ ",
" ⣾⣿⣿⣿⣿⠛⣿⡟⢻⣿⣿⣿⡀",
" ⠻⣿⣿⣿⣿⣀⣿⣇⣸⣿⣿⣿⡇",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁",
" ⠈⠛⠻⠿⢿⣿⣿⡿⠟ ",
],
[
" ⣠⣴⣶⣤⣄⣀⣀ ",
" ⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣷⡄ ",
" ⣿⣿⣿⡟⢻⣿⠛⣿⣿⣿⣿⣧ ",
" ⢻⣿⣿⣇⣸⣿⣀⣿⣿⣿⣿⣿⡇",
" ⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟ ",
" ⠈⠛⠻⠿⢿⣿⣿⡿⠋ ",
],
[
" ⣠⣴⣶⣤⣄⣀⣀ ",
" ⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣷⡄ ",
" ⣿⣿⣿⡟⢻⣿⠛⣿⣿⣿⣿⣧ ",
" ⢻⣿⣿⣇⣸⣿⣀⣿⣿⣿⣿⣿⡇",
" ⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟ ",
" ⠈⠛⠻⠿⢿⣿⣿⡿⠋ ",
],
[
" ⣠⣴⣶⣦⣄⣀⣀ ",
" ⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣷⡄ ",
" ⣾⣿⣿⣿⠋⣿⡏⢹⣿⣿⣿⣧ ",
" ⢻⣿⣿⣿⣄⣿⣧⣸⣿⣿⣿⣿⡇",
" ⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟ ",
" ⠈⠛⠛⠿⢿⣿⣿⠿⠋ ",
],
];
pub(crate) const THINKING_FRAMES: [LogoFrame; 8] = [
[
" ⢀⣴⣾⣿⣷⣦⣤⣤⡀ ",
" ⢠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⠿⣿⢿⡿⢿⣿⣿⣷⡀",
" ⢹⣿⣿⣿⣷⣿⣾⣷⣿⣿⣿⣿⠇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠙⠛⠻⠿⠿⠟⠋ ",
],
[
" ⣀⣠⣶⣿⣿⣷⣦⣀⡀ ",
" ⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦ ",
" ⢻⣿⣿⣿⠿⣿⢿⡿⣿⣿⣿⣿ ",
" ⢸⣿⣿⣿⣷⣿⣾⣷⣿⣿⣿⣿ ",
" ⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠏ ",
" ⠉⠻⠿⠿⠿⠋⠁ ",
],
[
" ⢀⣠⣤⣴⣾⣿⣿⣶⣄ ",
" ⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦ ",
" ⣸⣿⣿⣿⠿⡿⢿⡿⣿⣿⣿⣿⠆",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟ ",
" ⠈⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⠃ ",
" ⠈⠛⠿⠿⠿⠛⠋⠉ ",
],
[
" ⣤⣶⣶⣶⣶⣿⣶⣦⡀ ",
" ⣼⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄ ",
" ⣾⣿⣿⣿⠿⣿⢿⡿⣿⣿⣿⣿⡆",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃",
" ⠹⣿⣿⣿⣿⣿⣿⣿⣿⡿ ",
" ⠈⠛⠿⠟⠛⠛⠛⠋ ",
],
[
" ⢠⣶⣿⣿⣿⣶⣶⣤⣄ ",
" ⢠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⠿⣿⢿⡿⢿⣿⣿⣷⡄",
" ⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇",
" ⢻⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋ ",
" ⠈⠙⠛⠛⠿⠿⠟⠋ ",
],
[
" ⢀⣠⣤⣴⣾⣿⣿⣷⣄ ",
" ⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦ ",
" ⣰⣿⣿⣿⡿⣿⢿⡿⢿⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ",
" ⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⠏ ",
" ⠙⠿⠿⠿⠛⠛⠉⠁ ",
],
[
" ⢀⣴⣾⣿⣿⣷⣤⣤⣀ ",
" ⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧ ",
" ⢿⣿⣿⣿⡿⣿⢿⡿⢿⣿⣿⣿ ",
" ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇",
" ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋ ",
" ⠈⠉⠙⠿⠿⠿⠛⠁ ",
],
[
" ⢀⣴⣾⣿⣷⣦⣤⣤⡀ ",
" ⢠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆ ",
" ⣿⣿⣿⣿⠿⣿⢿⡿⢿⣿⣿⣿⡀",
" ⢹⣿⣿⣿⣿⣿⣾⣷⣿⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠙⠛⠻⠿⠿⠟⠋ ",
],
];
pub(crate) const WORKING_FRAMES: [LogoFrame; 8] = [
[
" ⢀⣴⣶⣿⣶⣦⣤⣤⡀ ",
" ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⡟⣁⡙⣿⡏⣁⣙⣿⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣤⣶⣶⣶⣦⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣆ ",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀",
" ⢹⣿⣟⣁⡙⣿⡟⣁⡙⣿⣿⣿⡇",
" ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣤⣶⣶⣶⣦⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣦ ",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄",
" ⢹⣿⣟⣁⡉⣿⡟⢉⡙⣿⣿⣿⡇",
" ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛ ",
" ⠉⠙⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣤⣶⣶⣶⣦⣤⣄⡀ ",
" ⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣆ ",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀",
" ⢹⣿⣿⡟⢉⡙⣿⡟⢉⠙⣿⣿⡇",
" ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣿⣶⣦⣤⣤⡀ ",
" ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⡟⢉⠙⣿⡟⢉⠙⣿⣿⡇",
" ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣷⣶⣤⣤⣤⡀ ",
" ⢠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⡟⢉⡙⣿⡟⢉⡙⣿⣿⠇",
" ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
[
" ⢀⣴⣶⣶⣶⣤⣤⣤⡀ ",
" ⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢻⣿⣿⣁⣌⣻⣿⣡⣌⣻⣿⣿⡇",
" ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠉ ",
],
[
" ⢀⣴⣶⣷⣶⣤⣤⣤⡀ ",
" ⢠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ ",
" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀",
" ⢹⣿⣿⣁⣌⣻⣟⣡⣌⣻⣿⣿⠇",
" ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋ ",
" ⠉⠛⠛⠻⠿⠿⠟⠋ ",
],
];

View File

@@ -162,4 +162,16 @@ impl HistoryCell for CompositeHistoryCell {
}
out
}
fn transcript_animation_tick(&self) -> Option<u64> {
self.parts
.iter()
.find_map(|part| part.transcript_animation_tick())
}
fn finalize_for_history(&mut self) {
for part in &mut self.parts {
part.finalize_for_history();
}
}
}

View File

@@ -295,6 +295,14 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
fn transcript_animation_tick(&self) -> Option<u64> {
None
}
/// Settles transient visuals before an active cell is committed to transcript history.
///
/// Implementations with time-dependent rendering should discard that state here so committed
/// history remains stable and does not require redraw scheduling. `ChatWidget` calls this when
/// flushing an active cell; forgetting to forward it through wrapper cells can leave transcript
/// history dependent on an expired animation clock.
fn finalize_for_history(&mut self) {}
}
impl Renderable for Box<dyn HistoryCell> {

View File

@@ -1,8 +1,12 @@
//! Session headers, onboarding guidance, and transcript cards.
use super::*;
use crate::codex_logo;
use crate::terminal_palette;
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
const SESSION_HEADER_TEXT_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize =
codex_logo::WIDTH + codex_logo::GAP_WIDTH + SESSION_HEADER_TEXT_MAX_INNER_WIDTH;
pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
if width < 4 {
@@ -116,9 +120,33 @@ impl HistoryCell for TooltipHistoryCell {
}
}
/// Session header plus any startup guidance or tooltip cells rendered with it.
#[derive(Debug)]
pub struct SessionInfoCell(CompositeHistoryCell);
impl SessionInfoCell {
/// Preserves a startup mascot animation when configured session information replaces a placeholder.
///
/// `new_session_info` always includes one `SessionHeaderHistoryCell`. This method locates that
/// nested header so the placeholder and configured header can share the same selected motion
/// and clock origin. If the session-info composition changes, keep this propagation path
/// intact or the startup mascot will stop or restart during the handoff.
pub(crate) fn with_startup_logo_animation(
mut self,
animation: codex_logo::StartupAnimation,
) -> Self {
if let Some(header) = self
.0
.parts
.iter_mut()
.find_map(|part| part.as_any_mut().downcast_mut::<SessionHeaderHistoryCell>())
{
header.logo_animation = Some(animation);
}
self
}
}
impl HistoryCell for SessionInfoCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.display_lines(width)
@@ -135,6 +163,14 @@ impl HistoryCell for SessionInfoCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
self.0.raw_lines()
}
fn transcript_animation_tick(&self) -> Option<u64> {
self.0.transcript_animation_tick()
}
fn finalize_for_history(&mut self) {
self.0.finalize_for_history();
}
}
pub(crate) fn new_session_info(
@@ -237,6 +273,11 @@ pub(crate) fn has_yolo_permissions(
}
)
}
/// Header card rendered while a session is configuring and after its metadata arrives.
///
/// The same type is used for the dim placeholder and configured header so both layouts follow the
/// same mascot width and fallback rules. `logo_animation` is transient: active cells may render
/// time-dependent frames, while committed history must be finalized to the static reference frame.
#[derive(Debug)]
pub(crate) struct SessionHeaderHistoryCell {
version: &'static str,
@@ -246,6 +287,7 @@ pub(crate) struct SessionHeaderHistoryCell {
show_fast_status: bool,
directory: PathBuf,
yolo_mode: bool,
logo_animation: Option<codex_logo::StartupAnimation>,
}
impl SessionHeaderHistoryCell {
@@ -282,6 +324,7 @@ impl SessionHeaderHistoryCell {
show_fast_status,
directory,
yolo_mode: false,
logo_animation: None,
}
}
@@ -290,6 +333,27 @@ impl SessionHeaderHistoryCell {
self
}
/// Attaches transient startup mascot state to this header.
///
/// Pass through the original `StartupAnimation` when replacing a placeholder header. Creating
/// a new value here would restart the clock and may switch the selected motion mid-startup.
pub(crate) fn with_startup_logo_animation(
mut self,
animation: codex_logo::StartupAnimation,
) -> Self {
self.logo_animation = Some(animation);
self
}
fn finalize_logo_animation(&mut self) {
self.logo_animation = None;
}
fn logo_animation_tick(&self) -> Option<(codex_logo::StartupAnimation, u64)> {
let animation = self.logo_animation?;
codex_logo::animation_tick(animation).map(|tick| (animation, tick))
}
fn format_directory(&self, max_width: Option<usize>) -> String {
Self::format_directory_inner(&self.directory, max_width)
}
@@ -334,6 +398,12 @@ impl HistoryCell for SessionHeaderHistoryCell {
let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else {
return Vec::new();
};
let show_logo = inner_width > codex_logo::WIDTH + codex_logo::GAP_WIDTH;
let text_width = if show_logo {
inner_width.saturating_sub(codex_logo::WIDTH + codex_logo::GAP_WIDTH)
} else {
inner_width
};
let make_row = |spans: Vec<Span<'static>>| Line::from(spans);
@@ -383,26 +453,69 @@ impl HistoryCell for SessionHeaderHistoryCell {
let dir_label = format!("{DIR_LABEL:<label_width$}");
let dir_prefix = format!("{dir_label} ");
let dir_prefix_width = UnicodeWidthStr::width(dir_prefix.as_str());
let dir_max_width = inner_width.saturating_sub(dir_prefix_width);
let dir = self.format_directory(Some(dir_max_width));
let dir_spans = vec![Span::from(dir_prefix).dim(), Span::from(dir)];
let build_text_lines = |width: usize| {
let dir_max_width = width.saturating_sub(dir_prefix_width);
let dir = self.format_directory(/*max_width*/ Some(dir_max_width));
let dir_spans = vec![Span::from(dir_prefix.clone()).dim(), Span::from(dir)];
let mut lines = vec![
make_row(title_spans.clone()),
make_row(Vec::new()),
make_row(model_spans.clone()),
make_row(dir_spans),
];
let mut lines = vec![
make_row(title_spans),
make_row(Vec::new()),
make_row(model_spans),
make_row(dir_spans),
];
if self.yolo_mode {
let permissions_label = format!("{PERMISSIONS_LABEL:<label_width$}");
lines.push(make_row(vec![
Span::from(format!("{permissions_label} ")).dim(),
"YOLO mode".magenta().bold(),
]));
}
lines
};
if self.yolo_mode {
let permissions_label = format!("{PERMISSIONS_LABEL:<label_width$}");
lines.push(make_row(vec![
Span::from(format!("{permissions_label} ")).dim(),
"YOLO mode".magenta().bold(),
]));
let text_lines = build_text_lines(text_width);
// Rebuild text against the full inner width when the side-by-side layout cannot fit.
// Reusing `text_lines` would keep the directory truncated for mascot space after the
// mascot itself has been removed.
if !show_logo || text_lines.iter().any(|line| line_width(line) > text_width) {
return with_border(build_text_lines(inner_width));
}
with_border(lines)
let logo_animation_tick = self.logo_animation_tick();
let frame = logo_animation_tick
.map(|(animation, tick)| codex_logo::frame_for_tick(animation, tick))
.unwrap_or(&codex_logo::STATIC_FRAME);
let terminal_bg = terminal_palette::default_bg();
let gradient = logo_animation_tick
.map(|(_, tick)| codex_logo::gradient_for_animation_tick(terminal_bg, tick))
.unwrap_or_else(|| codex_logo::gradient_for_bg(terminal_bg));
let text_top_padding = (codex_logo::HEIGHT - text_lines.len()) / 2;
let mut lines = Vec::with_capacity(codex_logo::HEIGHT);
let mut push_logo_row = |row: usize, logo_line: &str, style: Style| {
let mut spans = vec![
Span::styled(padded_logo_line(logo_line), style),
Span::from(" ".repeat(codex_logo::GAP_WIDTH)),
];
if let Some(text_line) = row
.checked_sub(text_top_padding)
.and_then(|text_row| text_lines.get(text_row))
{
spans.extend(text_line.spans.clone());
}
lines.push(Line::from(spans));
};
for (row, color) in gradient.into_iter().enumerate() {
push_logo_row(
row,
frame[row],
Style::default().fg(terminal_palette::best_color(color)),
);
}
with_border_with_inner_width(lines, inner_width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
@@ -425,4 +538,26 @@ impl HistoryCell for SessionHeaderHistoryCell {
}
lines
}
fn transcript_animation_tick(&self) -> Option<u64> {
self.logo_animation_tick().map(|(_, tick)| tick)
}
fn finalize_for_history(&mut self) {
self.finalize_logo_animation();
}
}
fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum()
}
fn padded_logo_line(line: &str) -> String {
let width = UnicodeWidthStr::width(line);
format!(
"{line}{}",
" ".repeat(codex_logo::WIDTH.saturating_sub(width))
)
}

View File

@@ -1,11 +1,12 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
expression: rendered
---
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (vtest) │
│ │
│ model: gpt-5 /model to change │
│ directory: /tmp/project │
│ permissions: YOLO mode │
╰───────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────
⢀⣴⣶⣶⣶⣤⣤⣄⡀ >_ OpenAI Codex (vtest)
⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆
⣿⣿⣿⣇⠙⣿⣿⣿⣿⣿⣿⣷⡀ model: gpt-5 /model to change
⢹⣿⣿⡏⣠⣿⣟⠛⠛⣿⣿⣿⡇ directory: /tmp/project
⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ permissions: YOLO mode
│ ⠉⠛⠛⠻⠿⠿⠟⠋ │
╰─────────────────────────────────────────────────────────────────────────╯

View File

@@ -1,12 +1,14 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
expression: rendered
---
╭─────────────────────────────────────╮
>_ OpenAI Codex (v0.0.0)
model: gpt-5 /model to change
directory: /tmp/project
╰─────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────
⢀⣴⣶⣶⣶⣤⣤⣄⡀
⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ >_ OpenAI Codex (v0.0.0)
⣿⣿⣿⣇⠙⣿⣿⣿⣿⣿⣿⣷⡀
⢹⣿⣿⡏⣠⣿⣟⠛⠛⣿⣿⣿⡇ model: gpt-5 /model to change
│ ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ directory: /tmp/project │
│ ⠉⠛⠛⠻⠿⠿⠟⠋ │
╰─────────────────────────────────────────────────────────────────────────╯
Tip: Model just became available

View File

@@ -1,6 +1,7 @@
//! Coverage for history-cell rendering, wrapping, and transcript behavior.
use super::*;
use crate::codex_logo;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCall;
use crate::exec_cell::ExecCell;
@@ -1473,6 +1474,7 @@ fn session_header_includes_reasoning_level_when_present() {
assert!(model_line.contains("gpt-4o high fast"));
assert!(model_line.contains("/model to change"));
assert!(lines.iter().any(|line| line.contains("")));
}
#[test]
@@ -1495,6 +1497,66 @@ fn session_header_hides_fast_status_when_disabled() {
assert!(!model_line.contains("fast"));
}
#[test]
fn session_header_hides_logo_when_text_would_not_fit() {
let directory = test_path_buf("/tmp/project/with/a/long/directory/name")
.abs()
.to_path_buf();
let cell = SessionHeaderHistoryCell::new(
"gpt-4o".to_string(),
Some(ReasoningEffortConfig::High),
/*show_fast_status*/ true,
directory.clone(),
"test",
);
let rendered = render_lines(&cell.display_lines(/*width*/ 48)).join("\n");
let inner_width = card_inner_width(/*width*/ 48, SESSION_HEADER_MAX_INNER_WIDTH)
.expect("session header should fit");
let full_width_directory = SessionHeaderHistoryCell::format_directory_inner(
&directory,
/*max_width*/ Some(inner_width.saturating_sub("directory: ".len())),
);
let logo_width_directory = SessionHeaderHistoryCell::format_directory_inner(
&directory,
/*max_width*/
Some(
inner_width
.saturating_sub(codex_logo::WIDTH + codex_logo::GAP_WIDTH)
.saturating_sub("directory: ".len()),
),
);
assert!(rendered.contains("OpenAI Codex"));
assert!(!rendered.contains(""));
assert!(rendered.contains(&full_width_directory));
assert_ne!(full_width_directory, logo_width_directory);
}
#[tokio::test]
async fn session_info_logo_animation_is_independent_from_welcome_banner() {
let mut config = test_config().await;
config.animations = true;
let mut cell = new_session_info(
&config,
"gpt-5",
&session_configured_event("gpt-5"),
/*is_first_event*/ false,
/*tooltip_override*/ None,
/*auth_plan*/ None,
/*show_fast_status*/ false,
)
.with_startup_logo_animation(codex_logo::startup_animation());
assert!(cell.transcript_animation_tick().is_some());
let rendered = render_transcript(&cell).join("\n");
assert!(rendered.contains(""));
assert!(!rendered.contains("To get started"));
cell.finalize_for_history();
assert!(cell.transcript_animation_tick().is_none());
}
#[test]
#[cfg_attr(
target_os = "windows",

View File

@@ -116,6 +116,8 @@ mod chatwidget;
mod cli;
mod clipboard_copy;
mod clipboard_paste;
mod codex_logo;
mod codex_logo_frames;
mod collaboration_modes;
mod color;
mod config_update;

View File

@@ -1,10 +1,12 @@
---
source: tui/src/app.rs
source: tui/src/app/tests.rs
expression: rendered
---
╭─────────────────────────────────────────────╮
>_ OpenAI Codex (v<VERSION>)
model: gpt-test high /model to change
directory: /tmp/project
╰─────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────
⢀⣴⣶⣶⣶⣤⣤⣄⡀
⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ >_ OpenAI Codex (v<VERSION>)
⣿⣿⣿⣇⠙⣿⣿⣿⣿⣿⣿⣷⡀
⢹⣿⣿⡏⣠⣿⣟⠛⠛⣿⣿⣿⡇ model: gpt-test high /model to change
│ ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ directory: /tmp/project │
│ ⠉⠛⠛⠻⠿⠿⠟⠋ │
╰─────────────────────────────────────────────────────────────────────────╯

View File

@@ -1,10 +1,12 @@
---
source: tui/src/app.rs
source: tui/src/app/tests.rs
expression: rendered
---
╭────────────────────────────────────────────────────╮
>_ OpenAI Codex (v<VERSION>)
model: gpt-5.4 xhigh fast /model to change
directory: /tmp/project
╰────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────
⢀⣴⣶⣶⣶⣤⣤⣄⡀
⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⡆ >_ OpenAI Codex (v<VERSION>)
⣿⣿⣿⣇⠙⣿⣿⣿⣿⣿⣿⣷⡀
⢹⣿⣿⡏⣠⣿⣟⠛⠛⣿⣿⣿⡇ model: gpt-5.4 xhigh fast /model to change
│ ⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋ directory: /tmp/project │
│ ⠉⠛⠛⠻⠿⠿⠟⠋ │
╰─────────────────────────────────────────────────────────────────────────╯