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:
parent
51307eaf07
commit
45f3250eec
10 changed files with 141 additions and 26 deletions
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
49
codex-rs/tui/src/tooltips.rs
Normal file
49
codex-rs/tui/src/tooltips.rs
Normal 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
11
codex-rs/tui/tooltips.txt
Normal 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`
|
||||
Loading…
Add table
Reference in a new issue