mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)?))?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(¤t.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(¤t.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(¤t))
|
||||
.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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
|
||||
+56
@@ -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
|
||||
+11
@@ -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
|
||||
+12
@@ -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
|
||||
+31
@@ -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
|
||||
+22
@@ -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,
|
||||
]
|
||||
}
|
||||
+21
@@ -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,
|
||||
]
|
||||
}
|
||||
+18
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user