mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
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
This commit is contained in:
@@ -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<Rect>,
|
||||
hint: Option<&Line<'static>>,
|
||||
hint: Option<&HyperlinkLine>,
|
||||
buffer: &mut Buffer,
|
||||
) -> Option<Duration> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user