Add transcript search and per-activity detail controls (#46734)

## What changed

- Add incremental, case-insensitive literal search across transcript details, loading older history as needed. Use `F3` to start Find, or `/` in the transcript pager, and navigate matches in either direction. Closing Find restores the previous presentation and reading position.
- Add clickable activity detail controls and `F4` keyboard focus for inspecting individual activity groups in the owned transcript. Preserve expansion state as live activity becomes history.
- Expose configurable `global.find_transcript`, `global.focus_activity`, and `pager.find` bindings under `tui.keymap`. Let new defaults yield to existing custom bindings and show available completions for pending chords.

## Testing

Add regression coverage for Unicode matching, bounded scanning, paginated and changing history, selection during search, composer input isolation, activity controls, and custom shortcut conflicts.

GitOrigin-RevId: 5bcbcdfbca7c5c296c635ec9b079ccd7a908e336
This commit is contained in:
Eric Traut
2026-09-20 00:06:25 +00:00
committed by copyberry
parent f1feda2299
commit df7f717c85
77 changed files with 4774 additions and 481 deletions
@@ -3,6 +3,9 @@
use super::*;
use crate::history_cell::PlainHistoryCell;
use crate::motion::MotionMode;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
fn cell(text: &str) -> Arc<dyn HistoryCell> {
@@ -63,3 +66,29 @@ fn unchanged_live_revisions_do_not_report_activity_but_hidden_text_does() {
view.scroll(&cells, /*rows*/ -3);
}
}
#[test]
fn escape_closes_selection_and_search_before_returning_to_latest() {
let mut cells = vec![cell("one\ntwo\nthree\nfour")];
let mut view = TranscriptView::default();
render(&mut view, &cells, /*height*/ 3);
view.scroll(&cells, /*rows*/ -1);
cells.push(cell("new output"));
render(&mut view, &cells, /*height*/ 3);
view.begin_search();
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
view.handle_key(escape, &cells);
assert!(view.selection.is_none());
assert!(view.is_search_active());
view.handle_key(escape, &cells);
assert!(!view.is_search_active());
assert!(!view.is_following());
assert!(matches!(
view.handle_key(escape, &cells),
Some(ViewAction::Changed)
));
assert!(view.is_following());
assert!(view.footer(/*width*/ 80, MotionMode::Reduced).is_none());
assert!(view.handle_key(escape, &cells).is_none());
}
@@ -0,0 +1,435 @@
//! Local activity disclosure keyed by retained tool identities, independent of text selection.
//! Controls follow the activity preview; copied text and full-transcript search remain source based.
use crate::style::accent_color;
use std::collections::HashSet;
use crate::bottom_pane::TranscriptFooter;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::key_label_spans;
use crate::keymap::KeymapContext;
use crate::keymap::ListAction;
use crate::keymap::RuntimeKeymap;
use crossterm::event::KeyEvent;
use ratatui::style::Modifier;
use ratatui::style::Style;
use super::*;
pub(super) struct Disclosure {
pub(super) expanded: HashSet<String>,
pub(super) focused: Option<Vec<String>>,
pub(super) live_ids: Vec<String>,
pub(super) keymap: RuntimeKeymap,
}
impl Default for Disclosure {
fn default() -> Self {
Self {
expanded: HashSet::new(),
focused: None,
live_ids: Vec::new(),
keymap: RuntimeKeymap::defaults(),
}
}
}
impl Disclosure {
pub(super) fn is_expanded(&self, ids: &[String]) -> bool {
ids.iter().any(|id| self.expanded.contains(id))
}
}
impl TranscriptView {
pub(crate) fn is_activity_focused(&self) -> bool {
self.disclosure.focused.is_some() && !self.detailed && self.mode == HistoryRenderMode::Rich
}
pub(crate) fn clear_activity_focus(&mut self) {
self.disclosure.focused = None;
}
/// Live member identities survive the transition from active widget to committed history.
pub(crate) fn sync_live_activity(
&mut self,
cells: &[Arc<dyn HistoryCell>],
ids: Vec<String>,
) -> bool {
// Disclosure follows its activity into history instead of an unrelated next live tail.
if self.snapshot().is_none()
&& let Position::Reading(anchor) = self.position
&& anchor.key == EntryKey::Live
&& let Some(index) = self.canonical_activity_index(cells, &self.disclosure.live_ids)
&& let Some(cell) = cells.get(index)
{
self.position = Position::Reading(Anchor {
key: EntryKey::cell(cell),
index,
offset: 0,
row_bias: 0,
});
}
if self.disclosure.live_ids != ids {
self.live_key = None;
}
let expanded = self.disclosure.is_expanded(&ids);
if expanded {
self.disclosure.expanded.extend(ids.iter().cloned());
}
self.disclosure.live_ids = ids;
expanded
}
pub(super) fn activity_ids(&self, cells: &[Arc<dyn HistoryCell>], index: usize) -> Vec<String> {
cells.get(index).map_or_else(
|| self.disclosure.live_ids.clone(),
|cell| cell.activity_ids(),
)
}
/// Retired live entries and replaced groups keep the identities of their displayed revision.
pub(super) fn displayed_activity_ids(
&self,
cells: &[Arc<dyn HistoryCell>],
index: usize,
) -> Arc<[String]> {
let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell);
self.snapshot()
.and_then(|snapshot| snapshot.activities.get(&key))
.cloned()
.unwrap_or_else(|| self.activity_ids(cells, index).into())
}
pub(super) fn render_disclosure(
&self,
ids: &[String],
layout: &TextLayout,
row: usize,
area: Rect,
buf: &mut Buffer,
) {
let focused = self
.disclosure
.focused
.as_ref()
.is_some_and(|focused| ids.iter().any(|id| focused.contains(id)));
if !focused || !layout.disclosure {
return;
}
if layout.disclosure_row() == Some(row) {
let columns = layout.disclosure_columns();
buf.set_style(
Rect::new(
area.x + columns.start,
area.y,
columns.end - columns.start,
/*height*/ 1,
),
Style::default()
.fg(accent_color())
.bold()
.remove_modifier(Modifier::DIM),
);
} else if row == usize::from(layout.separated) {
buf.set_style(area, Style::default().underlined());
}
}
pub(super) fn toggle_disclosure_at(
&mut self,
cells: &[Arc<dyn HistoryCell>],
column: u16,
row: u16,
) -> bool {
if self.detailed || self.mode != HistoryRenderMode::Rich {
return false;
}
let Some(visible) = row
.checked_sub(self.area.y)
.and_then(|row| self.visible.get(usize::from(row)))
else {
return false;
};
if visible.layout.disclosure_row() != Some(visible.row)
|| column
.checked_sub(self.area.x)
.is_none_or(|column| !visible.layout.disclosure_columns().contains(&column))
{
return false;
}
let Some(index) = self.canonical_activity_index(cells, &visible.activity_ids) else {
return false;
};
self.toggle_activity(cells, index);
true
}
pub(super) fn handle_disclosure_key(
&mut self,
key: KeyEvent,
cells: &[Arc<dyn HistoryCell>],
) -> Option<ViewAction> {
if self.detailed || self.mode != HistoryRenderMode::Rich || self.search.is_active() {
return None;
}
if self.disclosure.keymap.app.focus_activity.is_pressed(key) {
if self.is_activity_focused() {
self.disclosure.focused = None;
} else {
self.end_selection(cells);
let visible = self
.visible
.iter()
.rev()
.find_map(|row| self.canonical_activity_index(cells, &row.activity_ids));
for index in visible.into_iter().chain((0..=cells.len()).rev()) {
if !self.activity_ids(cells, index).is_empty()
&& self.focus_activity(cells, index)
{
break;
}
}
}
return Some(ViewAction::Changed);
}
let focused = self
.disclosure
.focused
.as_ref()
.and_then(|ids| self.canonical_activity_index(cells, ids));
let Some(focused) = focused else {
self.disclosure.focused = None;
return None;
};
let Some(action) = self.disclosure.keymap.list.action_for(key) else {
// Ordinary text resumes composing; activity navigation never becomes a text editor.
self.disclosure.focused = None;
return None;
};
match action {
ListAction::Cancel => self.disclosure.focused = None,
ListAction::Accept => self.toggle_activity(cells, focused),
ListAction::MoveLeft | ListAction::MoveRight => {
let ids = self.activity_ids(cells, focused);
if self.disclosure.is_expanded(&ids) != (action == ListAction::MoveRight) {
self.toggle_activity(cells, focused);
}
}
ListAction::MoveUp
| ListAction::MoveDown
| ListAction::PageUp
| ListAction::PageDown
| ListAction::JumpTop
| ListAction::JumpBottom => {
let (mut range, backwards, steps) = match action {
ListAction::MoveUp => (0..focused, true, 1),
ListAction::PageUp => (0..focused, true, 5),
ListAction::JumpTop => (0..cells.len() + 1, false, 1),
ListAction::MoveDown => (focused + 1..cells.len() + 1, false, 1),
ListAction::PageDown => (focused + 1..cells.len() + 1, false, 5),
ListAction::JumpBottom => (0..cells.len() + 1, true, 1),
ListAction::MoveLeft
| ListAction::MoveRight
| ListAction::Accept
| ListAction::Cancel => unreachable!(),
};
let mut next = focused;
for _ in 0..steps {
let candidate = if backwards {
range
.by_ref()
.rev()
.find(|index| !self.activity_ids(cells, *index).is_empty())
} else {
range
.by_ref()
.find(|index| !self.activity_ids(cells, *index).is_empty())
};
let Some(index) = candidate else { break };
next = index;
}
loop {
if self.focus_activity(cells, next) {
break;
}
let candidate = if backwards {
range.next_back()
} else {
range.next()
};
let Some(index) = candidate else { break };
next = index;
}
}
}
Some(ViewAction::Changed)
}
pub(super) fn canonical_activity_index(
&self,
cells: &[Arc<dyn HistoryCell>],
ids: &[String],
) -> Option<usize> {
if ids.is_empty() {
return None;
}
// A visible immutable cell resolves directly; replaced groups fall back to member IDs.
if let Some(row) = self.visible.iter().find(|row| {
row.index <= cells.len()
&& row.activity_ids.iter().any(|id| ids.contains(id))
&& cells.get(row.index).map_or(EntryKey::Live, EntryKey::cell) == row.key
&& self
.activity_ids(cells, row.index)
.iter()
.any(|id| ids.contains(id))
}) {
return Some(row.index);
}
(0..=cells.len()).find(|index| {
self.activity_ids(cells, *index)
.iter()
.any(|id| ids.contains(id))
})
}
fn focus_activity(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) -> bool {
if self
.current_layout(cells, index)
.is_none_or(|layout| !layout.disclosure)
{
return false;
}
let ids = self.activity_ids(cells, index);
let header_visible = self.visible.iter().any(|row| {
row.layout.disclosure
&& row.activity_ids.iter().any(|id| ids.contains(id))
&& row.row == usize::from(row.layout.separated)
});
self.disclosure.focused = Some(ids);
if !header_visible {
self.jump_to_entry(cells, index);
}
true
}
fn toggle_activity(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) {
let ids = self.activity_ids(cells, index);
if ids.is_empty() {
return;
}
let previous_top = self.visible.first().map(|visible| {
let offset = visible.layout.position_at(visible.row, /*column*/ 0);
(
visible.key,
Arc::clone(&visible.activity_ids),
offset,
visible.layout.row_for_offset(offset) as isize - visible.row as isize,
)
});
self.end_selection(cells);
self.release_live_reading();
// Source offsets only survive an unchanged cell. Regrouped/retired live entries resolve
// through their member IDs and anchor at their header, never a stale positional index.
let anchor = previous_top
.and_then(|(key, top_ids, offset, row_bias)| {
let unchanged = cells.iter().position(|cell| EntryKey::cell(cell) == key);
let top = unchanged.or_else(|| self.canonical_activity_index(cells, &top_ids))?;
let preserve_offset = unchanged.is_some() && top != index;
Some(Anchor {
key: cells.get(top).map_or(EntryKey::Live, EntryKey::cell),
index: top,
offset: if preserve_offset { offset } else { 0 },
row_bias: if preserve_offset { row_bias } else { 0 },
})
})
.unwrap_or(Anchor {
key: cells.get(index).map_or(EntryKey::Live, EntryKey::cell),
index,
offset: 0,
row_bias: 0,
});
if self.disclosure.is_expanded(&ids) {
self.disclosure.expanded.retain(|id| !ids.contains(id));
} else {
self.disclosure.expanded.extend(ids.iter().cloned());
}
self.disclosure.focused = Some(ids);
self.position = Position::Reading(anchor);
self.cache.clear();
self.live_key = None;
self.live_separated = None;
self.restart_search();
}
pub(super) fn disclosure_footer(&self, width: u16) -> Option<TranscriptFooter> {
if self.detailed || self.mode != HistoryRenderMode::Rich {
return None;
}
let keymap = &self.disclosure.keymap;
let text = if self.is_activity_focused() {
let hints: Vec<_> = [
(ListAction::MoveUp, "previous"),
(ListAction::MoveDown, "next"),
(ListAction::Accept, "details"),
(ListAction::Cancel, "back"),
]
.into_iter()
.filter_map(|(action, label)| {
keymap.list.primary_hint(action).map(|key| {
let mut spans = key.spans();
spans.push(format!(" {label}").dim());
Line::from(spans)
})
})
.collect();
// Drop navigation before the details/back controls, retaining whole remapped chords.
crate::footer_hint::first_fitting_line(
(0..hints.len()).map(|start| {
let mut spans = Vec::new();
for (index, hint) in hints[start..].iter().enumerate() {
if index > 0 {
spans.push(" · ".dim());
}
spans.extend(hint.spans.clone());
}
Line::from(spans)
}),
width,
)
} else {
if !self.visible.iter().any(|row| row.layout.disclosure) {
return None;
}
let key = keymap.primary_hint(KeymapContext::Global, "focus_activity")?;
let primary = key.display_label();
let alternatives = std::iter::once(primary.clone())
.chain(
crate::keymap::user_bindings(&keymap.app.focus_activity)
.iter()
.map(crate::key_hint::KeyBinding::display_label)
.filter(|label| *label != primary),
)
.take(/*n*/ 2)
.collect::<Vec<_>>()
.join("/");
crate::footer_hint::first_fitting_line(
[
(alternatives.as_str(), " inspect activity"),
(alternatives.as_str(), " activity"),
(primary.as_str(), " activity"),
]
.map(|(keys, action)| {
let mut spans = key_label_spans(keys);
spans.push(action.dim());
Line::from(spans)
}),
width,
)
};
Some(TranscriptFooter {
text: text.into(),
cursor_column: None,
is_interactive: self.is_activity_focused(),
})
}
}
@@ -23,6 +23,7 @@ impl TranscriptView {
&& self.can_return_to_latest()
&& self.highlight.is_none()
&& self.selection.is_none()
&& !self.is_search_active()
});
let Some(area) = area else {
self.follow_control = FollowControl::default();
+77 -59
View File
@@ -26,7 +26,21 @@ impl TranscriptView {
(selection.start.key, selection.start.offset)
!= (selection.end.key, selection.end.offset)
});
if let Some((query, cursor)) = self.search_footer(width) {
return Some(TranscriptFooter {
text: vec![
query,
if has_selected_text {
selection_hint(width)
} else {
self.search.status_line(width, self.history)
},
]
.into(),
cursor_column: self.selection.is_none().then_some(cursor),
is_interactive: true,
});
}
if has_selected_text {
return Some(TranscriptFooter {
text: first_fitting_line(
@@ -44,68 +58,72 @@ impl TranscriptView {
is_interactive: true,
});
}
if self.is_activity_focused() {
return self.disclosure_footer(width);
}
let pending = self.is_loading_history() || self.history == TranscriptHistoryState::Failed;
let can_return = self.selection.is_none() && self.can_return_to_latest();
(pending || self.unseen_activity || can_return).then(|| {
let navigation = if self.can_return_to_latest() {
latest_navigation
} else {
""
};
let mut status = self.status_line_with_navigation(navigation, motion);
// Loading must not resize the composer or move its caret. Shorten secondary text
// before clipping either shortcut; failed loads prioritize their retry action.
if status.width() > usize::from(width) {
status = if self.is_loading_history() {
let loading = if navigation.is_empty() {
if "↑ Loading…".width() <= usize::from(width) {
"Loading…"
} else {
"…"
}
} else if "↑ Loading… · ".width() + navigation.width() <= usize::from(width)
{
"Loading… · "
} else {
"… "
};
let mut spans = vec!["↑ ".fg(accent_color())];
spans.extend(shimmer_text(loading, motion));
spans.extend(navigation_line(navigation).spans);
Line::from(spans)
} else if self.history == TranscriptHistoryState::Failed {
let mut spans = vec!["Retry: ".dim()];
spans.extend(key_label_spans(&JumpTarget::Beginning.hint_label()));
Line::from(spans)
} else if navigation.is_empty() {
Line::from("New activity").dim()
} else if self.unseen_activity {
let mut spans = vec!["New · ".dim()];
spans.extend(navigation_line(navigation).spans);
Line::from(spans)
(pending || self.unseen_activity || can_return)
.then(|| {
let navigation = if self.can_return_to_latest() {
latest_navigation
} else {
navigation_line(navigation)
""
};
}
let retry = if self.history == TranscriptHistoryState::Failed {
format!(
"{} retry",
crate::key_hint::ctrl(KeyCode::Home).display_label()
)
} else {
navigation.to_owned()
};
TranscriptFooter {
text: first_fitting_line(
[status, navigation_line(&retry), navigation_line(navigation)],
width,
)
.into(),
cursor_column: None,
is_interactive: false,
}
})
let mut status = self.status_line_with_navigation(navigation, motion);
// Loading must not resize the composer or move its caret. Shorten secondary text
// before clipping either shortcut; failed loads prioritize their retry action.
if status.width() > usize::from(width) {
status = if self.is_loading_history() {
let loading = if navigation.is_empty() {
if "↑ Loading…".width() <= usize::from(width) {
"Loading…"
} else {
"…"
}
} else if "↑ Loading… · ".width() + navigation.width() <= usize::from(width)
{
"Loading… · "
} else {
"… "
};
let mut spans = vec!["↑ ".fg(accent_color())];
spans.extend(shimmer_text(loading, motion));
spans.extend(navigation_line(navigation).spans);
Line::from(spans)
} else if self.history == TranscriptHistoryState::Failed {
let mut spans = vec!["Retry: ".dim()];
spans.extend(key_label_spans(&JumpTarget::Beginning.hint_label()));
Line::from(spans)
} else if navigation.is_empty() {
Line::from("New activity").dim()
} else if self.unseen_activity {
let mut spans = vec!["New · ".dim()];
spans.extend(navigation_line(navigation).spans);
Line::from(spans)
} else {
navigation_line(navigation)
};
}
let retry = if self.history == TranscriptHistoryState::Failed {
format!(
"{} retry",
crate::key_hint::ctrl(KeyCode::Home).display_label()
)
} else {
navigation.to_owned()
};
TranscriptFooter {
text: first_fitting_line(
[status, navigation_line(&retry), navigation_line(navigation)],
width,
)
.into(),
cursor_column: None,
is_interactive: false,
}
})
.or_else(|| self.disclosure_footer(width))
}
pub(crate) fn status_line_with_navigation(
@@ -6,7 +6,12 @@ use crate::style::accent_color;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use pretty_assertions::assert_eq;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
fn render(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>]) -> Buffer {
let area = Rect::new(
@@ -17,6 +22,53 @@ fn render(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>]) -> Buffer {
buffer
}
#[test]
fn selection_hints_follow_selected_text_instead_of_mouse_down() {
let cells: Vec<Arc<dyn HistoryCell>> =
vec![Arc::new(PlainHistoryCell::new(vec!["é selection".into()]))];
let mut snapshots = Vec::new();
for searching in [false, true] {
let mut view = TranscriptView::default();
render(&mut view, &cells);
if searching {
view.begin_search();
}
for (label, kind, column) in [
("mouse down", MouseEventKind::Down(MouseButton::Left), 0),
("one character", MouseEventKind::Drag(MouseButton::Left), 1),
("back to anchor", MouseEventKind::Drag(MouseButton::Left), 0),
("released empty", MouseEventKind::Up(MouseButton::Left), 0),
] {
view.handle_mouse(
MouseEvent {
kind,
column,
row: 0,
modifiers: KeyModifiers::NONE,
},
&cells,
);
render(&mut view, &cells);
let footer = view.footer(/*width*/ 80, MotionMode::Reduced);
if label == "one character" {
assert_eq!(view.selected_text(&cells).as_deref(), Some("é"));
}
let text = footer.map_or_else(
|| "<composer hints>".to_string(),
|footer| footer.text.to_string(),
);
snapshots.push(format!("searching={searching} · {label}\n{text}"));
}
}
let joined = snapshots.join("\n\n");
let snapshot = joined
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("selection_hint_threshold", snapshot);
}
#[test]
fn loading_preserves_selected_content_and_copy_action() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(PlainHistoryCell::new(vec![
@@ -37,7 +89,7 @@ fn loading_preserves_selected_content_and_copy_action() {
let footer = view
.footer(/*width*/ 80, MotionMode::Reduced)
.expect("loading selection footer");
assert_eq!(footer.cursor_column, None);
assert_eq!((footer.cursor_column, footer.is_interactive), (None, true));
insta::allow_duplicates! {
insta::assert_snapshot!(footer.text.to_string(), @"↑ Loading earlier messages… · ctrl+c copy · enter copy & follow · esc clear");
}
@@ -67,6 +119,50 @@ fn loading_preserves_selected_content_and_copy_action() {
@"enter copy & follow · esc clear");
}
#[test]
fn loading_and_failure_preserve_search_query_and_caret() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(PlainHistoryCell::new(vec![
"visible history".into(),
]))];
let mut view = TranscriptView::default();
render(&mut view, &cells);
view.history = TranscriptHistoryState::Partial;
view.begin_search();
view.paste_search("missing needle");
assert!(!view.advance_search(&cells));
assert!(view.needs_history(&cells));
let mut hints = Vec::new();
for width in [32, 12] {
let editor = view.search_footer(width).expect("search editor");
for state in [
TranscriptHistoryState::LoadingOlder,
TranscriptHistoryState::LoadingBeginning,
TranscriptHistoryState::Failed,
] {
view.history = state;
view.advance_search(&cells);
let footer = view
.footer(width, MotionMode::Reduced)
.expect("search footer");
assert_eq!(
(footer.text.lines[0].clone(), footer.cursor_column),
(editor.0.clone(), Some(editor.1)),
);
assert!(footer.is_interactive);
let hint = &footer.text.lines[1];
assert!(hint.width() <= usize::from(width));
hints.push(format!("{width} columns, {state:?}: {hint}"));
}
}
insta::assert_snapshot!(hints.join("\n"));
view.history = TranscriptHistoryState::Complete;
view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells);
assert!(!view.is_search_active());
assert!(!view.is_detailed());
assert!(view.footer(/*width*/ 32, MotionMode::Reduced).is_none());
}
#[test]
fn loading_completion_restores_unseen_activity_and_failure_stops_motion() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(PlainHistoryCell::new(vec![
@@ -136,8 +232,38 @@ fn passive_footer_keeps_jump_actions_in_one_row() {
insta::assert_snapshot!("compact_navigation_hints", snapshots.join("\n"));
}
#[derive(Debug)]
struct ActivityCell;
#[test]
fn narrow_selection_and_search_keep_complete_exit_hints() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(PlainHistoryCell::new(vec![
"selected words".into(),
]))];
let mut view = TranscriptView::default();
render(&mut view, &cells);
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
view.end_drag();
let mut snapshots = Vec::new();
for searching in [false, true] {
if searching {
view.end_selection(&cells);
view.begin_search();
view.paste_search("selected");
view.advance_search(&cells);
}
for width in [12, 20, 32, 80] {
let footer = view.footer(width, MotionMode::Reduced).unwrap();
let hint = footer.text.lines.last().unwrap();
assert!(hint.width() <= usize::from(width));
assert!(hint.to_string().contains("esc"));
snapshots.push(format!("search={searching}, {width} columns: {hint}"));
}
}
insta::assert_snapshot!(snapshots.join("\n"));
}
#[derive(Debug, Default)]
struct ActivityCell {
expanded_renders: AtomicUsize,
}
impl HistoryCell for ActivityCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
@@ -152,11 +278,110 @@ impl HistoryCell for ActivityCell {
vec!["Activity summary".into(), "Hidden details".into()]
}
fn expanded_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.expanded_renders
.fetch_add(/*val*/ 1, Ordering::Relaxed);
self.transcript_hyperlink_lines(width)
}
fn activity_ids(&self) -> Vec<String> {
vec!["sample-activity".into()]
}
}
#[test]
fn activity_alternative_focuses_details_and_hints_follow_configured_chords() {
let mut cells: Vec<Arc<dyn HistoryCell>> = Vec::new();
let cell = Arc::new(ActivityCell::default());
let mut view = TranscriptView::default();
let render_live = |view: &mut TranscriptView| {
let expanded = view.sync_live_activity(&cells, cell.activity_ids());
view.sync_live_activity_tail(/*width*/ 32, /*key*/ None, expanded, |width| {
Some(ActivityTranscriptLines {
activity: if expanded {
cell.expanded_hyperlink_lines(width)
} else {
cell.compact_hyperlink_lines(width)
},
auxiliary: Vec::new(),
has_hidden_details: true,
})
});
render(view, &[])
};
let collapsed = render_live(&mut view);
let mut hints = vec![format!("collapsed\n{collapsed:?}")];
for width in [20, 32, 80] {
hints.push(format!(
"{width} columns: {}",
view.footer(width, MotionMode::Reduced).unwrap().text,
));
}
view.handle_key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE), &cells);
assert!(view.is_activity_focused());
hints.push(format!("focused\n{:?}", render_live(&mut view)));
view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells);
assert!(view.disclosure.is_expanded(&["sample-activity".to_owned()]));
let expanded = render_live(&mut view);
hints.push(format!("expanded\n{expanded:?}"));
let hidden = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 0,
);
view.render(hidden, &mut Buffer::empty(hidden), &cells);
cells.push(cell.clone());
view.sync_live_activity(&cells, Vec::new());
view.sync_live_tail(/*width*/ 32, /*key*/ None, |_| None);
assert_eq!(render(&mut view, &cells), expanded);
assert!(cell.expanded_renders.swap(/*val*/ 0, Ordering::Relaxed) > 0);
view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells);
render(&mut view, &cells);
assert_eq!(cell.expanded_renders.load(Ordering::Relaxed), 0);
view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells);
assert!(!view.is_activity_focused());
for configured in [
serde_json::json!("ctrl-x t"),
serde_json::json!(["ctrl-x t", "f4"]),
serde_json::json!([]),
] {
let config = serde_json::from_value(serde_json::json!({
"global": {"focus_activity": configured}
}))
.unwrap();
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).unwrap();
view.set_keymap_bindings(&keymap);
view.jump_to_latest();
render(&mut view, &cells);
let hint = view
.footer(/*width*/ 80, MotionMode::Reduced)
.map_or_else(|| "unbound".to_owned(), |footer| footer.text.to_string());
hints.push(format!("{configured}: {hint}"));
}
insta::assert_snapshot!(hints.join("\n"));
}
#[test]
fn transcript_footer_keys_remain_distinct_from_prose_and_query_text() {
use ratatui::widgets::Paragraph;
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(ActivityCell::default())];
let mut view = TranscriptView::default();
render(&mut view, &cells);
view.begin_search();
view.paste_search("Activity");
view.advance_search(&cells);
let footer = view.footer(/*width*/ 80, MotionMode::Reduced).unwrap();
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 80,
footer.text.height() as u16,
);
let mut buffer = Buffer::empty(area);
Paragraph::new(footer.text).render(area, &mut buffer);
insta::assert_snapshot!(format!("{buffer:?}"));
}
impl TranscriptView {
pub(crate) fn footer(&self, width: u16, motion: MotionMode) -> Option<TranscriptFooter> {
self.footer_with_navigation(width, motion, "esc latest")
+14 -3
View File
@@ -102,7 +102,10 @@ impl TranscriptView {
|| (modifiers == KeyModifiers::NONE
&& matches!(code, KeyCode::PageUp | KeyCode::PageDown));
}
false
self.is_search_active()
&& (matches!(code, KeyCode::Esc | KeyCode::Enter)
|| (modifiers == KeyModifiers::CONTROL
&& matches!(code, KeyCode::Char('c' | 'n' | 'p'))))
}
pub(crate) fn handle_key(
@@ -113,7 +116,9 @@ impl TranscriptView {
if key.kind == KeyEventKind::Release {
return None;
}
if let Some(action) = self.handle_disclosure_key(key, cells) {
return Some(action);
}
if self.selection.is_some() {
if let Some(action) = self.handle_selection_key(key, cells) {
return Some(action);
@@ -129,7 +134,9 @@ impl TranscriptView {
}
return Some(ViewAction::Changed);
}
if self.handle_search_key(key, cells) {
return Some(ViewAction::Changed);
}
self.handle_scroll_key(key, cells)
}
@@ -259,6 +266,10 @@ impl TranscriptView {
event: MouseEvent,
cells: &[Arc<dyn HistoryCell>],
) -> Option<ViewAction> {
if event.modifiers.is_empty() && self.toggle_disclosure_at(cells, event.column, event.row) {
return Some(ViewAction::Changed);
}
self.disclosure.focused = None;
let visible = self
.visible
.get(usize::from(event.row.checked_sub(self.area.y)?))?;
+61 -2
View File
@@ -10,6 +10,39 @@ use super::*;
const MAX_CACHED_ENTRIES: usize = 64;
const MAX_CACHED_TEXT_BYTES: usize = 8 * 1024 * 1024;
/// The selected activity presentation and any live content that follows it.
pub(crate) struct ActivityTranscriptLines {
pub(crate) activity: Vec<HyperlinkLine>,
pub(crate) auxiliary: Vec<HyperlinkLine>,
pub(crate) has_hidden_details: bool,
}
/// Keep disclosure controls outside selectable source without rendering hidden details.
pub(super) fn activity_layout(
mut lines: ActivityTranscriptLines,
width: u16,
expanded: bool,
) -> TextLayout {
let has_details = !lines.activity.is_empty() && (expanded || lines.has_hidden_details);
let source_offset = (!lines.auxiliary.is_empty())
.then(|| TextLayout::new(lines.activity.clone(), width).text().len());
lines.activity.extend(lines.auxiliary);
let layout = TextLayout::new(lines.activity, width);
if !has_details {
return layout;
}
let label = if expanded {
"− Show less"
} else {
"+ Show details"
}
.to_owned();
match source_offset {
Some(offset) => layout.with_disclosure_control_at(label, offset),
None => layout.with_disclosure_control(label),
}
}
#[derive(Default)]
pub(super) struct LayoutCache {
entries: Vec<CachedLayout>,
@@ -37,6 +70,8 @@ struct CachedLayout {
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) struct CellPresentation {
separated: bool,
expanded: bool,
disclosure: bool,
}
impl TranscriptView {
@@ -92,10 +127,34 @@ impl TranscriptView {
}
let detailed = self.detailed;
let mode = self.mode;
let ids = cell.activity_ids();
let disclosure = !detailed && mode == HistoryRenderMode::Rich && !ids.is_empty();
let expanded = disclosure && self.disclosure.is_expanded(&ids);
if expanded {
self.disclosure.expanded.extend(ids);
}
let separated = index > 0 && !cell.is_stream_continuation();
let presentation = CellPresentation { separated };
let presentation = CellPresentation {
separated,
expanded,
disclosure,
};
Some(self.cache.get(cell, width, presentation, || {
if detailed {
if disclosure {
activity_layout(
ActivityTranscriptLines {
activity: if expanded {
cell.expanded_hyperlink_lines(width)
} else {
cell.compact_hyperlink_lines(width)
},
auxiliary: Vec::new(),
has_hidden_details: cell.has_hidden_activity_details(width),
},
width,
expanded,
)
} else if detailed {
TextLayout::new(cell.transcript_hyperlink_lines(width), width)
} else if mode == HistoryRenderMode::Rich {
TextLayout::new(cell.compact_hyperlink_lines(width), width)
@@ -45,7 +45,11 @@ fn layouts_refresh_for_width_animation_and_mutable_frames() {
cache.get(
&history,
width,
CellPresentation { separated: false },
CellPresentation {
separated: false,
expanded: false,
disclosure: false,
},
|| TextLayout::new(history.transcript_hyperlink_lines(width), width),
)
};
@@ -95,7 +99,11 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() {
cache.get(
&history,
/*width*/ 20,
CellPresentation { separated: false },
CellPresentation {
separated: false,
expanded: false,
disclosure: false,
},
|| {
TextLayout::new(
history.transcript_hyperlink_lines(/*width*/ 20),
@@ -109,7 +117,11 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() {
cache.get(
&history,
/*width*/ 20,
CellPresentation { separated: false },
CellPresentation {
separated: false,
expanded: false,
disclosure: false,
},
|| {
TextLayout::new(
history.transcript_hyperlink_lines(/*width*/ 20),
+10 -25
View File
@@ -5,29 +5,6 @@ use std::ops::Range;
use super::*;
impl TranscriptView {
pub(crate) fn history_loaded(
&mut self,
cells: &[Arc<dyn HistoryCell>],
inserted: Range<usize>,
) {
// With only a header and live output, the first older page extends the canonical tail.
if !inserted.is_empty()
&& inserted.end == cells.len()
&& self.last_tail == cells[..inserted.start].last().map(EntryKey::cell)
{
self.last_tail = cells.last().map(EntryKey::cell);
}
self.prepend_snapshot_history(cells, inserted);
if let Position::Reading(anchor) = self.position {
let index = self.resolve(cells, anchor);
self.position = Position::Reading(Anchor {
key: self.entry_key(cells, index),
index,
..anchor
});
}
}
/// Refresh unseen activity before either footer measurement or transcript painting.
pub(crate) fn sync_history_tail(&mut self, cells: &[Arc<dyn HistoryCell>]) {
let tail = cells.last().map(EntryKey::cell);
@@ -58,12 +35,14 @@ impl TranscriptView {
if range.is_empty() {
return;
}
self.retain_search_origin(cells, range.clone());
if range.end == cells.len() && self.last_tail == cells.last().map(EntryKey::cell) {
self.last_tail = Some(EntryKey::cell(replacement));
}
if self.snapshot().is_some() {
return;
}
self.restart_search();
let reading = match self.position {
Position::Reading(anchor) if range.contains(&self.resolve(cells, anchor)) => {
Some(anchor)
@@ -103,10 +82,12 @@ impl TranscriptView {
if range.is_empty() {
return;
}
self.retain_search_origin(cells, range.clone());
// Older history may extend the final group without introducing new activity.
if range.end == cells.len() && self.last_tail == cells.last().map(EntryKey::cell) {
self.last_tail = Some(EntryKey::cell(replacement));
}
let searching = self.search.has_active_query();
let reading = match self.position {
Position::Reading(anchor) => Some(anchor.key),
Position::Latest => None,
@@ -119,7 +100,8 @@ impl TranscriptView {
if let Some(snapshot) = self.snapshot_mut() {
let replaced = &cells[range];
// Pins retain the initial viewport and selected text; later scrolling adds no pins.
if visible
if searching
|| visible
|| replaced.iter().any(|cell| {
let key = EntryKey::cell(cell);
snapshot.pinned.contains_key(&key) || reading == Some(key)
@@ -140,7 +122,8 @@ impl TranscriptView {
snapshot.cells = joined.into();
return;
}
if matches!(self.position, Position::Reading(anchor)
if searching
|| matches!(self.position, Position::Reading(anchor)
if cells[range].iter().any(|cell| EntryKey::cell(cell) == anchor.key))
{
self.held_reading = Some(self.capture_snapshot(cells));
@@ -160,6 +143,7 @@ impl TranscriptView {
let Some((tail, preceding)) = cells.split_last() else {
return;
};
self.retain_search_origin(cells, preceding.len()..cells.len());
let tail_key = EntryKey::cell(tail);
if self.last_tail == Some(tail_key) {
self.last_tail = preceding.last().map(EntryKey::cell);
@@ -176,6 +160,7 @@ impl TranscriptView {
if self.snapshot().is_some() {
return;
}
self.restart_search();
if let Position::Reading(anchor) = self.position
&& (anchor.key == tail_key || anchor.key == EntryKey::Live)
{
@@ -354,3 +354,98 @@ fn absorbing_older_history_excludes_only_the_hydration_revision_from_new_activit
);
}
}
#[test]
fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() {
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::fold_trailing_activity_details;
use crate::thread_transcript::thread_items_to_transcript_cells;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStatus;
let call = serde_json::from_value(serde_json::json!({
"type": "mcpToolCall", "id": "call", "server": "cua_repl", "tool": "js",
"status": "completed", "arguments": {"title": "Inspect"},
"result": {"content": [{"type": "text", "text": "retained call output"}]}, "durationMs": 1
}))
.expect("computer call");
let reasoning = ThreadItem::Reasoning {
id: "reasoning".to_owned(),
summary: vec!["retained needle text with exact spacing".to_owned()],
content: Vec::new(),
};
let current = Turn {
id: "turn".to_owned(),
items: vec![call, reasoning],
items_view: TurnItemsView::Full,
status: TurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
};
let project = |items: &[ThreadItem]| {
thread_items_to_transcript_cells(
/*thread_id*/ None,
&codex_utils_absolute_path::AbsolutePathBuf::current_dir().unwrap(),
items.iter().cloned(),
RawReasoningVisibility::Hidden,
/*config*/ None,
)
};
for selecting in [false, true] {
let mut cells = project(&current.items[1..]);
let mut view = TranscriptView::default();
view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich);
view.jump_to_entry(&cells, /*index*/ 0);
render(&mut view, &cells, /*width*/ 30);
if selecting {
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 1);
for _ in 0..8 {
view.selection_key(&cells, crossterm::event::KeyCode::Right);
}
} else {
view.begin_search();
view.paste_search("needle");
for _ in 0..8 {
if !view.advance_search(&cells) {
break;
}
}
assert!(
view.search
.status_line(/*width*/ 80, view.history)
.to_string()
.starts_with("enter next")
);
}
let before = render(&mut view, &cells, /*width*/ 30);
let copied = view.selected_text(&cells);
if selecting {
assert!(copied.as_ref().is_some_and(|text| !text.is_empty()));
}
cells.insert(
/*index*/ 0,
project(&current.items[..1]).remove(/*index*/ 0),
);
view.history_loaded(&cells, 0..1);
let joined =
fold_trailing_activity_details(&cells[0], &cells[1..], std::slice::from_ref(&current))
.unwrap();
view.replace_group(&cells, 0..2, &joined);
cells.splice(0..2, [joined]);
assert_eq!(render(&mut view, &cells, /*width*/ 30), before);
render(&mut view, &cells, /*width*/ 16);
assert_eq!(view.selected_text(&cells), copied);
if !selecting {
assert!(
view.search
.status_line(/*width*/ 80, view.history)
.to_string()
.starts_with("enter next")
);
}
}
}
+732
View File
@@ -0,0 +1,732 @@
//! Incremental literal search over the current transcript presentation.
//!
//! Search retains one match and one entry being scanned. Each frame examines a bounded text
//! chunk; reaching the oldest loaded entry asks the app's existing history pager to continue.
//! The pager owns loading and failure status; search only remembers that it needs another page.
use crate::bottom_pane::TextArea;
use crate::bottom_pane::TextAreaState;
use crate::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::text::Span;
use ratatui::widgets::StatefulWidgetRef;
use std::ops::Range;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use super::*;
const QUERY_BYTES: usize = 4096;
const SCAN_BYTES: usize = 16 * 1024;
const ENTRIES_PER_FRAME: usize = 8;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Direction {
Older,
Newer,
}
#[derive(Clone, Copy)]
struct Cursor {
anchor: Anchor,
direction: Direction,
}
#[derive(Clone, Copy, Default)]
enum Progress {
#[default]
Idle,
Restart,
Scanning(Cursor),
AwaitingHistory,
Found,
Exhausted,
}
struct Match {
anchor: Anchor,
end: usize,
}
pub(super) struct Search {
active: bool,
editor: TextArea,
folded_query: String,
saved_position: Position,
saved_snapshot: Option<ViewSnapshot>,
saved_detailed: bool,
current: Option<Match>,
progress: Progress,
scanning_layout: Option<(EntryKey, Arc<TextLayout>)>,
page_start: Option<EntryKey>,
query_truncated: bool,
}
impl Default for Search {
fn default() -> Self {
Self {
active: false,
editor: TextArea::new(),
folded_query: String::new(),
saved_position: Position::default(),
saved_snapshot: None,
saved_detailed: false,
current: None,
progress: Progress::Idle,
scanning_layout: None,
page_start: None,
query_truncated: false,
}
}
}
impl TranscriptView {
pub(crate) fn begin_search(&mut self) {
self.cancel_beginning();
if self.search.active {
return;
}
let saved_position = self.position;
let saved_snapshot = self
.selection
.take()
.map(|selection| selection.snapshot)
.or(self.held_reading.take());
let saved_detailed = self.detailed;
self.search.active = true;
self.disclosure.focused = None;
self.set_presentation(/*detailed*/ true, self.mode);
self.search.saved_position = saved_position;
self.search.saved_snapshot = saved_snapshot;
self.search.saved_detailed = saved_detailed;
self.search.progress = Progress::Restart;
}
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
// Resolved keymaps are immutable; config changes replace their shared chord map.
if Arc::ptr_eq(&self.disclosure.keymap.chords, &keymap.chords) {
return;
}
self.search.editor.set_keymap_bindings(keymap);
self.disclosure.keymap = keymap.clone();
}
pub(crate) fn is_search_active(&self) -> bool {
self.search.active
}
/// Project the editor and its caret from the same one-row viewport into the existing footer.
pub(crate) fn search_footer(&self, width: u16) -> Option<(Line<'static>, u16)> {
if !self.search.active || width == 0 {
return None;
}
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1);
let mut buffer = Buffer::empty(area);
let prefix_width = width.saturating_sub(/*rhs*/ 1).min(/*other*/ 6);
Line::from("Find: ").dim().render(
Rect::new(/*x*/ 0, /*y*/ 0, prefix_width, /*height*/ 1),
&mut buffer,
);
let query_area = Rect::new(
prefix_width,
/*y*/ 0,
width - prefix_width,
/*height*/ 1,
);
let mut state = TextAreaState::default();
StatefulWidgetRef::render_ref(&&self.search.editor, query_area, &mut buffer, &mut state);
let (cursor_column, _) = self
.search
.editor
.cursor_pos_with_state(query_area, state)?;
let mut spans = Vec::new();
let mut column = 0;
while column < width {
let cell = &buffer[(column, 0)];
spans.push(Span::styled(cell.symbol().to_string(), cell.style()));
column += cell.symbol().width().max(/*other*/ 1) as u16;
}
Some((Line::from(spans), cursor_column))
}
pub(super) fn handle_search_key(
&mut self,
key: KeyEvent,
cells: &[Arc<dyn HistoryCell>],
) -> bool {
if !self.search.active {
return false;
}
let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers);
match (code, modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.cancel_search();
}
// Ctrl+N/P stay distinct when a legacy terminal encodes Shift+Enter as Enter.
(KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
self.next_search_match(cells, Direction::Newer);
}
(KeyCode::Enter, KeyModifiers::SHIFT) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
self.next_search_match(cells, Direction::Older);
}
_ => {
let before = self.search.editor.text().to_string();
let cursor = self.search.editor.cursor();
self.search.editor.input(key);
if self.search.editor.text().len() > QUERY_BYTES {
self.search.editor.set_text_clearing_elements(&before);
self.search.editor.set_cursor(cursor);
self.search.query_truncated = true;
} else if self.search.editor.text() != before {
if self.held_reading.take().is_some() {
self.visible.clear();
self.position = Position::Latest;
}
self.search.query_changed();
}
}
}
// Find owns every key until it closes, including unbound keys and editor chords.
true
}
/// Retain the pre-search revision when canonical history retires the cell anchoring it.
pub(super) fn retain_search_origin(
&mut self,
cells: &[Arc<dyn HistoryCell>],
retiring: std::ops::Range<usize>,
) {
if self.search.active
&& self.search.saved_snapshot.is_none()
&& let Position::Reading(anchor) = self.search.saved_position
&& cells[retiring]
.iter()
.any(|cell| EntryKey::cell(cell) == anchor.key)
{
let mut snapshot = self.capture_snapshot(cells);
if self.detailed != self.search.saved_detailed {
snapshot.pinned.clear();
snapshot.activities.clear();
}
self.search.saved_snapshot = Some(snapshot);
}
}
/// Restore the pre-search presentation before an explicit return or cancellation.
pub(super) fn cancel_search(&mut self) {
if !self.search.active {
return;
}
let position = self.search.saved_position;
let snapshot = self.search.saved_snapshot.take().or_else(|| {
let Position::Reading(anchor) = position else {
return None;
};
let mut snapshot = self.held_reading.take().filter(|snapshot| {
snapshot
.cells
.iter()
.any(|cell| EntryKey::cell(cell) == anchor.key)
})?;
if self.detailed != self.search.saved_detailed {
snapshot.pinned.clear();
snapshot.activities.clear();
}
Some(snapshot)
});
let detailed = self.search.saved_detailed;
self.set_presentation(detailed, self.mode);
self.search.active = false;
self.search.editor.set_text_clearing_elements("");
self.search.query_changed();
self.search.progress = Progress::Idle;
self.position = position;
self.held_reading = snapshot;
self.rewrap_snapshot(self.area.width);
}
pub(crate) fn paste_search(&mut self, text: &str) -> bool {
if !self.search.active {
return false;
}
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let remaining = QUERY_BYTES.saturating_sub(self.search.editor.text().len());
let end = normalized
.grapheme_indices(/*is_extended*/ true)
.map(|(offset, grapheme)| offset + grapheme.len())
.take_while(|end| *end <= remaining)
.last()
.unwrap_or_default();
if end > 0 {
if self.held_reading.take().is_some() {
self.visible.clear();
self.position = Position::Latest;
}
self.search.editor.insert_str(&normalized[..end]);
self.search.query_changed();
}
self.search.query_truncated = end < normalized.len();
true
}
/// Restart after a presentation change invalidates the searched text and its source offsets.
pub(crate) fn restart_search(&mut self) {
if self.search.active {
self.search.query_changed();
}
}
/// Drop an in-progress scan of a live revision when the visible source changes. A match
/// already being read has its own snapshot and remains valid until navigation leaves it.
pub(super) fn invalidate_live_search(&mut self) {
if self.held_reading.is_none()
&& matches!(
self.search.progress,
Progress::Scanning(Cursor {
anchor: Anchor {
key: EntryKey::Live,
..
},
..
})
)
{
self.restart_search();
}
}
pub(super) fn invalidate_held_search(&mut self) {
if self.search.has_active_query() {
self.restart_search();
}
}
/// Prepare geometry before scanning: offsets belong to a particular source layout.
pub(crate) fn prepare_width(&mut self, width: u16) {
if width == self.area.width {
return;
}
self.rewrap_snapshot(width);
self.area.width = width;
if self.held_reading.is_none() || !matches!(self.search.progress, Progress::Found) {
self.restart_search();
}
}
/// Return true only while another local scanning frame can make progress.
pub(crate) fn advance_search(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
if self.selection.is_some() {
return false;
}
let current_cells = cells;
let snapshot = self.snapshot_cells();
let cells = snapshot.as_deref().unwrap_or(cells);
if matches!(self.search.progress, Progress::Restart) {
self.search.progress = Progress::Idle;
// A refined query must not leave an obsolete match at the top while history loads.
self.position = self.search.saved_position;
if self.search.editor.is_empty() {
self.held_reading = self.search.saved_snapshot.clone();
self.rewrap_snapshot(self.area.width);
return false;
}
self.start_search_scan(cells, Direction::Older);
}
if matches!(self.search.progress, Progress::AwaitingHistory)
&& matches!(
self.history,
TranscriptHistoryState::Complete | TranscriptHistoryState::Idle
)
{
self.search.progress = Progress::Exhausted;
}
let mut scanned_bytes = 0;
for _ in 0..ENTRIES_PER_FRAME {
let Progress::Scanning(mut cursor) = self.search.progress else {
return false;
};
cursor.anchor.index = self.resolve(cells, cursor.anchor);
let Some(layout) = self.search_layout(cells, cursor.anchor) else {
self.advance_search_cursor(cells, cursor, 0..0, /*text_len*/ 0);
return false;
};
let text = layout.text();
cursor.anchor.offset = text.floor_char_boundary(cursor.anchor.offset.min(text.len()));
let window = scan_window(text, cursor, self.search.folded_query.len());
if let Some(range) = find_literal(
&text[window.clone()],
&self.search.folded_query,
cursor.direction,
) {
let anchor = Anchor {
offset: window.start + range.start,
..cursor.anchor
};
self.position = Position::Reading(anchor);
self.hold_live_reading(current_cells, Arc::clone(&layout));
self.search.current = Some(Match {
anchor,
end: window.start + range.end,
});
self.search.progress = Progress::Found;
self.search.scanning_layout = None;
return false;
}
scanned_bytes += window.len();
self.advance_search_cursor(cells, cursor, window, text.len());
// Batch short entries without letting a frame scan an unbounded transcript.
// The last window can overshoot the budget; its size and query overlap are bounded.
if scanned_bytes >= SCAN_BYTES {
return matches!(self.search.progress, Progress::Scanning(_));
}
}
matches!(self.search.progress, Progress::Scanning(_))
}
/// Scan only the inserted page; retained session headers may precede its splice location.
pub(crate) fn history_loaded(
&mut self,
cells: &[Arc<dyn HistoryCell>],
inserted: Range<usize>,
) {
// With only a header and live output, the first older page extends the canonical tail.
if !inserted.is_empty()
&& inserted.end == cells.len()
&& self.last_tail == cells[..inserted.start].last().map(EntryKey::cell)
{
self.last_tail = cells.last().map(EntryKey::cell);
}
self.prepend_snapshot_history(cells, inserted.clone());
if let Position::Reading(anchor) = self.position {
let index = self.resolve(cells, anchor);
self.position = Position::Reading(Anchor {
key: self.entry_key(cells, index),
index,
..anchor
});
}
if !matches!(self.search.progress, Progress::AwaitingHistory) || inserted.is_empty() {
return;
}
let index = inserted.end - 1;
self.search.progress = Progress::Scanning(Cursor {
anchor: Anchor {
key: self.entry_key(cells, index),
index,
offset: usize::MAX,
row_bias: 0,
},
direction: Direction::Older,
});
self.search.scanning_layout = None;
self.search.page_start = Some(self.entry_key(cells, inserted.start));
}
pub(super) fn render_search(&self, buf: &mut Buffer) {
// The copy range takes precedence until selection ends.
if self.selection.is_some() {
return;
}
let Some(found) = &self.search.current else {
return;
};
for (y, visible) in self.visible.iter().enumerate() {
if visible.key == found.anchor.key {
let area = Rect::new(
self.area.x,
self.area.y + y as u16,
self.area.width,
/*height*/ 1,
);
visible
.layout
.highlight(found.anchor.offset..found.end, area, buf, visible.row);
}
}
}
/// Navigate from a hit; confirmation must not reverse the initial scan of older history.
fn next_search_match(&mut self, cells: &[Arc<dyn HistoryCell>], direction: Direction) {
if self.search.editor.is_empty() {
return;
}
if matches!(self.search.progress, Progress::AwaitingHistory)
&& self.history == TranscriptHistoryState::Failed
&& direction == Direction::Older
{
self.history = TranscriptHistoryState::Partial;
} else if let Some(found) = &self.search.current {
self.search.scanning_layout = None;
let offset = match direction {
Direction::Older => found.anchor.offset,
Direction::Newer => found.end,
};
let match_start = found.anchor.offset;
let match_end = found.end;
let mut anchor = Anchor {
offset,
..found.anchor
};
if direction == Direction::Newer
&& let Some(snapshot) = self.held_reading.take()
{
// Continue through newly committed output, or rejoin a surviving cell.
let index = if anchor.key == EntryKey::Live {
snapshot
.cells
.iter()
.rev()
.find_map(|previous| {
cells.iter().rposition(|cell| Arc::ptr_eq(cell, previous))
})
.map_or(0, |index| index + 1)
} else if let Some(index) = cells
.iter()
.position(|cell| EntryKey::cell(cell) == anchor.key)
{
index
} else {
self.restart_search();
self.visible.clear();
return;
};
let prefix = snapshot
.pinned
.get(&anchor.key)
.and_then(|layout| layout.text().get(..offset));
let same_prefix = self.current_layout(cells, index).is_some_and(|layout| {
prefix.is_some_and(|prefix| layout.text().starts_with(prefix))
});
anchor = Anchor {
key: cells.get(index).map_or(EntryKey::Live, EntryKey::cell),
index,
offset: if same_prefix { offset } else { 0 },
row_bias: 0,
};
self.search.current = same_prefix.then_some(Match {
anchor: Anchor {
offset: match_start,
..anchor
},
end: match_end,
});
self.visible.clear();
self.position = Position::Reading(
self.search
.current
.as_ref()
.map_or(anchor, |found| found.anchor),
);
if same_prefix && let Some(layout) = self.current_layout(cells, index) {
self.hold_live_reading(cells, layout);
}
}
self.search.progress = Progress::Scanning(Cursor { anchor, direction });
} else if matches!(self.search.progress, Progress::Idle | Progress::Exhausted) {
self.start_search_scan(cells, Direction::Older);
}
}
fn start_search_scan(&mut self, cells: &[Arc<dyn HistoryCell>], direction: Direction) {
self.search.page_start = None;
let count = cells.len() + usize::from(self.live.is_some());
let index = match direction {
Direction::Older => count.saturating_sub(/*rhs*/ 1),
Direction::Newer => 0,
};
let offset = match direction {
Direction::Older => usize::MAX,
Direction::Newer => 0,
};
self.search.progress = Progress::Scanning(Cursor {
anchor: Anchor {
key: self.entry_key(cells, index),
index,
offset,
row_bias: 0,
},
direction,
});
}
fn search_layout(
&mut self,
cells: &[Arc<dyn HistoryCell>],
anchor: Anchor,
) -> Option<Arc<TextLayout>> {
if let Some((key, layout)) = &self.search.scanning_layout
&& *key == anchor.key
{
return Some(Arc::clone(layout));
}
let layout = self.layout(cells, anchor.index)?;
self.search.scanning_layout = Some((anchor.key, Arc::clone(&layout)));
Some(layout)
}
fn advance_search_cursor(
&mut self,
cells: &[Arc<dyn HistoryCell>],
cursor: Cursor,
window: Range<usize>,
text_len: usize,
) {
let remaining = match cursor.direction {
Direction::Older => window.start > 0,
Direction::Newer => window.end < text_len,
};
if remaining {
let offset = match cursor.direction {
Direction::Older => cursor.anchor.offset.saturating_sub(SCAN_BYTES),
Direction::Newer => cursor.anchor.offset.saturating_add(SCAN_BYTES),
};
self.search.progress = Progress::Scanning(Cursor {
anchor: Anchor {
offset,
..cursor.anchor
},
..cursor
});
return;
}
self.search.scanning_layout = None;
let next = match cursor.direction {
Direction::Older if self.search.page_start == Some(cursor.anchor.key) => None,
Direction::Older => cursor.anchor.index.checked_sub(/*rhs*/ 1),
Direction::Newer => (cursor.anchor.index + 1
< cells.len() + usize::from(self.live.is_some()))
.then_some(cursor.anchor.index + 1),
};
self.search.progress = match next {
Some(index) => Progress::Scanning(Cursor {
anchor: Anchor {
key: self.entry_key(cells, index),
index,
offset: if cursor.direction == Direction::Older {
usize::MAX
} else {
0
},
row_bias: 0,
},
direction: cursor.direction,
}),
None if cursor.direction == Direction::Older
&& matches!(
self.history,
TranscriptHistoryState::LoadingOlder
| TranscriptHistoryState::LoadingBeginning
| TranscriptHistoryState::Partial
| TranscriptHistoryState::Failed
) =>
{
Progress::AwaitingHistory
}
None => Progress::Exhausted,
};
}
}
impl Search {
pub(super) fn is_active(&self) -> bool {
self.active
}
pub(super) fn has_active_query(&self) -> bool {
self.active && !self.editor.is_empty()
}
pub(super) fn needs_history(&self, history: TranscriptHistoryState) -> bool {
matches!(self.progress, Progress::AwaitingHistory)
&& history == TranscriptHistoryState::Partial
}
pub(super) fn status_line(&self, width: u16, history: TranscriptHistoryState) -> Line<'static> {
let (status, compact) = match self.progress {
Progress::Idle => ("Type to find", "Type to find"),
Progress::Restart | Progress::Scanning(_) => ("Searching…", "Searching…"),
Progress::AwaitingHistory if history == TranscriptHistoryState::Failed => {
("History unavailable · ctrl+p retry", "ctrl+p retry")
}
Progress::AwaitingHistory => ("Searching earlier history…", "Loading…"),
Progress::Found => ("enter next · ctrl+p previous", "enter next"),
Progress::Exhausted if self.current.is_some() => (
"No more matches · enter next · ctrl+p previous",
"enter next",
),
Progress::Exhausted => ("No matches", "No matches"),
};
let limit = if self.query_truncated {
" · query limited to 4 KiB"
} else {
""
};
crate::footer_hint::first_fitting_line(
[
format!("{status} · full transcript · esc close{limit}"),
format!("{status} · esc close{limit}"),
format!("{compact} · esc close"),
format!("{compact} · esc"),
"esc close".to_owned(),
]
.map(|hint| super::footer::navigation_line(&hint)),
width,
)
}
fn query_changed(&mut self) {
self.folded_query = self
.editor
.text()
.chars()
.flat_map(char::to_lowercase)
.collect();
self.current = None;
self.scanning_layout = None;
self.page_start = None;
self.progress = Progress::Restart;
self.query_truncated = false;
}
}
fn scan_window(text: &str, cursor: Cursor, query_len: usize) -> Range<usize> {
// A source character can occupy four bytes while its lowercase form occupies only one.
let overlap = query_len.saturating_mul(/*rhs*/ 4);
let extent = SCAN_BYTES.saturating_add(overlap);
match cursor.direction {
Direction::Older => {
text.floor_char_boundary(cursor.anchor.offset.saturating_sub(extent))
..cursor.anchor.offset
}
Direction::Newer => {
cursor.anchor.offset
..text.floor_char_boundary(
cursor.anchor.offset.saturating_add(extent).min(text.len()),
)
}
}
}
fn find_literal(text: &str, query: &str, direction: Direction) -> Option<Range<usize>> {
let mut folded = String::new();
let mut spans = Vec::new();
for (start, character) in text.char_indices() {
for lowercase in character.to_lowercase() {
folded.push(lowercase);
spans.push((folded.len(), start..start + character.len_utf8()));
}
}
let start = match direction {
Direction::Older => folded.rfind(query),
Direction::Newer => folded.find(query),
}?;
let first = spans.partition_point(|(end, _)| *end <= start);
let last = spans.partition_point(|(end, _)| *end < start + query.len());
Some(spans.get(first)?.1.start..spans.get(last)?.1.end)
}
#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff
@@ -83,7 +83,7 @@ impl TranscriptView {
}
}
/// Rejoin current history, retaining a replaced group until navigation leaves its revision.
/// Rejoin current history, retaining Find offsets or a replaced group until navigation.
/// Real selections keep their reading position, including a retired live revision.
pub(crate) fn end_selection(&mut self, cells: &[Arc<dyn HistoryCell>]) {
let Some(selection) = self.selection.take() else {
@@ -100,7 +100,9 @@ impl TranscriptView {
self.hold_position();
}
if let Position::Reading(anchor) = self.position {
if !cells.iter().any(|cell| EntryKey::cell(cell) == anchor.key) {
if self.search.has_active_query()
|| !cells.iter().any(|cell| EntryKey::cell(cell) == anchor.key)
{
self.held_reading = Some(selection.snapshot);
return;
}
@@ -18,6 +18,83 @@ use crossterm::event::MouseEventKind;
use pretty_assertions::assert_eq;
use std::time::Instant;
#[test]
fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() {
use crate::clipboard_copy::CopyStatus;
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(PlainHistoryCell::new(vec![
"selected\tca\rfé\x1b\u{85}".into(),
]))];
for key in [
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER),
KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
KeyEvent::new(
KeyCode::Char('C'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL),
] {
for status in [CopyStatus::Confirmed, CopyStatus::Unconfirmed] {
for searching in [false, true] {
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 32, /*height*/ 3);
if searching {
view.begin_search();
}
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
let action = view.handle_key(key, &cells);
assert_eq!(
matches!(&action, Some(ViewAction::CopyAndFollow(_))),
key.code == KeyCode::Enter
);
let Some(ViewAction::Copy(text) | ViewAction::CopyAndFollow(text)) = action else {
panic!("selection key must request a copy");
};
assert_eq!(text, "selected\tcafé");
let failed = view.copy_selected_text_with(&cells, &text, |text| {
assert_eq!(text, "selected\tcafé");
Err("clipboard unavailable".to_string())
});
assert_eq!(failed, Err("clipboard unavailable".to_string()));
assert_eq!(
view.selected_text(&cells).as_deref(),
Some("selected\tcafé")
);
let Some(ViewAction::Copy(text) | ViewAction::CopyAndFollow(text)) =
view.handle_key(key, &cells)
else {
panic!("failed copy must remain retryable");
};
let copied = view.copy_selected_text_with(&cells, &text, |text| {
assert_eq!(text, "selected\tcafé");
Ok(status)
});
assert_eq!(copied, Ok(status));
assert_eq!(
(view.selected_text(&cells), view.is_search_active()),
(
(status == CopyStatus::Unconfirmed).then(|| text.clone()),
searching
)
);
assert_eq!(view.selection.is_none(), status == CopyStatus::Confirmed);
if !searching && status == CopyStatus::Confirmed {
assert!(
view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells)
.is_none()
);
}
}
}
}
}
#[test]
fn command_c_ignores_release_and_copies_only_an_active_selection() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(PlainHistoryCell::new(vec![
@@ -500,74 +577,3 @@ fn loading_older_history_extends_the_snapshot_without_adopting_new_commits() {
render(&mut view, &cells, /*width*/ 32, /*height*/ 6);
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
}
#[test]
fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() {
use crate::clipboard_copy::CopyStatus;
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(PlainHistoryCell::new(vec![
"selected\tca\rfé\x1b\u{85}".into(),
]))];
for key in [
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER),
KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
KeyEvent::new(
KeyCode::Char('C'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL),
] {
for status in [CopyStatus::Confirmed, CopyStatus::Unconfirmed] {
{
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 32, /*height*/ 3);
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
let action = view.handle_key(key, &cells);
assert_eq!(
matches!(&action, Some(ViewAction::CopyAndFollow(_))),
key.code == KeyCode::Enter
);
let Some(ViewAction::Copy(text) | ViewAction::CopyAndFollow(text)) = action else {
panic!("selection key must request a copy");
};
assert_eq!(text, "selected\tcafé");
let failed = view.copy_selected_text_with(&cells, &text, |text| {
assert_eq!(text, "selected\tcafé");
Err("clipboard unavailable".to_string())
});
assert_eq!(failed, Err("clipboard unavailable".to_string()));
assert_eq!(
view.selected_text(&cells).as_deref(),
Some("selected\tcafé")
);
let Some(ViewAction::Copy(text) | ViewAction::CopyAndFollow(text)) =
view.handle_key(key, &cells)
else {
panic!("failed copy must remain retryable");
};
let copied = view.copy_selected_text_with(&cells, &text, |text| {
assert_eq!(text, "selected\tcafé");
Ok(status)
});
assert_eq!(copied, Ok(status));
assert_eq!(
view.selected_text(&cells),
(status == CopyStatus::Unconfirmed).then(|| text.clone())
);
assert_eq!(view.selection.is_none(), status == CopyStatus::Confirmed);
if status == CopyStatus::Confirmed {
assert!(
view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells)
.is_none()
);
}
}
}
}
}
+36 -6
View File
@@ -8,6 +8,7 @@ use super::*;
pub(super) struct ViewSnapshot {
pub(super) cells: Arc<[Arc<dyn HistoryCell>]>,
pub(super) pinned: HashMap<EntryKey, Arc<TextLayout>>,
pub(super) activities: HashMap<EntryKey, Arc<[String]>>,
}
impl TranscriptView {
@@ -36,18 +37,35 @@ impl TranscriptView {
.snapshot()
.map(|snapshot| snapshot.pinned.clone())
.unwrap_or_default();
let mut activities = self
.snapshot()
.map(|snapshot| snapshot.activities.clone())
.unwrap_or_default();
pinned.extend(
self.visible
.iter()
.map(|visible| (visible.key, Arc::clone(&visible.layout))),
);
activities.extend(
self.visible
.iter()
.map(|visible| (visible.key, Arc::clone(&visible.activity_ids))),
);
if let Some(live) = self.layout(&cells, cells.len()) {
pinned.insert(EntryKey::Live, live);
activities.insert(
EntryKey::Live,
self.displayed_activity_ids(&cells, cells.len()),
);
}
ViewSnapshot {
cells,
pinned,
activities,
}
ViewSnapshot { cells, pinned }
}
/// Keep a live or replaced group's revision while reading inside it. Ordinary navigation
/// Keep a live, mutable, or replaced cell revision while reading inside it. Navigation
/// rejoins current history when it reaches a surviving cell.
pub(super) fn hold_live_reading(
&mut self,
@@ -61,10 +79,20 @@ impl TranscriptView {
self.release_live_reading();
return;
};
if anchor.key == EntryKey::Live {
if self.held_reading.is_none() {
let mutable_cell = cells.get(anchor.index).is_some_and(|cell| {
EntryKey::cell(cell) == anchor.key
&& (!cell.has_stable_transcript_height()
|| cell.transcript_animation_tick().is_some())
});
if anchor.key == EntryKey::Live || mutable_cell {
if self
.held_reading
.as_ref()
.is_none_or(|snapshot| !snapshot.pinned.contains_key(&anchor.key))
{
self.held_reading = None;
let mut snapshot = self.capture_snapshot(cells);
snapshot.pinned.insert(EntryKey::Live, layout);
snapshot.pinned.insert(anchor.key, layout);
self.held_reading = Some(snapshot);
}
} else if self.held_reading.is_some()
@@ -79,7 +107,9 @@ impl TranscriptView {
/// Search offsets belong to the held revision and must be refreshed when it is released.
pub(super) fn release_live_reading(&mut self) {
self.held_reading = None;
if self.held_reading.take().is_some() {
self.invalidate_held_search();
}
}
pub(super) fn rewrap_snapshot(&mut self, width: u16) {
@@ -1,6 +1,6 @@
---
source: tui/src/transcript_view/follow_control_tests.rs
assertion_line: 173
assertion_line: 212
expression: "snapshots.join(\"\\n\\n\")"
---
paused
@@ -0,0 +1,56 @@
---
source: tui/src/transcript_view/footer_tests.rs
expression: "hints.join(\"\\n\")"
---
collapsed
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 3 },
content: [
"Activity summary ",
" + Show details ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
20 columns: f4 inspect activity
32 columns: f4 inspect activity
80 columns: f4 inspect activity
focused
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 3 },
content: [
"Activity summary ",
" + Show details ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 18, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
expanded
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 3 },
content: [
"Activity summary ",
"Hidden details ",
" − Show less ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 15, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
"ctrl-x t": ctrl+x t inspect activity
["ctrl-x t","f4"]: ctrl+x t/f4 inspect activity
[]: unbound
@@ -0,0 +1,11 @@
---
source: tui/src/transcript_view/footer_tests.rs
assertion_line: 166
expression: "hints.join(\"\\n\")"
---
32 columns, LoadingOlder: Loading… · esc close
32 columns, LoadingBeginning: Loading… · esc close
32 columns, Failed: ctrl+p retry · esc close
12 columns, LoadingOlder: esc close
12 columns, LoadingBeginning: esc close
12 columns, Failed: esc close
@@ -0,0 +1,12 @@
---
source: tui/src/transcript_view/footer_tests.rs
expression: "snapshots.join(\"\\n\")"
---
search=false, 12 columns: esc clear
search=false, 20 columns: enter copy+↓ · esc
search=false, 32 columns: enter copy & follow · esc clear
search=false, 80 columns: ctrl+c copy · enter copy & follow · esc clear
search=true, 12 columns: esc close
search=true, 20 columns: enter next · esc
search=true, 32 columns: enter next · esc close
search=true, 80 columns: enter next · ctrl+p previous · full transcript · esc close
@@ -0,0 +1,31 @@
---
source: tui/src/transcript_view/footer_tests.rs
expression: snapshot
---
searching=false · mouse down
<composer hints>
searching=false · one character
ctrl+c copy · enter copy & follow · esc clear
searching=false · back to anchor
<composer hints>
searching=false · released empty
<composer hints>
searching=true · mouse down
Find:
Searching… · full transcript · esc close
searching=true · one character
Find:
ctrl+c copy · enter copy & follow · esc clear
searching=true · back to anchor
Find:
Searching… · full transcript · esc close
searching=true · released empty
Find:
Searching… · full transcript · esc close
@@ -0,0 +1,22 @@
---
source: tui/src/transcript_view/footer_tests.rs
expression: 'format!("{buffer:?}")'
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 2 },
content: [
"Find: Activity ",
"enter next · ctrl+p previous · full transcript · esc close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 19, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 52, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 58, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -0,0 +1,21 @@
---
source: tui/src/transcript_view/search_tests.rs
assertion_line: 203
expression: "format!(\"{buffer:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 8 },
content: [
"joined computer actions ",
" ",
"latest answer ",
" ",
"new committed answer ",
" ",
"stream after page ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -0,0 +1,18 @@
---
source: tui/src/transcript_view/search_tests.rs
assertion_line: 63
expression: "format!(\"{selected_frame:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 4 },
content: [
"newer needle ",
" ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 12, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
+93 -4
View File
@@ -19,10 +19,12 @@ use ratatui::layout::Alignment;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::widgets::Widget;
use unicode_segmentation::UnicodeSegmentation;
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_to_width;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::HyperlinkParagraph;
use crate::terminal_hyperlinks::LineWrapPolicy;
@@ -44,6 +46,14 @@ pub(super) struct TextLayout {
rows: Vec<TextRow>,
width: u16,
pub(super) separated: bool,
pub(super) disclosure: bool,
disclosure_control: Option<DisclosureControl>,
}
struct DisclosureControl {
label: String,
source_offset: usize,
row: usize,
}
struct TextRow {
@@ -63,8 +73,11 @@ impl TextLayout {
/// Reflow the same displayed content revision, retaining styles, links and disclosure label.
pub(super) fn rewrap(&self, width: u16) -> Self {
let layout = Self::from_logical(self.logical.clone(), width);
let mut layout = Self::from_logical(self.logical.clone(), width);
if let Some(control) = &self.disclosure_control {
layout =
layout.with_disclosure_control_at(control.label.clone(), control.source_offset);
}
if self.separated {
layout.with_leading_separator()
} else {
@@ -72,6 +85,57 @@ impl TextLayout {
}
}
/// Append a clipped interaction row outside the copied and searched source text.
pub(super) fn with_disclosure_control(self, label: String) -> Self {
let source_offset = self.text.len();
self.with_disclosure_control_at(label, source_offset)
}
/// Place a control after an activity, before any following auxiliary source text.
pub(super) fn with_disclosure_control_at(
mut self,
label: String,
source_offset: usize,
) -> Self {
if self.text.is_empty() || label.is_empty() {
return self;
}
if let Some(control) = self.disclosure_control.take() {
self.rows.remove(control.row);
}
let source_offset = self
.text
.floor_char_boundary(source_offset.min(self.text.len()));
let control_row = self
.rows
.iter()
.rposition(|row| row.source.end <= source_offset)
.map_or(/*default*/ 0, |row| row + 1);
let indent = usize::from(self.width / 4).min(/*other*/ 4);
let line = truncate_line_to_width(
Line::from(format!("{}{label}", " ".repeat(indent))).dim(),
usize::from(self.width),
);
self.rows.insert(
control_row,
TextRow {
line: HyperlinkLine::from(line),
source: source_offset..source_offset,
content_width: self.width,
first_column: indent,
prefix_columns: indent,
tabs: tabs::TabStops::default(),
},
);
self.disclosure = true;
self.disclosure_control = Some(DisclosureControl {
label,
source_offset,
row: control_row,
});
self
}
/// Add visual spacing between entries without changing any source position.
pub(super) fn with_leading_separator(mut self) -> Self {
if !self.separated && !self.rows.is_empty() {
@@ -86,7 +150,9 @@ impl TextLayout {
tabs: tabs::TabStops::default(),
},
);
if let Some(control) = &mut self.disclosure_control {
control.row += 1;
}
self.separated = true;
}
self
@@ -116,6 +182,17 @@ impl TextLayout {
self.rows.len()
}
pub(super) fn disclosure_row(&self) -> Option<usize> {
self.disclosure_control.as_ref().map(|control| control.row)
}
pub(super) fn disclosure_columns(&self) -> Range<u16> {
let Some(row) = self.disclosure_row().and_then(|row| self.rows.get(row)) else {
return 0..0;
};
row.first_column as u16..line_width(&row.line.line).min(usize::from(self.width)) as u16
}
/// Paint visible rows, including inherited styles on empty rows and trailing columns.
pub(super) fn render(&self, area: Rect, buf: &mut Buffer, start_row: usize) {
for (screen_row, row) in self.visible_rows(area, start_row) {
@@ -147,7 +224,17 @@ impl TextLayout {
/// Find the display row containing a source position, including omitted wrapping whitespace.
pub(super) fn row_for_offset(&self, offset: usize) -> usize {
self.rows
let before_control = if let Some(control_row) = self.disclosure_row() {
let after_control = &self.rows[control_row + 1..];
let matching_rows = after_control.partition_point(|row| row.source.start <= offset);
if matching_rows > 0 {
return control_row + matching_rows;
}
control_row
} else {
self.rows.len()
};
self.rows[..before_control]
.partition_point(|row| row.source.start <= offset)
.saturating_sub(/*rhs*/ 1)
}
@@ -261,6 +348,8 @@ impl TextLayout {
rows,
width,
separated: false,
disclosure: false,
disclosure_control: None,
}
}
}