diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5e768ba9d..6276d3b6e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -161,6 +161,9 @@ pub struct Config { /// Enable ASCII animations and shimmer effects in the TUI. pub animations: bool, + /// Show startup tooltips in the TUI welcome screen. + pub show_tooltips: bool, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1252,6 +1255,7 @@ impl Config { .map(|t| t.notifications.clone()) .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), + show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -1426,6 +1430,7 @@ persistence = "none" let tui = parsed.tui.expect("config should include tui section"); assert_eq!(tui.notifications, Notifications::Enabled(true)); + assert!(tui.show_tooltips); } #[test] @@ -2999,6 +3004,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }, o3_profile_config @@ -3072,6 +3078,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; @@ -3160,6 +3167,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; @@ -3234,6 +3242,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index c47a57096..5e1b78aa7 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -368,6 +368,11 @@ pub struct Tui { /// Defaults to `true`. #[serde(default = "default_true")] pub animations: bool, + + /// Show startup tooltips in the TUI welcome screen. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_tooltips: bool, } const fn default_true() -> bool { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2ba7e4d87..fa3e610e2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -252,6 +252,7 @@ impl App { initial_images: Vec, resume_selection: ResumeSelection, feedback: codex_feedback::CodexFeedback, + is_first_run: bool, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -297,6 +298,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + is_first_run, }; ChatWidget::new(init, conversation_manager.clone()) } @@ -320,6 +322,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + is_first_run, }; ChatWidget::new_from_existing( init, @@ -473,6 +476,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + is_first_run: false, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b161867e4..677f29abd 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -347,6 +347,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + is_first_run: false, }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 687217844..e0bb0d3a6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -256,6 +256,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, + pub(crate) is_first_run: bool, } #[derive(Default)] @@ -1230,6 +1231,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + is_first_run, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1274,7 +1276,7 @@ impl ChatWidget { retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), - show_welcome_banner: true, + show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, @@ -1305,6 +1307,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + .. } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1351,7 +1354,7 @@ impl ChatWidget { retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), - show_welcome_banner: true, + show_welcome_banner: false, suppress_session_configured_redraw: true, pending_notification: None, is_review_mode: false, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 890e8bbe1..c44401b3e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -355,6 +355,7 @@ async fn helpers_are_available_and_do_not_panic() { enhanced_keys_supported: false, auth_manager, feedback: codex_feedback::CodexFeedback::new(), + is_first_run: true, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 02ab0d243..475eb1db1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -15,6 +15,7 @@ use crate::render::renderable::Renderable; use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; +use crate::tooltips; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; @@ -559,6 +560,34 @@ pub(crate) fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } +#[derive(Debug)] +struct TooltipHistoryCell { + tip: &'static str, +} + +impl TooltipHistoryCell { + fn new(tip: &'static str) -> Self { + Self { tip } + } +} + +impl HistoryCell for TooltipHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let indent: Line<'static> = " ".into(); + let mut lines = Vec::new(); + let tooltip_line: Line<'static> = vec!["Tip: ".cyan(), self.tip.into()].into(); + let wrap_opts = RtOptions::new(usize::from(width.max(1))) + .initial_indent(indent.clone()) + .subsequent_indent(indent.clone()); + lines.extend( + word_wrap_line(&tooltip_line, wrap_opts.clone()) + .into_iter() + .map(|line| line_to_static(&line)), + ); + lines + } +} + #[derive(Debug)] pub struct SessionInfoCell(CompositeHistoryCell); @@ -586,15 +615,16 @@ pub(crate) fn new_session_info( reasoning_effort, .. } = event; - SessionInfoCell(if is_first_event { - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - model, - reasoning_effort, - config.cwd.clone(), - crate::version::CODEX_CLI_VERSION, - ); + // Header box rendered as history (so it appears at the very top) + let header = SessionHeaderHistoryCell::new( + model.clone(), + reasoning_effort, + config.cwd.clone(), + CODEX_CLI_VERSION, + ); + let mut parts: Vec> = vec![Box::new(header)]; + if is_first_event { // Help lines below the header (new copy and list) let help_lines: Vec> = vec![ " To get started, describe a task or try one of these commands:" @@ -628,24 +658,24 @@ pub(crate) fn new_session_info( ]), ]; - CompositeHistoryCell { - parts: vec![ - Box::new(header), - Box::new(PlainHistoryCell { lines: help_lines }), - ], - } - } else if config.model == model { - CompositeHistoryCell { parts: vec![] } + parts.push(Box::new(PlainHistoryCell { lines: help_lines })); } else { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {}", config.model).into(), - format!("used: {model}").into(), - ]; - CompositeHistoryCell { - parts: vec![Box::new(PlainHistoryCell { lines })], + if config.show_tooltips + && let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new) + { + parts.push(Box::new(tooltips)); } - }) + if config.model != model { + let lines = vec![ + "model changed:".magenta().bold().into(), + format!("requested: {}", config.model).into(), + format!("used: {model}").into(), + ]; + parts.push(Box::new(PlainHistoryCell { lines })); + } + } + + SessionInfoCell(CompositeHistoryCell { parts }) } pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 15e9ccb92..0aa422cc6 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -75,6 +75,7 @@ mod streaming; mod style; mod terminal_palette; mod text_formatting; +mod tooltips; mod tui; mod ui_consts; pub mod update_action; @@ -505,6 +506,7 @@ async fn run_ratatui_app( images, resume_selection, feedback, + should_show_trust_screen, // Proxy to: is it a first run in this directory? ) .await; diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs new file mode 100644 index 000000000..eb419c2ea --- /dev/null +++ b/codex-rs/tui/src/tooltips.rs @@ -0,0 +1,49 @@ +use lazy_static::lazy_static; +use rand::Rng; + +const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); + +lazy_static! { + static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect(); +} + +pub(crate) fn random_tooltip() -> Option<&'static str> { + let mut rng = rand::rng(); + pick_tooltip(&mut rng) +} + +fn pick_tooltip(rng: &mut R) -> Option<&'static str> { + if TOOLTIPS.is_empty() { + None + } else { + TOOLTIPS.get(rng.random_range(0..TOOLTIPS.len())).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + use rand::rngs::StdRng; + + #[test] + fn random_tooltip_returns_some_tip_when_available() { + let mut rng = StdRng::seed_from_u64(42); + assert!(pick_tooltip(&mut rng).is_some()); + } + + #[test] + fn random_tooltip_is_reproducible_with_seed() { + let expected = { + let mut rng = StdRng::seed_from_u64(7); + pick_tooltip(&mut rng) + }; + + let mut rng = StdRng::seed_from_u64(7); + assert_eq!(expected, pick_tooltip(&mut rng)); + } +} diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt new file mode 100644 index 000000000..09167eb4f --- /dev/null +++ b/codex-rs/tui/tooltips.txt @@ -0,0 +1,11 @@ +Use /compact when the conversation gets long to summarize history and free up context. +Start a fresh idea with /new; the previous session stays available in history. +If a turn went sideways, /undo asks Codex to revert the last changes. +Use /feedback to send logs to the maintainers when something looks off. +Switch models or reasoning effort quickly with /model. +You can run any shell commands from codex using `!` (e.g. `!ls`) +Type / to open the command popup; Tab autocompletes slash commands and saved prompts. +Use /prompts: key=value to expand a saved prompt with placeholders before sending. +With the composer empty, press Esc to step back and edit your last message; Enter confirms. +Paste an image with Ctrl+V to attach it to your next message. +You can resume a previous conversation by doing `codex resume` \ No newline at end of file