feat: codex tool tips (#7440)

<img width="551" height="316" alt="Screenshot 2025-12-01 at 12 22 26"
src="https://github.com/user-attachments/assets/6ca3deff-8ef8-4f74-a8e1-e5ea13fd6740"
/>
This commit is contained in:
jif-oai 2025-12-03 16:29:13 +00:00 committed by GitHub
parent 51307eaf07
commit 45f3250eec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 141 additions and 26 deletions

View file

@ -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(),
};

View file

@ -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 {

View file

@ -252,6 +252,7 @@ impl App {
initial_images: Vec<PathBuf>,
resume_selection: ResumeSelection,
feedback: codex_feedback::CodexFeedback,
is_first_run: bool,
) -> Result<AppExitInfo> {
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 {

View file

@ -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);

View file

@ -256,6 +256,7 @@ pub(crate) struct ChatWidgetInit {
pub(crate) enhanced_keys_supported: bool,
pub(crate) auth_manager: Arc<AuthManager>,
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,

View file

@ -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.

View file

@ -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<Line<'static>> {
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<Box<dyn HistoryCell>> = vec![Box::new(header)];
if is_first_event {
// Help lines below the header (new copy and list)
let help_lines: Vec<Line<'static>> = 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 {

View file

@ -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;

View file

@ -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<R: Rng + ?Sized>(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));
}
}

11
codex-rs/tui/tooltips.txt Normal file
View file

@ -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:<name> 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`