From 57567b8d9e5a158ddce2a8bb5f75ac455b1e9b83 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 21 Sep 2026 02:53:36 +0000 Subject: [PATCH] Use shared, keymap-aware tips in the fullscreen composer (#46931) ## Why Fullscreen composer hints used a small, fixed rotation, while catalog shortcut tips hard-coded default bindings. Tips should reflect the user's configured shortcuts and expose the shared catalog with working links. ## What changed - Select complete, single-row catalog tips that fit the composer gap, keeping selection stable across redraws and varying it across sessions and turns. - Prefer eligible startup announcements until a user turn begins, without restoring them after history is cleared. Cache parsed announcements while reevaluating eligibility. - Resolve `{key:context.action}` placeholders using current primary shortcuts and chords; skip tips with invalid or unbound actions. - Expand the tip catalog and share Markdown rendering between composer and session tips, preserving hyperlinks and supporting Ctrl/Super-click in the composer gap. - Keep usage notices and interaction controls ahead of tips, and respect `show_tooltips` and idle, empty input. ## Testing Add tests and snapshots for remapped shortcuts, unbound actions, stable tip selection, announcement dismissal, narrow layouts, styled links, and removal of stale link targets. GitOrigin-RevId: 64751293d9f91e4444b112361540a73234f1d51d --- codex-rs/tui/assets/tooltips.txt | 66 +++-- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/composer_hints.rs | 120 +++++--- codex-rs/tui/src/app/composer_hints_tests.rs | 276 +++++++++++++++++- .../src/app/empty_state_animation_tests.rs | 5 + codex-rs/tui/src/app/owned_transcript.rs | 2 +- ...ser_hints__tests__composer_tip_layout.snap | 3 +- ...poser_hints__tests__composer_tip_link.snap | 5 + ...ts__composer_tip_startup_announcement.snap | 5 + ..._animation_tests__fresh_thread_header.snap | 2 +- codex-rs/tui/src/app/startup.rs | 1 + codex-rs/tui/src/app/test_support.rs | 14 + codex-rs/tui/src/app/tests.rs | 2 + codex-rs/tui/src/history_cell/mod.rs | 1 - codex-rs/tui/src/history_cell/session.rs | 30 +- ...n_info_preserves_styled_tooltip_links.snap | 11 + codex-rs/tui/src/history_cell/tests.rs | 52 ++++ codex-rs/tui/src/tooltips.rs | 202 ++++++++----- codex-rs/tui/src/tooltips/keybinding_tests.rs | 104 +++++++ ..._shortcut_tips_render_at_narrow_width.snap | 54 ++++ codex-rs/tui/src/transcript_view.rs | 3 + .../tui/src/transcript_view/composer_gap.rs | 17 +- .../src/transcript_view/composer_gap_tests.rs | 100 +++++++ codex-rs/tui/src/transcript_view/input.rs | 15 + 24 files changed, 929 insertions(+), 162 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_link.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_startup_announcement.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_info_preserves_styled_tooltip_links.snap create mode 100644 codex-rs/tui/src/tooltips/keybinding_tests.rs create mode 100644 codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap diff --git a/codex-rs/tui/assets/tooltips.txt b/codex-rs/tui/assets/tooltips.txt index 029e9e7bd490..d36ae196e8e9 100644 --- a/codex-rs/tui/assets/tooltips.txt +++ b/codex-rs/tui/assets/tooltips.txt @@ -1,26 +1,46 @@ -Use /compact when the conversation gets long to summarize history and free up context. -Start a fresh idea with /new; the previous session stays in history. -Use /feedback to send logs to the maintainers when something looks off. -Switch models or reasoning effort quickly with /model. -Use /permissions to control when Codex asks for confirmation. -Run /review to get a code review of your current changes. -Use /skills to list available skills or ask Codex to use one. -Use /status to see the current model, approvals, and token usage. -Use /statusline to configure which items appear in the status line. -Use /fork to branch the current chat into a new thread. -Use /side to start a side conversation in a temporary fork without polluting the main thread. -Use /init to create an AGENTS.md with project-specific guidance. -Use /mcp to list configured MCP tools. -Use /rename to rename your threads for easier thread resuming. +# Tips support Markdown: **emphasis**, `code`, and [label](https://example.com) links. +# Tips may use {key:context.action} to insert the configured primary key or chord. +# A tip is skipped if any referenced action is unbound or unavailable. +Start a fresh idea with **/new**; the previous session stays in history. +Use **/feedback** to send logs to the maintainers when something looks off. +Switch models or reasoning effort quickly with **/model**. +Use **/permissions** to control when Codex asks for confirmation. +Run **/review** to get a code review of your current changes. +Use **/skills** to list available skills or ask Codex to use one. +Use **/status** to see the current model, approvals, and token usage. +Use **/statusline** to configure which items appear in the status line. +Use **/fork** to branch the current chat into a new thread. +Use **/side** to start a side conversation in a temporary fork without polluting the main thread. +Use **/init** to create an `AGENTS.md` with project-specific guidance. +Use **/mcp** to list configured MCP tools. +Use **/rename** to rename your threads for easier thread resuming. Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`. -Join the OpenAI community Discord: http://discord.gg/openai -Visit the Codex community forum: https://community.openai.com/c/codex/37 +Visit the [Codex community forum](https://community.openai.com/c/codex/37). You can run any shell command from Codex using `!` (e.g. `!ls`) -Type / to open the command popup; Tab autocompletes slash commands. -When the composer is empty, press Esc to step back and edit your last message; Enter confirms. -Press Tab to queue a message when a task is running; otherwise it sends immediately (except `!`). -[tui.keymap] in ~/.codex/config.toml lets you rebind supported shortcuts. -See the Codex keymap documentation for supported actions and examples. -Paste an image with Ctrl+V to attach it to your next message. +Type `/` to open the command popup; `Tab` autocompletes slash commands. +When the composer is empty, press `Esc` to step back and edit your last message; `Enter` confirms. +Press {key:composer.queue} to queue a message when a task is running; otherwise it sends immediately (except `!`). +Use **/keymap** to configure keyboard shortcuts. +Paste an image with `Ctrl+V` to attach it to your next message. You can resume a previous conversation by running `codex resume` -Use /copy or press Ctrl+O to copy the latest agent response as Markdown. +Use **/copy** or press {key:global.copy} to copy the latest agent response as Markdown. +Use **/vim** to toggle Vim editing in the composer. +Moving from Claude Code? Use **/import** to bring over your setup, project, and recent chats. +Use **/hooks** to view and manage lifecycle hooks. +Use **/archive** to archive the current session. +When worktree support is available, use **/worktree** to start or continue a conversation in a separate Git worktree. +Coming back to a conversation? Use **/recap** to summarize it. +When voice is available, use **/voice** to start or stop a voice conversation, or **/voice settings** to choose a voice. +When goals are enabled, use **/goal** to set or inspect the objective for a long-running task. +Use **/agents** to open the agent command center. +Use **/subagents** to switch between this session’s subagents. +Use **/export** to save your conversation as Markdown. +When signed in with ChatGPT, use **/usage** to check your account usage and access available usage limit resets. +Use **/title** to choose what appears in your terminal’s title. +Use **/theme** to choose a syntax highlighting theme. +When plugins are enabled, use **/plugins** to browse plugins. +Press {key:global.open_transcript} to open the full transcript. +Press {key:global.find_transcript} to search this conversation. +Press {key:global.open_external_editor} to edit your current draft in an external editor. +Press {key:composer.history_search_previous} to search previously entered prompts. +For models with adjustable reasoning, press {key:chat.increase_reasoning_effort} to increase reasoning effort or {key:chat.decrease_reasoning_effort} to decrease it. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b8e5af98a036..eba13d510d63 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -578,6 +578,7 @@ pub(crate) struct App { pub(crate) file_search: FileSearchManager, pub(crate) transcript_cells: Vec>, + composer_tips: composer_hints::ComposerTips, native_history: native_history::NativeHistory, pub(crate) transcript_view: crate::transcript_view::TranscriptView, last_rendered_history_tail: Option, diff --git a/codex-rs/tui/src/app/composer_hints.rs b/codex-rs/tui/src/app/composer_hints.rs index 35ad52892718..9d58336ba18c 100644 --- a/codex-rs/tui/src/app/composer_hints.rs +++ b/codex-rs/tui/src/app/composer_hints.rs @@ -1,16 +1,75 @@ -//! Usage warnings and contextual discovery share the existing composer-gap row. -//! Interaction controls take priority; only discovery tips require idle input and show_tooltips. +//! Usage warnings take priority over startup announcements and stable catalog tips in fullscreen. +//! Only complete tips that fit one row are shown; tips require idle input and show_tooltips. use super::*; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; -use crate::keymap::KeymapContext; use crate::style::secondary_text_style; -use ratatui::style::Styled; -use ratatui::text::Line; +use crate::terminal_hyperlinks::HyperlinkLine; +use rand::SeedableRng; +use rand::rngs::StdRng; +use rand::seq::SliceRandom; + +/// Keep announcement eligibility and random tip selection stable across redraws. +pub(super) struct ComposerTips { + seed: u64, + boot_turn_count: Option, + announcement_dismissed: bool, +} + +impl Default for ComposerTips { + fn default() -> Self { + Self::new(rand::random()) + } +} + +impl ComposerTips { + pub(super) fn new(seed: u64) -> Self { + Self { + seed, + boot_turn_count: None, + announcement_dismissed: false, + } + } + + fn select( + &mut self, + turn_count: usize, + announcement: impl FnOnce() -> Option, + keymap: &RuntimeKeymap, + width: usize, + cwd: &Path, + ) -> Option { + // Remember leaving startup even if /clear or backtracking later removes user cells. + self.announcement_dismissed |= + *self.boot_turn_count.get_or_insert(turn_count) != turn_count; + let render = |tip: String| { + let lines = crate::tooltips::render_tooltip_lines(&tip, width, cwd); + match lines.as_slice() { + [line] if line.width() <= width => Some(line.clone().style(secondary_text_style())), + _ => None, + } + }; + if !self.announcement_dismissed + && let Some(line) = announcement().and_then(&render) + { + return Some(line); + } + + let mut rng = StdRng::seed_from_u64(self.seed.wrapping_add(turn_count as u64)); + // Randomize before fitting so short tips are not favored by catalog order. + let mut tips = crate::tooltips::resolved_tooltips(Some(keymap)).collect::>(); + tips.shuffle(&mut rng); + tips.into_iter().find_map(render) + } +} impl App { - pub(super) fn composer_hint(&self, width: u16) -> Option> { + pub(super) fn composer_hint(&mut self, width: u16) -> Option { + let running = self.chat_widget.is_user_turn_pending_or_running(); + if running { + self.composer_tips.announcement_dismissed = true; + } if !self.chat_widget.no_modal_or_popup_active() || !self.transcript_view.is_following() || self.transcript_view.has_active_interaction() @@ -19,12 +78,16 @@ impl App { { return None; } + let width = width.checked_sub(/*rhs*/ 2)?; + if width == 0 { + return None; + } if let Some(notice) = self.chat_widget.usage_notice(width) { - return Some(notice); + return Some(HyperlinkLine::new(notice)); } if !self.local_settings.tui.show_tooltips || !self.chat_widget.composer_is_empty() - || self.chat_widget.is_user_turn_pending_or_running() + || running { return None; } @@ -36,36 +99,17 @@ impl App { .take_while(|cell| !cell.as_any().is::()) .filter(|cell| cell.as_any().is::()) .count(); - let (prefix, keys, label) = if count == 0 { - ( - "Tip: ", - crate::key_hint::key_label_spans("@"), - " mentions files, skills, and plugins", - ) - } else { - match count % 3 { - 0 => ( - "Tip: drag to select text · ", - crate::key_hint::key_label_spans("ctrl+c"), - " copies", - ), - 1 => { - let key = self - .keymap - .primary_hint(KeymapContext::Global, "find_transcript")?; - ("Tip: ", key.spans(), " searches this conversation") - } - _ => { - return Some(Line::from( - "Tip: /copy copies the last response".set_style(secondary_text_style()), - )); - } - } - }; - let mut tip = Line::from(prefix.set_style(secondary_text_style())); - tip.spans.extend(keys); - tip.spans.push(label.set_style(secondary_text_style())); - Some(tip) + self.composer_tips.select( + count, + || { + crate::tooltips::announcement::fetch_announcement_tip( + self.chat_widget.current_plan_type(), + ) + }, + &self.keymap, + usize::from(width), + self.config.cwd.as_path(), + ) } } diff --git a/codex-rs/tui/src/app/composer_hints_tests.rs b/codex-rs/tui/src/app/composer_hints_tests.rs index a0908cfaefdc..2ffdbfe692ef 100644 --- a/codex-rs/tui/src/app/composer_hints_tests.rs +++ b/codex-rs/tui/src/app/composer_hints_tests.rs @@ -1,12 +1,19 @@ -//! Composer hints preserve input geometry and yield to interaction controls. +//! Shared tips remain stable and linked while usage warnings take precedence in the composer gap. use super::*; +use crate::app::test_support::select_catalog_tip; +use crate::terminal_hyperlinks::strip_osc8; +use crate::transcript_view::ViewAction; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; +use codex_protocol::account::PlanType; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; fn quota(used_percent: i32) -> RateLimitSnapshot { @@ -53,7 +60,7 @@ async fn usage_notice_preserves_composer_geometry_and_restores_tip_on_recovery() tui.terminal.resize(size)?; let before = app.render_owned_transcript(&mut tui, size)?; let cursor = tui.terminal.last_known_cursor_pos; - let tip = app.composer_hint(width.saturating_sub(/*rhs*/ 2)); + let tip = app.composer_hint(width); app.chat_widget .on_rate_limit_snapshot(Some(quota(used_percent))); while let Ok(event) = events.try_recv() { @@ -79,7 +86,7 @@ async fn usage_notice_preserves_composer_geometry_and_restores_tip_on_recovery() snapshots.push(format!("{width} columns, running={running}\n{screen}")); app.chat_widget .on_rate_limit_snapshot(Some(quota(/*used_percent*/ 10))); - assert_eq!(app.composer_hint(width.saturating_sub(/*rhs*/ 2)), tip); + assert_eq!(app.composer_hint(width), tip); assert_eq!(app.render_owned_transcript(&mut tui, size)?, before); assert_eq!(tui.terminal.last_known_cursor_pos, cursor); tui.set_owned_screen(/*owned*/ false)?; @@ -101,6 +108,7 @@ async fn usage_notice_yields_to_interactions_and_blocking_banners() { let notice = app.composer_hint(/*width*/ 80); assert!(notice.is_some()); assert_ne!(notice, tip); + let notice_line = notice.as_ref().map(|notice| notice.line.clone()); app.transcript_view.begin_search(); assert_eq!(app.composer_hint(/*width*/ 80), None); app.transcript_view = Default::default(); @@ -125,7 +133,7 @@ async fn usage_notice_yields_to_interactions_and_blocking_banners() { } app.chat_widget .on_rate_limit_snapshot(Some(quota(/*used_percent*/ 92))); - assert_eq!(app.chat_widget.usage_notice(/*width*/ 80), notice); + assert_eq!(app.chat_widget.usage_notice(/*width*/ 78), notice_line); app.chat_widget.update_backend_banner( &serde_json::from_value(serde_json::json!({ "rateLimits": quota(/*used_percent*/ 92), @@ -138,26 +146,44 @@ async fn usage_notice_yields_to_interactions_and_blocking_banners() { ); assert_eq!(app.chat_widget.usage_notice(/*width*/ 80), None); app.chat_widget.clear_backend_banner(); - assert_eq!(app.chat_widget.usage_notice(/*width*/ 80), notice); + assert_eq!(app.chat_widget.usage_notice(/*width*/ 78), notice_line); } #[tokio::test] async fn hints_respect_settings_drafts_and_custom_shortcuts() -> Result<()> { let mut app = crate::app::test_support::make_test_app().await; app.local_settings.tui.show_tooltips = true; - app.transcript_cells = vec![Arc::new(crate::history_cell::new_user_prompt( - "question".into(), - Vec::new(), - Vec::new(), - Vec::new(), - ))]; + app.chat_widget.update_account_state( + /*status_account_display*/ None, + Some(PlanType::Free), + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + app.transcript_cells + .push(Arc::new(crate::history_cell::new_user_prompt( + "question".into(), + Vec::new(), + Vec::new(), + Vec::new(), + ))); app.keymap.app.find_transcript = vec![crate::key_hint::plain(KeyCode::F(12))]; + select_catalog_tip( + &mut app, + /*width*/ 80, + "Tip: Press f12 to search this conversation.", + ); + let tip = app.composer_hint(/*width*/ 80).unwrap(); + assert_eq!( + tip.line.to_string(), + "Tip: Press f12 to search this conversation." + ); + assert_eq!(app.composer_hint(/*width*/ 80), Some(tip)); let mut tui = crate::tui::test_support::make_test_tui()?; tui.set_owned_screen(/*owned*/ true)?; let mut snapshots = Vec::new(); for width in [80, 32] { let size = Size::new(width, /*height*/ 8); - tui.terminal.resize(size)?; + tui.screen_size_for_event(&TuiEvent::Resize(size))?; app.render_owned_transcript(&mut tui, size)?; let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); let rendered = (buffer.area.y..buffer.area.bottom()) @@ -176,14 +202,236 @@ async fn hints_respect_settings_drafts_and_custom_shortcuts() -> Result<()> { "composer_tip_layout", crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapshots.join("\n\n")), ); + for width in [0, 1, 2, 32] { + assert_eq!(app.composer_hint(width), None); + } + app.keymap.app.find_transcript = vec![crate::key_hint::plain(KeyCode::F(6))]; + assert_eq!( + app.composer_hint(/*width*/ 80).unwrap().line.to_string(), + "Tip: Press f6 to search this conversation." + ); app.keymap.app.find_transcript.clear(); - assert_eq!(app.composer_hint(/*width*/ 80), None); - app.transcript_cells.clear(); + let tip = app.composer_hint(/*width*/ 80).unwrap(); + assert!( + !tip.line + .to_string() + .contains(" to search this conversation.") + ); app.chat_widget.apply_external_edit("draft".into()); assert_eq!(app.composer_hint(/*width*/ 80), None); app.chat_widget.apply_external_edit(String::new()); app.local_settings.tui.show_tooltips = false; + assert_eq!(app.composer_hint(/*width*/ 120), None); + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + +#[test] +fn announcement_is_only_used_at_startup_and_catalog_tips_are_stable() { + let mut tips = ComposerTips::new(/*seed*/ 42); + let keymap = RuntimeKeymap::defaults(); + let cwd = Path::new("."); + let before_fetch = tips + .select( + /*turn_count*/ 0, + || None, + &keymap, + /*width*/ 78, + cwd, + ) + .unwrap(); + let announcement = || Some("A startup announcement.".to_string()); + let boot = tips + .select( + /*turn_count*/ 0, + announcement, + &keymap, + /*width*/ 78, + cwd, + ) + .unwrap(); + assert_eq!(boot.line.to_string(), "Tip: A startup announcement."); + assert_eq!( + tips.select( + /*turn_count*/ 0, + announcement, + &keymap, + /*width*/ 78, + cwd + ), + Some(boot.clone()), + ); + let after_turn = tips + .select( + /*turn_count*/ 1, + || panic!("announcements must not be fetched after a user turn"), + &keymap, + /*width*/ 78, + cwd, + ) + .unwrap(); + assert_eq!( + tips.select( + /*turn_count*/ 1, + announcement, + &keymap, + /*width*/ 78, + cwd + ), + Some(after_turn), + ); + let after_clear = tips + .select( + /*turn_count*/ 0, + || panic!("clearing history must not restore the startup announcement"), + &keymap, + /*width*/ 78, + cwd, + ) + .unwrap(); + assert_eq!(after_clear, before_fetch); + insta::assert_snapshot!("composer_tip_startup_announcement", boot.line.to_string()); +} + +#[test] +fn catalog_selection_varies_across_sessions_and_turns_and_fits_the_row() { + let keymap = RuntimeKeymap::defaults(); + let cwd = Path::new("."); + let mut starts = std::collections::BTreeSet::new(); + for seed in 0..16 { + let mut turns = std::collections::BTreeSet::new(); + let mut tips = ComposerTips::new(seed); + // A long announcement falls back to a complete catalog tip that fits. + let initial = tips + .select( + /*turn_count*/ 0, + || Some("A long announcement. ".repeat(/*n*/ 10)), + &keymap, + /*width*/ 78, + cwd, + ) + .unwrap(); + assert!(initial.width() <= 78); + starts.insert(initial.line.to_string()); + for count in 1..8 { + let tip = tips + .select(count, || None, &keymap, /*width*/ 78, cwd) + .unwrap(); + assert!(tip.width() <= 78); + turns.insert(tip.line.to_string()); + } + assert!(turns.len() > 1); + assert_eq!( + tips.select( + /*turn_count*/ 8, + || None, + &keymap, + /*width*/ 1, + cwd + ), + None, + ); + } + assert!(starts.len() > 1); +} + +#[tokio::test] +async fn running_turn_dismisses_announcement_across_thread_resets() { + let mut app = crate::app::test_support::make_test_app().await; + app.chat_widget.handle_server_notification( + ServerNotification::TurnStarted(codex_app_server_protocol::TurnStartedNotification { + thread_id: ThreadId::new().to_string(), + turn: codex_app_server_protocol::Turn { + id: "active-turn".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }), + /*replay_kind*/ None, + ); assert_eq!(app.composer_hint(/*width*/ 80), None); + app.reset_thread_event_state(); + let tip = app.composer_tips.select( + /*turn_count*/ 0, + || panic!("an already running turn must end the startup announcement"), + &app.keymap, + /*width*/ 78, + app.config.cwd.as_path(), + ); + assert!(tip.is_some()); +} + +#[tokio::test] +async fn hints_render_clickable_links_and_release_them_when_disabled() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + app.local_settings.tui.show_tooltips = true; + app.chat_widget.update_account_state( + /*status_account_display*/ None, + Some(PlanType::Free), + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let destination = "https://community.openai.com/c/codex/37"; + select_catalog_tip( + &mut app, + /*width*/ 85, + "Tip: Visit the Codex community forum", + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = Size::new(/*width*/ 85, /*height*/ 8); + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + let bottom = app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let row = bottom.y - 1; + let rendered = (0..size.width) + .map(|column| strip_osc8(buffer[(column, row)].symbol())) + .collect::(); + insta::assert_snapshot!("composer_tip_link", rendered.trim_end()); + let columns = (0..size.width) + .filter(|column| buffer[(*column, row)].symbol().contains(destination)) + .collect::>(); + assert!(!columns.is_empty()); + for column in columns { + for modifiers in [KeyModifiers::CONTROL, KeyModifiers::SUPER] { + let event = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers, + }; + let Some(ViewAction::OpenLink(url)) = app + .transcript_view + .handle_mouse(event, &app.transcript_cells) + else { + panic!("linked tip cell should open its destination"); + }; + assert_eq!(url, destination); + } + } + app.local_settings.tui.show_tooltips = false; + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + app.render_owned_transcript(&mut tui, size)?; + for column in 0..size.width { + assert!( + app.transcript_view + .handle_mouse( + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::CONTROL, + }, + &app.transcript_cells + ) + .is_none() + ); + } tui.set_owned_screen(/*owned*/ false)?; Ok(()) } diff --git a/codex-rs/tui/src/app/empty_state_animation_tests.rs b/codex-rs/tui/src/app/empty_state_animation_tests.rs index a7b53d490f4f..d4760ecab1a4 100644 --- a/codex-rs/tui/src/app/empty_state_animation_tests.rs +++ b/codex-rs/tui/src/app/empty_state_animation_tests.rs @@ -195,6 +195,11 @@ async fn empty_state_animation_preserves_header_cursor_and_footer() -> Result<() ["example".to_string()], /*failure_reason*/ None, ))); + crate::app::test_support::select_catalog_tip( + &mut app, + size.width, + "Tip: Start a fresh idea with /new; the previous session stays in history.", + ); let before_bottom = draw(&mut app, &mut tui, size)?; let history_len = app.transcript_cells.len(); let before = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone(); diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index d383e1071341..9d6c83f02d73 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -81,7 +81,7 @@ impl App { }; self.sync_owned_transcript(screen_size.width); let transcript_width = self.chat_widget.history_wrap_width(screen_size.width); - let composer_hint = self.composer_hint(transcript_width.saturating_sub(/*rhs*/ 2)); + let composer_hint = self.composer_hint(transcript_width); let mut prompt_footer = self.prompt_navigation_footer(screen_size.width.saturating_sub(/*rhs*/ 2)); let chat_widget = &self.chat_widget; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_layout.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_layout.snap index 7158ba7c2624..219056e97f01 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_layout.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_layout.snap @@ -1,12 +1,11 @@ --- source: tui/src/app/composer_hints_tests.rs -assertion_line: 37 expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapshots.join(\"\\n\\n\"))" --- 80 columns › question - Tip: f12 searches this conversation + Tip: Press f12 to search this conversation. › Ask Codex to do anything diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_link.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_link.snap new file mode 100644 index 000000000000..db789b639fd0 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_link.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/composer_hints_tests.rs +expression: rendered.trim_end() +--- + Tip: Visit the Codex community forum (https://community.openai.com/c/codex/37). diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_startup_announcement.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_startup_announcement.snap new file mode 100644 index 000000000000..fe1a29afedf5 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_startup_announcement.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/composer_hints_tests.rs +expression: boot.line.to_string() +--- +Tip: A startup announcement. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__empty_state_animation_tests__fresh_thread_header.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__empty_state_animation_tests__fresh_thread_header.snap index 28f70200c077..91094ce9a928 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__empty_state_animation_tests__fresh_thread_header.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__empty_state_animation_tests__fresh_thread_header.snap @@ -40,7 +40,7 @@ expression: text(after) - Tip: @ mentions files, skills, and plugins + Tip: Start a fresh idea with /new; the previous session stays in history. › Ask Codex to do anything diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 867561454a1b..d135ec08a298 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -764,6 +764,7 @@ See the Codex keymap documentation for supported actions and examples." keymap: runtime_keymap, key_chord_matcher: KeyChordMatcher::default(), transcript_cells: Vec::new(), + composer_tips: Default::default(), native_history: Default::default(), transcript_view: Default::default(), last_rendered_history_tail: None, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 232ea6513223..c4b1e6360712 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -9,6 +9,19 @@ use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use codex_models_manager::test_support::construct_model_info_offline_for_tests; use codex_models_manager::test_support::get_model_offline_for_tests; +pub(super) fn select_catalog_tip(app: &mut App, width: u16, expected: &str) { + for seed in 0..1024 { + app.composer_tips = super::composer_hints::ComposerTips::new(seed); + if app + .composer_hint(width) + .is_some_and(|tip| tip.line.to_string().starts_with(expected)) + { + return; + } + } + panic!("catalog tip was never selected: {expected}"); +} + pub(crate) async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); @@ -37,6 +50,7 @@ pub(crate) async fn make_test_app() -> App { pending_server_profiles: HashMap::new(), file_search, transcript_cells: Vec::new(), + composer_tips: super::composer_hints::ComposerTips::new(/*seed*/ 0), native_history: Default::default(), transcript_view: Default::default(), last_rendered_history_tail: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a79b25bd35ad..9c884f2ed327 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5978,6 +5978,7 @@ async fn make_test_app() -> App { runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), + composer_tips: super::composer_hints::ComposerTips::new(/*seed*/ 0), native_history: Default::default(), transcript_view: Default::default(), last_rendered_history_tail: None, @@ -6081,6 +6082,7 @@ pub(super) async fn make_test_app_with_channels() -> ( runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), + composer_tips: super::composer_hints::ComposerTips::new(/*seed*/ 0), native_history: Default::default(), transcript_view: Default::default(), last_rendered_history_tail: None, diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 5ff39b99c13e..c177895a1b7f 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -23,7 +23,6 @@ use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::legacy_core::config::Config; use crate::live_wrap::take_prefix_by_width; -use crate::markdown::append_markdown; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index c8433ba25ebb..a6d5eed20e6c 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -86,20 +86,22 @@ impl HistoryCell for TooltipHistoryCell { } fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let indent = " "; let indent_width = display_width(indent); let wrap_width = usize::from(width.max(1)) .saturating_sub(indent_width) .max(1); - let mut lines: Vec> = Vec::new(); - append_markdown( - &format!("**Tip:** {}", self.tip), - Some(wrap_width), - Some(self.cwd.as_path()), - &mut lines, - ); + let lines = crate::tooltips::render_tooltip_lines(&self.tip, wrap_width, &self.cwd); + + prefix_hyperlink_lines(lines, indent.into(), indent.into()) + } - prefix_lines(lines, indent.into(), indent.into()) + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) } fn raw_lines(&self) -> Vec> { @@ -133,6 +135,10 @@ impl HistoryCell for SessionInfoCell { self.0.display_lines(width) } + fn display_hyperlink_lines(&self, width: u16) -> Vec { + self.0.display_hyperlink_lines(width) + } + fn desired_height(&self, width: u16) -> u16 { self.0.desired_height(width) } @@ -141,6 +147,10 @@ impl HistoryCell for SessionInfoCell { self.0.transcript_lines(width) } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.0.transcript_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { self.0.raw_lines() } @@ -213,7 +223,9 @@ pub(crate) fn new_session_info( } else { if local_settings.tui.show_tooltips && let Some(tooltips) = tooltip_override - .or_else(|| tooltips::get_tooltip(auth_plan, show_fast_status)) + .or_else(|| { + tooltips::get_tooltip(auth_plan, show_fast_status, &local_settings.tui.keymap) + }) .map(|tip| TooltipHistoryCell::new(tip, &config.cwd)) { parts.push(Box::new(tooltips)); diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_info_preserves_styled_tooltip_links.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_info_preserves_styled_tooltip_links.snap new file mode 100644 index 000000000000..562070e86d5e --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_info_preserves_styled_tooltip_links.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +assertion_line: 824 +expression: "rendered.join(\"\\n\")" +--- + Tip: Use /copy or ctrl+y; + visit the Codex community + link 12..27: Codex community -> https://example.com + forum (https://example.com). + link 2..7: forum -> https://example.com + link 9..28: https://example.com -> https://example.com diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 7079f5160e3d..0062cd5b2ceb 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -778,6 +778,58 @@ async fn session_info_availability_nux_tooltip_snapshot() { insta::assert_snapshot!(rendered); } +#[tokio::test] +async fn session_info_preserves_styled_tooltip_links() { + let config = test_config().await; + let cell = new_session_info( + &config, + &crate::local_settings::LocalSettings::from(&config), + "gpt-5", + "gpt-5", + &session_configured_event("gpt-5"), + /*is_first_event*/ false, + Some( + "Use **/copy** or `ctrl+y`; visit the [Codex community forum](https://example.com)." + .to_string(), + ), + Some(PlanType::Free), + /*show_fast_status*/ false, + ); + + let lines = cell.transcript_hyperlink_lines(/*width*/ 30); + assert_eq!(lines, cell.display_hyperlink_lines(/*width*/ 30)); + assert_eq!( + visible_lines(lines.clone()), + cell.transcript_lines(/*width*/ 30) + ); + let tip_start = lines + .iter() + .position(|line| line.line.to_string().starts_with(" Tip:")) + .unwrap(); + let tip_lines = &lines[tip_start..]; + let command = tip_lines + .iter() + .flat_map(|line| &line.line.spans) + .find(|span| span.content == "/copy") + .unwrap(); + assert!(command.style.add_modifier.contains(Modifier::BOLD)); + let mut rendered = Vec::new(); + for line in tip_lines { + let text = line.line.to_string(); + rendered.push(text.clone()); + for link in &line.hyperlinks { + // This ASCII fixture makes byte offsets equal to terminal columns. + rendered.push(format!( + " link {:?}: {} -> {}", + link.columns, + &text[link.columns.clone()], + link.destination, + )); + } + } + insta::assert_snapshot!(rendered.join("\n")); +} + #[tokio::test] async fn session_info_first_event_suppresses_tooltips_and_nux() { let config = test_config().await; diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index e80809c1d714..fddd6d9b1217 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -1,9 +1,19 @@ +use crate::keymap::RuntimeKeymap; +use crate::keymap::keymap_action_id; +use crate::terminal_hyperlinks::HyperlinkLine; +use codex_config::types::TuiKeymap; use codex_features::FEATURES; use codex_features::Feature; use codex_features::FeatureSpec; use codex_protocol::account::PlanType; use lazy_static::lazy_static; use rand::Rng; +use rand::seq::IteratorRandom; +use std::path::Path; + +#[cfg(test)] +#[path = "tooltips/keybinding_tests.rs"] +mod keybinding_tests; const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml"; @@ -58,15 +68,28 @@ fn experimental_tooltips( } /// Pick a random tooltip to show to the user when starting Codex. -pub(crate) fn get_tooltip(plan: Option, fast_mode_enabled: bool) -> Option { +pub(crate) fn get_tooltip( + plan: Option, + fast_mode_enabled: bool, + keymap: &TuiKeymap, +) -> Option { let mut rng = rand::rng(); + preferred_tooltip(&mut rng, plan, fast_mode_enabled).or_else(|| pick_tooltip(&mut rng, keymap)) +} +/// Apply the shared announcement and promotion policy before falling back to local tips. +/// The announcement lookup only reads the prewarmed cache. +pub(crate) fn preferred_tooltip( + rng: &mut R, + plan: Option, + fast_mode_enabled: bool, +) -> Option { if let Some(announcement) = announcement::fetch_announcement_tip(plan) { return Some(announcement); } // Leave small chance for a random tooltip to be shown. - if rng.random_ratio(8, 10) { + if rng.random_ratio(/*numerator*/ 8, /*denominator*/ 10) { match plan { Some(plan_type) if matches!( @@ -75,7 +98,7 @@ pub(crate) fn get_tooltip(plan: Option, fast_mode_enabled: bool) -> Op ) || plan_type.is_team_like() || plan_type.is_business_like() => { - if let Some(tooltip) = pick_paid_tooltip(&mut rng, fast_mode_enabled) { + if let Some(tooltip) = pick_paid_tooltip(rng, fast_mode_enabled) { return Some(tooltip.to_string()); } } @@ -93,7 +116,7 @@ pub(crate) fn get_tooltip(plan: Option, fast_mode_enabled: bool) -> Op } } - pick_tooltip(&mut rng).map(str::to_string) + None } struct LinuxDesktopSession { @@ -149,14 +172,51 @@ fn pick_paid_tooltip( } } -fn pick_tooltip(rng: &mut R) -> Option<&'static str> { - if ALL_TOOLTIPS.is_empty() { - None - } else { - ALL_TOOLTIPS - .get(rng.random_range(0..ALL_TOOLTIPS.len())) - .copied() +fn pick_tooltip(rng: &mut R, keymap: &TuiKeymap) -> Option { + // Resolve current settings for each new tip; never replace an invalid or unbound keymap + // with defaults, or cache shortcut text across /keymap edits. + let keymap = RuntimeKeymap::from_config(keymap).ok(); + resolved_tooltips(keymap.as_ref()).choose(rng) +} + +/// Render shared tip styling and links, retaining visible URLs when the terminal needs them. +pub(crate) fn render_tooltip_lines(tip: &str, width: usize, cwd: &Path) -> Vec { + crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd( + &format!("**Tip:** {tip}"), + Some(width), + Some(cwd), + &crate::markdown_render::hide_web_link_destination, + crate::markdown_render::ListSpacing::AfterMultiline, + ) + .lines +} + +/// Resolve the local tip pool in catalog order using the supplied runtime keymap. +/// Tips with invalid or unbound shortcuts are omitted; without a keymap, only key-free tips remain. +pub(crate) fn resolved_tooltips( + keymap: Option<&RuntimeKeymap>, +) -> impl Iterator + '_ { + ALL_TOOLTIPS + .iter() + .filter_map(move |tip| render_tooltip(tip, keymap)) +} + +/// Substitute `{key:context.action}` with the current primary shortcut in a Markdown code span. +/// Skip the tip if a placeholder is invalid or its action has no binding. +fn render_tooltip(mut template: &str, keymap: Option<&RuntimeKeymap>) -> Option { + let mut rendered = String::new(); + while let Some((prefix, rest)) = template.split_once("{key:") { + let (action, suffix) = rest.split_once('}')?; + let (context, action) = action.split_once('.')?; + let action = keymap_action_id(context, action)?; + let hint = keymap?.primary_hint(action.context, action.action)?; + rendered.push_str(prefix); + // A key or two-key chord can contain literal backticks; use a padded code span. + rendered.push_str(&format!("`` {} ``", hint.display_label())); + template = suffix; } + rendered.push_str(template); + Some(rendered) } pub(crate) mod announcement { @@ -173,7 +233,7 @@ pub(crate) mod announcement { use std::sync::OnceLock; use std::time::Duration; - static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); + static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); const CURRENT_OS: TargetOs = TargetOs::current(); /// Prewarm the cache of the announcement tip. @@ -182,7 +242,9 @@ pub(crate) mod announcement { return; } tokio::spawn(async move { - let announcement_tip = fetch_announcement_tip_text(http_client_factory).await; + let announcement_tip = fetch_announcement_tip_text(http_client_factory) + .await + .and_then(|raw| AnnouncementTips::parse(&raw)); let _ = ANNOUNCEMENT_TIP.set(announcement_tip); }); } @@ -191,9 +253,8 @@ pub(crate) mod announcement { pub(crate) fn fetch_announcement_tip(plan: Option) -> Option { ANNOUNCEMENT_TIP .get() - .cloned() - .flatten() - .and_then(|raw| parse_announcement_tip_toml(&raw, plan)) + .and_then(Option::as_ref) + .and_then(|tips| tips.select(plan)) } #[derive(Debug, Deserialize)] @@ -257,39 +318,44 @@ pub(crate) mod announcement { response.error_for_status().ok()?.text().await.ok() } - pub(crate) fn parse_announcement_tip_toml( - text: &str, - plan: Option, - ) -> Option { - let announcements = toml::from_str::(text) - .map(|doc| doc.announcements) - .or_else(|_| toml::from_str::>(text)) - .ok()?; + /// Parsed once during prewarming; eligibility stays current across redraws and account changes. + pub(super) struct AnnouncementTips(Vec); + + impl AnnouncementTips { + pub(super) fn parse(text: &str) -> Option { + let announcements = toml::from_str::(text) + .map(|doc| doc.announcements) + .or_else(|_| toml::from_str::>(text)) + .ok()?; + Some(Self( + announcements + .into_iter() + .filter_map(AnnouncementTip::from_raw) + .collect(), + )) + } - let mut latest_match = None; - let today = Utc::now().date_naive(); - for raw in announcements { - let Some(tip) = AnnouncementTip::from_raw(raw) else { - continue; - }; - let plan_matches = tip - .target_plan_types - .as_ref() - .is_none_or(|target_plans| plan.is_some_and(|plan| target_plans.contains(&plan))); - let os_matches = tip - .target_oses - .as_ref() - .is_none_or(|target_oses| target_oses.contains(&CURRENT_OS)); - if tip.version_matches(CODEX_CLI_VERSION) - && tip.date_matches(today) - && tip.target_app == "cli" - && plan_matches - && os_matches - { - latest_match = Some(tip.content); - } + pub(super) fn select(&self, plan: Option) -> Option { + let today = Utc::now().date_naive(); + self.0 + .iter() + .rev() + .find(|tip| { + let plan_matches = tip.target_plan_types.as_ref().is_none_or(|target_plans| { + plan.is_some_and(|plan| target_plans.contains(&plan)) + }); + let os_matches = tip + .target_oses + .as_ref() + .is_none_or(|target_oses| target_oses.contains(&CURRENT_OS)); + tip.version_matches(CODEX_CLI_VERSION) + && tip.date_matches(today) + && tip.target_app == "cli" + && plan_matches + && os_matches + }) + .map(|tip| tip.content.clone()) } - latest_match } impl AnnouncementTip { @@ -362,7 +428,7 @@ pub(crate) mod announcement { #[cfg(test)] mod tests { use super::*; - use crate::tooltips::announcement::parse_announcement_tip_toml; + use crate::tooltips::announcement::AnnouncementTips; use pretty_assertions::assert_eq; use rand::SeedableRng; use rand::rngs::StdRng; @@ -407,18 +473,18 @@ mod tests { #[test] fn random_tooltip_returns_some_tip_when_available() { let mut rng = StdRng::seed_from_u64(42); - assert!(pick_tooltip(&mut rng).is_some()); + assert!(pick_tooltip(&mut rng, &TuiKeymap::default()).is_some()); } #[test] fn random_tooltip_is_reproducible_with_seed() { let expected = { let mut rng = StdRng::seed_from_u64(7); - pick_tooltip(&mut rng) + pick_tooltip(&mut rng, &TuiKeymap::default()) }; let mut rng = StdRng::seed_from_u64(7); - assert_eq!(expected, pick_tooltip(&mut rng)); + assert_eq!(expected, pick_tooltip(&mut rng, &TuiKeymap::default())); } #[test] @@ -515,7 +581,7 @@ to_date = "2000-01-01" assert_eq!( Some("latest match".to_string()), - parse_announcement_tip_toml(toml, /*plan*/ None) + AnnouncementTips::parse(toml).and_then(|tips| tips.select(/*plan*/ None)) ); let toml = r#" @@ -535,7 +601,7 @@ to_date = "2000-01-01" assert_eq!( Some("latest match".to_string()), - parse_announcement_tip_toml(toml, /*plan*/ None) + AnnouncementTips::parse(toml).and_then(|tips| tips.select(/*plan*/ None)) ); } @@ -556,7 +622,10 @@ content = "should not match either " target_app = "vsce" "#; - assert_eq!(None, parse_announcement_tip_toml(toml, /*plan*/ None)); + assert_eq!( + None, + AnnouncementTips::parse(toml).and_then(|tips| tips.select(/*plan*/ None)) + ); } #[test] @@ -567,7 +636,10 @@ content = 123 from_date = "2000-01-01" "#; - assert_eq!(None, parse_announcement_tip_toml(toml, /*plan*/ None)); + assert_eq!( + None, + AnnouncementTips::parse(toml).and_then(|tips| tips.select(/*plan*/ None)) + ); } #[test] @@ -594,7 +666,7 @@ content = "This is a test announcement" assert_eq!( Some("This is a test announcement".to_string()), - parse_announcement_tip_toml(toml, /*plan*/ None) + AnnouncementTips::parse(toml).and_then(|tips| tips.select(/*plan*/ None)) ); } @@ -613,22 +685,20 @@ content = "free announcement" target_plan_types = ["free"] "#; + let tips = AnnouncementTips::parse(toml).unwrap(); assert_eq!( Some("pro announcement".to_string()), - parse_announcement_tip_toml(toml, Some(PlanType::Pro)) + tips.select(Some(PlanType::Pro)) ); assert_eq!( Some("free announcement".to_string()), - parse_announcement_tip_toml(toml, Some(PlanType::Free)) - ); - assert_eq!( - Some("all plans".to_string()), - parse_announcement_tip_toml(toml, Some(PlanType::Plus)) + tips.select(Some(PlanType::Free)) ); assert_eq!( Some("all plans".to_string()), - parse_announcement_tip_toml(toml, /*plan*/ None) + tips.select(Some(PlanType::Plus)) ); + assert_eq!(Some("all plans".to_string()), tips.select(/*plan*/ None)); } #[test] @@ -644,7 +714,7 @@ target_plan_types = ["prp"] assert_eq!( Some("all plans".to_string()), - parse_announcement_tip_toml(toml, Some(PlanType::Unknown)) + AnnouncementTips::parse(toml).and_then(|tips| tips.select(Some(PlanType::Unknown))) ); } @@ -673,7 +743,7 @@ target_oses = ["windows"] }; assert_eq!( Some(expected.to_string()), - parse_announcement_tip_toml(toml, /*plan*/ None) + AnnouncementTips::parse(toml).and_then(|tips| tips.select(/*plan*/ None)) ); } @@ -690,7 +760,7 @@ target_oses = ["amiga"] assert_eq!( Some("all operating systems".to_string()), - parse_announcement_tip_toml(toml, /*plan*/ None) + AnnouncementTips::parse(toml).and_then(|tips| tips.select(/*plan*/ None)) ); } } diff --git a/codex-rs/tui/src/tooltips/keybinding_tests.rs b/codex-rs/tui/src/tooltips/keybinding_tests.rs new file mode 100644 index 000000000000..1ff8be56f289 --- /dev/null +++ b/codex-rs/tui/src/tooltips/keybinding_tests.rs @@ -0,0 +1,104 @@ +//! Tooltip placeholders follow current keybindings and chords, render safely as Markdown, +//! and suppress tips when any referenced shortcut is invalid or unbound. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn configured_shortcut_tips_render_at_narrow_width() { + let config = serde_json::from_value(json!({ + "global": { + "open_transcript": ["ctrl-x t", "f12"], + "find_transcript": "ctrl-x f", + "open_external_editor": "ctrl-x e", + "copy": "ctrl-x `" + }, + "composer": { + "history_search_previous": "ctrl-x r", + "queue": "ctrl-x q" + }, + "chat": { + "increase_reasoning_effort": "ctrl-x +", + "decrease_reasoning_effort": "ctrl-x minus" + } + })) + .unwrap(); + let keymap = RuntimeKeymap::from_config(&config).unwrap(); + let mut lines = Vec::new(); + for (label, keymap) in [("Default", RuntimeKeymap::defaults()), ("Remapped", keymap)] { + lines.push(label.into()); + for template in TOOLTIPS.iter().filter(|tip| tip.contains("{key:")) { + let tip = render_tooltip(template, Some(&keymap)).expect("valid shortcut tip"); + crate::markdown::append_markdown( + &format!("**Tip:** {tip}"), + Some(40), + /*cwd*/ None, + &mut lines, + ); + lines.push("".into()); + } + } + insta::assert_snapshot!( + lines + .iter() + .map(|line| format!("{line:?}")) + .collect::>() + .join("\n") + ); +} + +#[test] +fn skips_unbound_or_invalid_placeholders() { + let config = serde_json::from_value(json!({ "global": { "copy": [] } })).unwrap(); + let keymap = RuntimeKeymap::from_config(&config).unwrap(); + for template in [ + "Press {key:global.copy} to copy.", + "Press {key:global.open_transcript} or {key:global.copy}.", + "Press {key:global.missing}.", + "Press {key:missing.copy}.", + "Press {key:copy}.", + "Press {key:global.copy", + ] { + assert_eq!(render_tooltip(template, Some(&keymap)), None, "{template}"); + } + assert_eq!( + render_tooltip("Press {key:global.copy}.", /*keymap*/ None), + None + ); + assert_eq!( + render_tooltip("Use /copy to copy a response.", /*keymap*/ None), + Some("Use /copy to copy a response.".to_string()) + ); +} + +#[test] +fn reasoning_tip_requires_both_bindings() { + let template = TOOLTIPS + .iter() + .find(|tip| tip.contains("{key:chat.increase_reasoning_effort}")) + .unwrap(); + for action in ["increase_reasoning_effort", "decrease_reasoning_effort"] { + let config = serde_json::from_value(json!({ "chat": { action: [] } })).unwrap(); + let keymap = RuntimeKeymap::from_config(&config).unwrap(); + assert_eq!(render_tooltip(template, Some(&keymap)), None); + } +} + +#[test] +fn subsequent_tips_reflect_remapped_bindings() { + for (configured, label) in [ + (json!("f12"), Some("f12")), + (json!(["ctrl-x t", "f12"]), Some("ctrl+x t")), + (json!([]), None), + ] { + let config = + serde_json::from_value(json!({ "global": { "open_transcript": configured } })).unwrap(); + let keymap = RuntimeKeymap::from_config(&config).unwrap(); + assert_eq!( + resolved_tooltips(Some(&keymap)) + .find(|tip| tip.ends_with(" to open the full transcript.")), + label.map(|label| format!("Press `` {label} `` to open the full transcript.")) + ); + } +} diff --git a/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap b/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap new file mode 100644 index 000000000000..8eb1c85b91eb --- /dev/null +++ b/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap @@ -0,0 +1,54 @@ +--- +source: tui/src/tooltips/keybinding_tests.rs +assertion_line: 42 +expression: "lines.iter().map(|line| format!(\"{line:?}\")).collect::>().join(\"\\n\")" +--- +Line::from("Default") +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("tab").fg(Color::Rgb(166, 227, 161)), Span::from(" to queue a message when a")]) +Line::from("task is running; otherwise it sends") +Line::from_iter([Span::from("immediately (except "), Span::from("!").fg(Color::Rgb(166, 227, 161)), Span::from(").")]) +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use "), Span::from("/copy").bold(), Span::from(" or press "), Span::from("ctrl+o").fg(Color::Rgb(166, 227, 161)), Span::from(" to copy")]) +Line::from("the latest agent response as Markdown.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+t").fg(Color::Rgb(166, 227, 161)), Span::from(" to open the full")]) +Line::from("transcript.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("f3").fg(Color::Rgb(166, 227, 161)), Span::from(" to search this")]) +Line::from("conversation.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+g").fg(Color::Rgb(166, 227, 161)), Span::from(" to edit your current")]) +Line::from("draft in an external editor.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+r").fg(Color::Rgb(166, 227, 161)), Span::from(" to search previously")]) +Line::from("entered prompts.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" For models with adjustable")]) +Line::from_iter([Span::from("reasoning, press "), Span::from("⌥+.").fg(Color::Rgb(166, 227, 161)), Span::from(" to increase")]) +Line::from_iter([Span::from("reasoning effort or "), Span::from("⌥+,").fg(Color::Rgb(166, 227, 161)), Span::from(" to decrease it.")]) +Line::default() +Line::from("Remapped") +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+x q").fg(Color::Rgb(166, 227, 161)), Span::from(" to queue a message")]) +Line::from("when a task is running; otherwise it") +Line::from_iter([Span::from("sends immediately (except "), Span::from("!").fg(Color::Rgb(166, 227, 161)), Span::from(").")]) +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use "), Span::from("/copy").bold(), Span::from(" or press "), Span::from("ctrl+x `").fg(Color::Rgb(166, 227, 161)), Span::from(" to copy")]) +Line::from("the latest agent response as Markdown.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+x t").fg(Color::Rgb(166, 227, 161)), Span::from(" to open the full")]) +Line::from("transcript.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+x f").fg(Color::Rgb(166, 227, 161)), Span::from(" to search this")]) +Line::from("conversation.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+x e").fg(Color::Rgb(166, 227, 161)), Span::from(" to edit your current")]) +Line::from("draft in an external editor.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+x r").fg(Color::Rgb(166, 227, 161)), Span::from(" to search previously")]) +Line::from("entered prompts.") +Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" For models with adjustable")]) +Line::from_iter([Span::from("reasoning, press "), Span::from("ctrl+x +").fg(Color::Rgb(166, 227, 161)), Span::from(" to increase")]) +Line::from_iter([Span::from("reasoning effort or "), Span::from("ctrl+x -").fg(Color::Rgb(166, 227, 161)), Span::from(" to decrease")]) +Line::from("it.") +Line::default() diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index d64e786570df..984790cca830 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -86,6 +86,7 @@ pub(crate) struct TranscriptView { position: Position, follow_control: follow_control::FollowControl, copy_feedback: Option, + composer_tip: Option<(Rect, HyperlinkLine)>, cache: LayoutCache, live: Option>, live_separated: Option>, @@ -115,6 +116,7 @@ impl Default for TranscriptView { position: Position::Latest, follow_control: follow_control::FollowControl::default(), copy_feedback: None, + composer_tip: None, cache: LayoutCache::default(), live: None, live_separated: None, @@ -142,6 +144,7 @@ impl Default for TranscriptView { impl TranscriptView { pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cells: &[Arc]) { + self.composer_tip = None; self.cache.begin_frame(); self.sync_history_tail(cells); let current_cells = cells; diff --git a/codex-rs/tui/src/transcript_view/composer_gap.rs b/codex-rs/tui/src/transcript_view/composer_gap.rs index ea7bfcddb6e4..fec537370bd4 100644 --- a/codex-rs/tui/src/transcript_view/composer_gap.rs +++ b/codex-rs/tui/src/transcript_view/composer_gap.rs @@ -1,9 +1,11 @@ -//! Share the existing composer gap between transient copy feedback and reading controls. -//! Feedback wins while visible; controls release their pointer targets when replaced. +//! Share the composer gap between copy feedback, reading controls, and linked tips. +//! Feedback wins while visible; controls and tips release their pointer targets when replaced. use super::*; use crate::clipboard_copy::CopyStatus; use crate::footer_hint::first_fitting_line; +use crate::terminal_hyperlinks::HyperlinkParagraph; +use ratatui::style::Style; use std::time::Duration; use std::time::Instant; @@ -29,9 +31,10 @@ impl TranscriptView { pub(crate) fn render_composer_gap( &mut self, area: Option, - hint: Option<&Line<'static>>, + hint: Option<&HyperlinkLine>, buffer: &mut Buffer, ) -> Option { + self.composer_tip = None; if self .copy_feedback .as_ref() @@ -65,10 +68,10 @@ impl TranscriptView { && let Some(hint) = hint.filter(|hint| hint.width() + 2 <= usize::from(area.width)) { let width = hint.width() as u16; - hint.render( - Rect::new(area.right() - width - 1, area.y, width, /*height*/ 1), - buffer, - ); + let target = Rect::new(area.right() - width - 1, area.y, width, /*height*/ 1); + HyperlinkParagraph::new(std::slice::from_ref(hint), Style::default()) + .render(target, buffer); + self.composer_tip = Some((target, hint.clone())); } None } diff --git a/codex-rs/tui/src/transcript_view/composer_gap_tests.rs b/codex-rs/tui/src/transcript_view/composer_gap_tests.rs index be82877dbb99..31b07554ea5d 100644 --- a/codex-rs/tui/src/transcript_view/composer_gap_tests.rs +++ b/codex-rs/tui/src/transcript_view/composer_gap_tests.rs @@ -4,6 +4,10 @@ use super::*; use crate::transcript_view::tests::cell; use crate::transcript_view::tests::render; use crate::transcript_view::tests::text; +use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; #[test] @@ -56,3 +60,99 @@ fn feedback_releases_navigation_targets_and_expiry_restores_them() { ); assert!(text(&buffer).contains("Back to bottom")); } + +#[test] +fn tip_links_follow_alignment_and_release_stale_targets() { + let destination = "https://example.com/docs"; + let mut tip = HyperlinkLine::from("Tip: "); + tip.push_span("文档".into(), Some(destination)); + let mut view = TranscriptView::default(); + let area = Rect::new( + /*x*/ 4, /*y*/ 3, /*width*/ 30, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: area.right() - 3, + row: area.y, + modifiers: KeyModifiers::CONTROL, + }; + view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + assert!( + buffer[(click.column, click.row)] + .symbol() + .contains(destination) + ); + for column in area.right() - 5..area.right() - 1 { + for modifiers in [KeyModifiers::CONTROL, KeyModifiers::SUPER] { + let Some(ViewAction::OpenLink(url)) = view.handle_mouse( + MouseEvent { + column, + modifiers, + ..click + }, + &[], + ) else { + panic!("both columns of each linked glyph should open its destination"); + }; + assert_eq!(url, destination); + } + } + for event in [ + MouseEvent { + modifiers: KeyModifiers::NONE, + ..click + }, + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + ..click + }, + MouseEvent { + column: area.right() - 6, + ..click + }, + MouseEvent { + row: area.y + 1, + ..click + }, + ] { + assert!(view.handle_mouse(event, &[]).is_none()); + } + + for replacement in [None, Some(HyperlinkLine::from("Other tip"))] { + view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + view.render_composer_gap(Some(area), replacement.as_ref(), &mut buffer); + assert!(view.handle_mouse(click, &[]).is_none()); + } + for next_area in [ + None, + Some(Rect::new( + /*x*/ 4, /*y*/ 3, /*width*/ 0, /*height*/ 1, + )), + Some(Rect::new( + /*x*/ 4, /*y*/ 3, /*width*/ 8, /*height*/ 1, + )), + Some(Rect::new( + /*x*/ 4, /*y*/ 3, /*width*/ 20, /*height*/ 1, + )), + ] { + view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + view.render_composer_gap(next_area, Some(&tip), &mut buffer); + assert!(view.handle_mouse(click, &[]).is_none()); + } + view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + view.show_copy_feedback(&Ok(CopyStatus::Confirmed), /*characters*/ 3); + view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + assert!(view.handle_mouse(click, &[]).is_none()); + view.copy_feedback.as_mut().unwrap().expires_at = Instant::now(); + view.render_composer_gap(Some(area), Some(&tip), &mut buffer); + assert!(matches!( + view.handle_mouse(click, &[]), + Some(ViewAction::OpenLink(_)) + )); + view.render(area, &mut buffer, &[]); + assert!(!matches!( + view.handle_mouse(click, &[]), + Some(ViewAction::OpenLink(_)) + )); +} diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index 5bad1fd5992e..1ce32a9f4155 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -181,6 +181,21 @@ impl TranscriptView { if let Some(action) = self.handle_follow_control_mouse(event) { return Some(action); } + if event.kind == MouseEventKind::Down(MouseButton::Left) + && event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER) + && let Some((area, tip)) = &self.composer_tip + && area.contains(ScreenPosition::new(event.column, event.row)) + { + let column = usize::from(event.column - area.x); + return tip + .hyperlinks + .iter() + .find(|link| link.columns.contains(&column))? + .terminal_destination() + .map(ViewAction::OpenLink); + } let inside = self .area .contains(ScreenPosition::new(event.column, event.row));