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:
Eric Traut
2026-09-21 03:02:45 +00:00
committed by copyberry
parent 05f39d7346
commit 57567b8d9e
24 changed files with 929 additions and 162 deletions
@@ -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(_))
));
}
+15
View File
@@ -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));