mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +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:
@@ -96,6 +96,10 @@ pub struct TuiGlobalKeymap {
|
||||
pub open_agents: Option<KeybindingsSpec>,
|
||||
/// Open the transcript overlay.
|
||||
pub open_transcript: Option<KeybindingsSpec>,
|
||||
/// Find text in the full transcript.
|
||||
pub find_transcript: Option<KeybindingsSpec>,
|
||||
/// Focus activity groups in the owned transcript to inspect their details.
|
||||
pub focus_activity: Option<KeybindingsSpec>,
|
||||
/// Open the external editor for the current draft.
|
||||
pub open_external_editor: Option<KeybindingsSpec>,
|
||||
/// Copy the last agent response to the clipboard.
|
||||
@@ -401,6 +405,8 @@ pub struct TuiPagerKeymap {
|
||||
pub close: Option<KeybindingsSpec>,
|
||||
/// Close the transcript overlay via its dedicated toggle key.
|
||||
pub close_transcript: Option<KeybindingsSpec>,
|
||||
/// Find text in a transcript pager.
|
||||
pub find: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// List selection context keybindings for popup-style selectable lists.
|
||||
@@ -769,6 +775,48 @@ mod tests {
|
||||
assert!(keymap.global.open_transcript.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_find_accepts_custom_chords_and_can_be_disabled() {
|
||||
for (bindings, expected) in [
|
||||
(
|
||||
"'ctrl-x f'",
|
||||
KeybindingsSpec::One(KeybindingSpec("ctrl-x f".to_owned())),
|
||||
),
|
||||
("[]", KeybindingsSpec::Many(Vec::new())),
|
||||
] {
|
||||
for (context, action) in [("pager", "find"), ("global", "find_transcript")] {
|
||||
let keymap: TuiKeymap =
|
||||
toml::from_str(&format!("[{context}]\n{action} = {bindings}\n"))
|
||||
.expect("Find binding");
|
||||
let mut expected_keymap = TuiKeymap::default();
|
||||
if context == "pager" {
|
||||
expected_keymap.pager.find = Some(expected.clone());
|
||||
} else {
|
||||
expected_keymap.global.find_transcript = Some(expected.clone());
|
||||
}
|
||||
assert_eq!(keymap, expected_keymap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_focus_accepts_custom_chords_and_can_be_disabled() {
|
||||
for (bindings, expected) in [
|
||||
(
|
||||
"'ctrl-x t'",
|
||||
KeybindingsSpec::One(KeybindingSpec("ctrl-x t".to_owned())),
|
||||
),
|
||||
("[]", KeybindingsSpec::Many(Vec::new())),
|
||||
] {
|
||||
let keymap: TuiKeymap =
|
||||
toml::from_str(&format!("[global]\nfocus_activity = {bindings}\n"))
|
||||
.expect("activity focus binding");
|
||||
let mut expected_keymap = TuiKeymap::default();
|
||||
expected_keymap.global.focus_activity = Some(expected);
|
||||
assert_eq!(keymap, expected_keymap);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minus_bindings_under_global_context_are_accepted() {
|
||||
for (spec, expected) in [
|
||||
|
||||
@@ -4280,6 +4280,8 @@
|
||||
"global": {
|
||||
"clear_terminal": null,
|
||||
"copy": null,
|
||||
"find_transcript": null,
|
||||
"focus_activity": null,
|
||||
"open_agents": null,
|
||||
"open_external_editor": null,
|
||||
"open_transcript": null,
|
||||
@@ -4306,6 +4308,7 @@
|
||||
"pager": {
|
||||
"close": null,
|
||||
"close_transcript": null,
|
||||
"find": null,
|
||||
"half_page_down": null,
|
||||
"half_page_up": null,
|
||||
"jump_bottom": null,
|
||||
@@ -4988,6 +4991,22 @@
|
||||
],
|
||||
"description": "Copy the last agent response to the clipboard."
|
||||
},
|
||||
"find_transcript": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Find text in the full transcript."
|
||||
},
|
||||
"focus_activity": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Focus activity groups in the owned transcript to inspect their details."
|
||||
},
|
||||
"open_agents": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -5179,6 +5198,8 @@
|
||||
"default": {
|
||||
"clear_terminal": null,
|
||||
"copy": null,
|
||||
"find_transcript": null,
|
||||
"focus_activity": null,
|
||||
"open_agents": null,
|
||||
"open_external_editor": null,
|
||||
"open_transcript": null,
|
||||
@@ -5219,6 +5240,7 @@
|
||||
"default": {
|
||||
"close": null,
|
||||
"close_transcript": null,
|
||||
"find": null,
|
||||
"half_page_down": null,
|
||||
"half_page_up": null,
|
||||
"jump_bottom": null,
|
||||
@@ -5444,6 +5466,14 @@
|
||||
],
|
||||
"description": "Close the transcript overlay via its dedicated toggle key."
|
||||
},
|
||||
"find": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Find text in a transcript pager."
|
||||
},
|
||||
"half_page_down": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -198,6 +198,7 @@ impl App {
|
||||
self.transcript_cells.remove(index);
|
||||
}
|
||||
self.native_history.retain(&self.transcript_cells);
|
||||
self.transcript_view.restart_search();
|
||||
self.transcript_view
|
||||
.history_loaded(&self.transcript_cells, 0..0);
|
||||
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
|
||||
|
||||
@@ -16,6 +16,7 @@ impl App {
|
||||
cfg!(unix)
|
||||
&& !self.enhanced_keys_supported
|
||||
&& self.overlay.is_none()
|
||||
&& !self.transcript_view.is_search_active()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& matches!(key_event.code, KeyCode::Char(_))
|
||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
@@ -41,9 +42,7 @@ impl App {
|
||||
self.key_chord_matcher.clone().advance(
|
||||
key_event,
|
||||
&self.keymap.chords,
|
||||
active_contexts,
|
||||
tokio::time::Instant::now(),
|
||||
),
|
||||
active_contexts),
|
||||
crate::keymap::KeyChordMatch::Pending(_)
|
||||
| crate::keymap::KeyChordMatch::Completed(_)
|
||||
)
|
||||
@@ -54,18 +53,18 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
key_event: KeyEvent,
|
||||
) -> Option<KeyEvent> {
|
||||
if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event))
|
||||
{
|
||||
self.cancel_pending_key_chord();
|
||||
return Some(key_event);
|
||||
self.transcript_view.set_keymap_bindings(&self.keymap);
|
||||
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.set_keymap_bindings(&self.keymap);
|
||||
}
|
||||
if tui.is_owned_screen()
|
||||
&& self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& self.transcript_view.owns_interaction_key(key_event)
|
||||
&& (self.transcript_view.has_active_interaction()
|
||||
|| self.backtrack.overlay_preview_active
|
||||
|| crate::transcript_view::JumpTarget::from_key(key_event).is_none())
|
||||
if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event))
|
||||
|| (tui.is_owned_screen()
|
||||
&& self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& self.transcript_view.owns_interaction_key(key_event)
|
||||
&& (self.transcript_view.has_active_interaction()
|
||||
|| self.backtrack.overlay_preview_active
|
||||
|| crate::transcript_view::JumpTarget::from_key(key_event).is_none()))
|
||||
{
|
||||
let close_chord = tui.is_owned_screen()
|
||||
&& self.overlay.is_none()
|
||||
@@ -76,7 +75,6 @@ impl App {
|
||||
key_event,
|
||||
&self.keymap.chords,
|
||||
self.active_keymap_contexts(),
|
||||
tokio::time::Instant::now(),
|
||||
) {
|
||||
crate::keymap::KeyChordMatch::Completed(event) => {
|
||||
self.keymap.pager.close_transcript.is_pressed(event)
|
||||
@@ -111,29 +109,24 @@ impl App {
|
||||
{
|
||||
return Some(key_event);
|
||||
}
|
||||
match self.key_chord_matcher.advance(
|
||||
key_event,
|
||||
&self.keymap.chords,
|
||||
contexts,
|
||||
tokio::time::Instant::now(),
|
||||
) {
|
||||
match self
|
||||
.key_chord_matcher
|
||||
.advance(key_event, &self.keymap.chords, contexts)
|
||||
{
|
||||
crate::keymap::KeyChordMatch::PassThrough => {
|
||||
if was_pending && !self.key_chord_matcher.is_pending() {
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
}
|
||||
Some(key_event)
|
||||
}
|
||||
crate::keymap::KeyChordMatch::Pending(prefix) => {
|
||||
if self.backtrack.primed {
|
||||
crate::keymap::KeyChordMatch::Pending(_) => {
|
||||
if self.backtrack.primed && !self.backtrack.overlay_preview_active {
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
self.set_key_chord_hint_override(Some(vec![
|
||||
(
|
||||
format!("{} …", prefix.display_label()),
|
||||
"waiting for next key".to_string(),
|
||||
),
|
||||
("esc".to_string(), "cancel".to_string()),
|
||||
]));
|
||||
self.set_key_chord_hint_override(self.key_chord_matcher.pending_hint_items(
|
||||
&self.keymap.chords,
|
||||
tui.terminal.last_known_screen_size.width,
|
||||
));
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::keymap::KEY_CHORD_TIMEOUT);
|
||||
None
|
||||
@@ -152,10 +145,7 @@ impl App {
|
||||
|
||||
pub(super) fn expire_pending_key_chord(&mut self) {
|
||||
let contexts = self.active_keymap_contexts();
|
||||
if self
|
||||
.key_chord_matcher
|
||||
.expire(contexts, tokio::time::Instant::now())
|
||||
{
|
||||
if self.key_chord_matcher.expire(contexts) {
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +157,9 @@ impl App {
|
||||
}
|
||||
|
||||
fn set_key_chord_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||||
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.key_chord_hint = items.clone();
|
||||
}
|
||||
self.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
@@ -179,8 +172,21 @@ impl App {
|
||||
use crate::keymap::KeymapContext;
|
||||
use crate::keymap::KeymapContextSet;
|
||||
|
||||
if self.overlay.is_some() {
|
||||
return KeymapContextSet::new(KeymapContext::Pager);
|
||||
if let Some(overlay) = &self.overlay {
|
||||
let context = if matches!(overlay, Overlay::Transcript(view) if view.is_search_active())
|
||||
{
|
||||
KeymapContext::Editor
|
||||
} else {
|
||||
KeymapContext::Pager
|
||||
};
|
||||
return KeymapContextSet::new(context);
|
||||
}
|
||||
if self.transcript_view.is_search_active() && self.chat_widget.no_modal_or_popup_active() {
|
||||
return KeymapContextSet::new(KeymapContext::Editor);
|
||||
}
|
||||
if self.transcript_view.is_activity_focused() && self.chat_widget.no_modal_or_popup_active()
|
||||
{
|
||||
return KeymapContextSet::activity();
|
||||
}
|
||||
let voice_available = self.chat_widget.realtime_microphone_shortcut_available();
|
||||
let contexts = self.chat_widget.keymap_contexts();
|
||||
@@ -496,12 +502,19 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) {
|
||||
let find_transcript = self.keymap.app.find_transcript.is_pressed(key_event);
|
||||
if app_keymap_shortcuts_available
|
||||
&& (self.keymap.app.open_transcript.is_pressed(key_event) || find_transcript)
|
||||
{
|
||||
self.scrollback_has_older_history = self
|
||||
.chat_widget
|
||||
.thread_id()
|
||||
.is_some_and(|thread_id| app_server.has_older_history(thread_id));
|
||||
self.open_transcript_overlay(tui);
|
||||
if find_transcript && let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.set_keymap_bindings(&self.keymap);
|
||||
overlay.begin_search();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -527,7 +540,9 @@ impl App {
|
||||
// handles it.
|
||||
if !tui.is_owned_screen() && self.should_handle_backtrack_esc(key_event) {
|
||||
self.chat_widget.prepare_composer_sparkle_key(key_event);
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
if key_event.kind == KeyEventKind::Press {
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
}
|
||||
} else if self.should_reject_side_backtrack_esc(key_event) {
|
||||
self.reject_side_backtrack_esc();
|
||||
} else {
|
||||
@@ -559,7 +574,11 @@ impl App {
|
||||
// This avoids stale "Esc-primed" state after the user starts typing
|
||||
// (even if they later backspace to empty).
|
||||
if key_event.code != KeyCode::Esc && self.backtrack.primed {
|
||||
self.reset_backtrack_state();
|
||||
if self.backtrack.overlay_preview_active {
|
||||
self.close_transcript_overlay(tui);
|
||||
} else {
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
}
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
@@ -654,6 +673,7 @@ impl App {
|
||||
|
||||
pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool {
|
||||
!self.chat_widget.side_conversation_active()
|
||||
&& !self.chat_widget.shortcut_overlay_visible()
|
||||
&& self.chat_widget.is_normal_backtrack_mode()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
&& !self.chat_widget.should_handle_vim_insert_escape(key_event)
|
||||
@@ -661,6 +681,7 @@ impl App {
|
||||
|
||||
pub(super) fn should_reject_side_backtrack_esc(&self, key_event: KeyEvent) -> bool {
|
||||
self.chat_widget.side_conversation_active()
|
||||
&& !self.chat_widget.shortcut_overlay_visible()
|
||||
&& self.chat_widget.is_normal_backtrack_mode()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
&& !self.chat_widget.should_handle_vim_insert_escape(key_event)
|
||||
@@ -681,6 +702,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "input_ownership_tests.rs"]
|
||||
mod ownership_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::make_test_app;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Find and transcript selection retain control keys ahead of configurable chord prefixes.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn selection_copy_cancels_a_pending_find_editor_chord() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({
|
||||
"editor": {"move_line_start": ["ctrl-p h", "ctrl-x h"], "move_up": []}
|
||||
}))?)
|
||||
.expect("valid editor keymap");
|
||||
app.transcript_cells = vec![Arc::new(history_cell::PlainHistoryCell::new(vec![
|
||||
"needle text".into(),
|
||||
]))];
|
||||
app.transcript_view.begin_search();
|
||||
app.transcript_view.paste_search("needle");
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
|
||||
KeyCode::Enter.into(),
|
||||
KeyCode::Esc.into(),
|
||||
] {
|
||||
assert_eq!(app.route_key_chord_event(&mut tui, key), Some(key));
|
||||
assert!(!app.key_chord_matcher.is_pending());
|
||||
}
|
||||
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
|
||||
assert_eq!(
|
||||
app.route_key_chord_event(
|
||||
&mut tui,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)
|
||||
),
|
||||
None
|
||||
);
|
||||
assert!(app.key_chord_matcher.is_pending());
|
||||
app.transcript_view.handle_key(
|
||||
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
|
||||
&app.transcript_cells,
|
||||
);
|
||||
for _ in "needle text".chars() {
|
||||
app.transcript_view
|
||||
.handle_key(KeyCode::Right.into(), &app.transcript_cells);
|
||||
}
|
||||
let copy = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||
assert_eq!(app.route_key_chord_event(&mut tui, copy), Some(copy));
|
||||
assert!(!app.key_chord_matcher.is_pending());
|
||||
assert_eq!(
|
||||
app.transcript_view
|
||||
.selected_text(&app.transcript_cells)
|
||||
.as_deref(),
|
||||
Some("needle text")
|
||||
);
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -19,20 +19,22 @@ impl App {
|
||||
let chat_widget = &self.chat_widget;
|
||||
let transcript_width = chat_widget.history_wrap_width(width);
|
||||
let view = &mut self.transcript_view;
|
||||
view.set_keymap_bindings(&self.keymap);
|
||||
view.set_presentation(view.is_detailed(), chat_widget.history_render_mode());
|
||||
let active_key = chat_widget.active_cell_transcript_key();
|
||||
let detailed = view.is_detailed();
|
||||
view.sync_live_tail(transcript_width, active_key, |width| {
|
||||
chat_widget.active_cell_hyperlink_lines_with(width, |cell, width| {
|
||||
if detailed {
|
||||
cell.transcript_hyperlink_lines(width)
|
||||
} else if chat_widget.history_render_mode() == HistoryRenderMode::Raw {
|
||||
cell.display_hyperlink_lines_for_mode(width, HistoryRenderMode::Raw)
|
||||
} else {
|
||||
cell.compact_hyperlink_lines(width)
|
||||
}
|
||||
let active_ids = chat_widget.active_activity_ids();
|
||||
let expanded = view.sync_live_activity(&self.transcript_cells, active_ids)
|
||||
&& chat_widget.history_render_mode() == HistoryRenderMode::Rich;
|
||||
if detailed {
|
||||
view.sync_live_tail(transcript_width, active_key, |width| {
|
||||
chat_widget.active_cell_transcript_hyperlink_lines(width)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
view.sync_live_activity_tail(transcript_width, active_key, expanded, |width| {
|
||||
chat_widget.active_cell_owned_transcript_lines(width, expanded)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_owned_transcript(
|
||||
@@ -52,6 +54,10 @@ impl App {
|
||||
let view = &mut self.transcript_view;
|
||||
let active_key = chat_widget.active_cell_transcript_key();
|
||||
view.sync_history_tail(&self.transcript_cells);
|
||||
view.prepare_width(transcript_width);
|
||||
if view.advance_search(&self.transcript_cells) {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
let footer_area = crate::bottom_pane::inset_footer_hint_area(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
@@ -107,8 +113,16 @@ impl App {
|
||||
feedback_tick = view.render_composer_gap(follow_area, /*hint*/ None, frame.buffer);
|
||||
// Rendering resolves whether new activity is still hidden. Paint that result in
|
||||
// this frame so a revision change cannot flash a stale activity hint.
|
||||
let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation);
|
||||
|
||||
let mut footer =
|
||||
view.footer_with_navigation(footer_area.width, motion, latest_navigation);
|
||||
if let Some(footer) = footer.as_mut().filter(|footer| footer.is_interactive)
|
||||
&& let Some(items) = self
|
||||
.key_chord_matcher
|
||||
.pending_hint_items(&self.keymap.chords, footer_area.width)
|
||||
&& let Some(progress) = footer.text.lines.last_mut()
|
||||
{
|
||||
*progress = crate::bottom_pane::footer_hint_items_line(&items);
|
||||
}
|
||||
let bottom = chat_widget.bottom_pane_renderable(footer.as_ref());
|
||||
footer_height_changed = !dashboard_visible
|
||||
&& bottom
|
||||
@@ -212,6 +226,7 @@ impl App {
|
||||
&& !self.transcript_view.has_active_interaction()
|
||||
&& keymap_action_ids().any(|action| {
|
||||
action.context != KeymapContext::Pager
|
||||
&& !matches!(action.action, "find_transcript" | "focus_activity")
|
||||
&& self.active_keymap_contexts().contains_action(action)
|
||||
&& bindings_for_action(
|
||||
&self.keymap,
|
||||
@@ -224,15 +239,27 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
let action = match event {
|
||||
TuiEvent::Key(key)
|
||||
if !self.transcript_view.is_search_active()
|
||||
&& self.keymap.app.find_transcript.is_pressed(*key) =>
|
||||
{
|
||||
self.transcript_view.begin_search();
|
||||
Some(ViewAction::Changed)
|
||||
}
|
||||
TuiEvent::Key(key) => self
|
||||
.transcript_view
|
||||
.handle_key(*key, &self.transcript_cells),
|
||||
TuiEvent::Mouse(mouse) => self
|
||||
.transcript_view
|
||||
.handle_mouse(*mouse, &self.transcript_cells),
|
||||
TuiEvent::Paste(_) => {
|
||||
TuiEvent::Paste(text) => {
|
||||
self.transcript_view.end_selection(&self.transcript_cells);
|
||||
None
|
||||
if self.transcript_view.paste_search(text) {
|
||||
Some(ViewAction::Changed)
|
||||
} else {
|
||||
self.transcript_view.clear_activity_focus();
|
||||
None
|
||||
}
|
||||
}
|
||||
TuiEvent::Draw
|
||||
| TuiEvent::Resume
|
||||
@@ -323,10 +350,10 @@ impl App {
|
||||
#[path = "owned_transcript_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "owned_transcript_follow_tests.rs"]
|
||||
mod follow_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "owned_transcript_input_tests.rs"]
|
||||
mod input_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "owned_transcript_follow_tests.rs"]
|
||||
mod follow_tests;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use super::*;
|
||||
use crate::chatwidget::tests::helpers::normalize_snapshot_paths;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::session_state::ThreadSessionState;
|
||||
use crate::test_support::test_path_buf;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
@@ -10,6 +12,16 @@ use codex_protocol::models::PermissionProfile;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
|
||||
fn user_cell(message: &str) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
spoken: false,
|
||||
message: message.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn attach_thread(app: &mut App, thread_id: ThreadId) {
|
||||
app.chat_widget.handle_thread_session(ThreadSessionState {
|
||||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||||
@@ -307,3 +319,241 @@ async fn owned_transcript_keeps_text_out_of_the_pet_columns() -> Result<()> {
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_owns_editor_chords_without_changing_the_composer_draft() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.transcript_cells = vec![user_cell("alpha beta")];
|
||||
app.chat_widget
|
||||
.apply_external_edit("draft remains intact".to_string());
|
||||
app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({
|
||||
"global": {"find_transcript": "f4", "open_external_editor": "ctrl-g e"},
|
||||
"editor": {"move_line_start": "ctrl-x h"},
|
||||
"pager": {"find": "f4"}
|
||||
}))?)
|
||||
.expect("independent query bindings");
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
for owned in [true, false] {
|
||||
tui.set_owned_screen(owned)?;
|
||||
if !owned {
|
||||
app.open_transcript_overlay(&mut tui);
|
||||
}
|
||||
for event in [
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE)),
|
||||
TuiEvent::Draw,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
|
||||
TuiEvent::Paste("alpha beta".to_string()),
|
||||
// A global chord cannot own the next query character.
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
|
||||
// Configured editor chords use the same query TextArea.
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
|
||||
] {
|
||||
let pasted = matches!(event, TuiEvent::Paste(_));
|
||||
app.handle_tui_event(&mut tui, &mut app_server, event)
|
||||
.await?;
|
||||
if pasted {
|
||||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
|
||||
.await?;
|
||||
let rendered = buffer_text(
|
||||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("enter next"),
|
||||
"Find must resume after pasting over selection"
|
||||
);
|
||||
}
|
||||
if app.key_chord_matcher.is_pending() {
|
||||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
|
||||
.await?;
|
||||
let rendered = buffer_text(
|
||||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
|
||||
);
|
||||
let cursor = tui.terminal.last_known_cursor_pos;
|
||||
let query_row = rendered
|
||||
.lines()
|
||||
.nth(usize::from(cursor.y))
|
||||
.expect("visible query caret");
|
||||
assert_eq!(query_row.trim(), "Find: alpha betax");
|
||||
assert!(rendered.contains("h move line start"));
|
||||
insta::assert_snapshot!(
|
||||
"owned_find_pending_chord_footer",
|
||||
rendered
|
||||
.lines()
|
||||
.skip(usize::from(cursor.y))
|
||||
.take(/*n*/ 2)
|
||||
.map(str::trim)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
|
||||
.await?;
|
||||
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
||||
&tui.terminal,
|
||||
));
|
||||
assert!(
|
||||
rendered
|
||||
.lines()
|
||||
.any(|line| line.trim() == "Find: apha betax")
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"draft remains intact"
|
||||
);
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
|
||||
)
|
||||
.await?;
|
||||
assert!(!match &app.overlay {
|
||||
Some(Overlay::Transcript(overlay)) => overlay.is_search_active(),
|
||||
_ => app.transcript_view.is_search_active(),
|
||||
});
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"draft remains intact"
|
||||
);
|
||||
app.close_transcript_overlay(&mut tui);
|
||||
}
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn offline_find_closes_before_the_next_ctrl_c_quits() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.chat_widget
|
||||
.apply_external_edit("offline draft".to_string());
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
app.open_transcript_overlay(&mut tui);
|
||||
app.transcript_view.begin_search();
|
||||
app.transcript_view.paste_search("needle");
|
||||
app.reconnect.offline = true;
|
||||
app.chat_widget.pause_for_disconnect();
|
||||
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
|
||||
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
||||
&tui.terminal,
|
||||
));
|
||||
let cursor = tui.terminal.last_known_cursor_pos;
|
||||
assert_eq!(
|
||||
rendered.lines().nth(usize::from(cursor.y)).map(str::trim),
|
||||
Some("Find: needle")
|
||||
);
|
||||
assert!(!rendered.contains("ctrl+c quit"));
|
||||
let close = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
assert!(matches!(
|
||||
app.handle_tui_event(&mut tui, &mut app_server, close)
|
||||
.await?,
|
||||
AppRunControl::Continue
|
||||
));
|
||||
assert!(!app.transcript_view.is_search_active());
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"offline draft"
|
||||
);
|
||||
assert!(app.transcript_view.is_detailed());
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
|
||||
)
|
||||
.await?;
|
||||
assert!(!app.transcript_view.is_detailed());
|
||||
let quit = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
assert!(matches!(
|
||||
app.handle_tui_event(&mut tui, &mut app_server, quit)
|
||||
.await?,
|
||||
AppRunControl::Exit(ExitReason::UserRequested)
|
||||
));
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_transcript_search_draws_and_escape_closes_find() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
attach_thread(&mut app, ThreadId::new());
|
||||
app.transcript_cells = vec![user_cell("needle in history")];
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.open_transcript_overlay(&mut tui);
|
||||
for event in [
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)),
|
||||
TuiEvent::Paste("needle".to_string()),
|
||||
TuiEvent::Draw,
|
||||
] {
|
||||
app.handle_backtrack_overlay_event(&mut tui, &mut app_server, event)
|
||||
.await?;
|
||||
}
|
||||
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
||||
&tui.terminal,
|
||||
));
|
||||
assert!(
|
||||
rendered.contains("enter next"),
|
||||
"search advances on the inline overlay draw path"
|
||||
);
|
||||
app.handle_backtrack_overlay_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
matches!(&app.overlay, Some(Overlay::Transcript(overlay)) if !overlay.has_active_interaction())
|
||||
);
|
||||
app.close_transcript_overlay(&mut tui);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_refreshes_live_details_before_searching_the_first_query() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
crate::chatwidget::tests::helpers::set_active_cell(
|
||||
&mut app.chat_widget,
|
||||
Box::new(crate::exec_cell::new_active_exec_command(
|
||||
"live".into(),
|
||||
vec!["printf visible\nprintf needle".into()],
|
||||
Vec::new(),
|
||||
codex_app_server_protocol::CommandExecutionSource::Agent,
|
||||
/*interaction_input*/ None,
|
||||
/*animations_enabled*/ false,
|
||||
)),
|
||||
);
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
|
||||
assert!(
|
||||
!buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
||||
&tui.terminal
|
||||
))
|
||||
.contains("needle")
|
||||
);
|
||||
for event in [
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
|
||||
TuiEvent::Paste("needle".into()),
|
||||
TuiEvent::Draw,
|
||||
] {
|
||||
app.handle_tui_event(&mut tui, &mut app_server, event)
|
||||
.await?;
|
||||
}
|
||||
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
||||
&tui.terminal,
|
||||
));
|
||||
assert!(rendered.contains("enter next"), "{rendered}");
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/app/owned_transcript_tests.rs
|
||||
expression: "rendered.lines().skip(usize::from(cursor.y)).take(2).map(str::trim).collect::<Vec<_>>().join(\"\\n\")"
|
||||
---
|
||||
Find: alpha betax
|
||||
ctrl+x then · h move line start · esc cancel
|
||||
@@ -10,6 +10,8 @@ use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::chatwidget::tests::helpers::render_bottom_popup;
|
||||
use crate::chatwidget::tests::helpers::set_active_cell;
|
||||
use crate::keymap::KeymapContext;
|
||||
#[cfg(unix)]
|
||||
use crate::pager_overlay::TranscriptHistoryState;
|
||||
use crate::test_support::test_path_display;
|
||||
use crate::tui::Tui;
|
||||
use codex_app_server_protocol::ToolRequestUserInputOption;
|
||||
@@ -94,20 +96,25 @@ async fn vim_buffer_jumps_route_default_chords_in_normal_and_operator_contexts()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<()> {
|
||||
async fn global_chord_keeps_hints_and_completes_before_deadline() -> Result<()> {
|
||||
let (mut app, mut tui, mut app_server) = chord_app().await?;
|
||||
|
||||
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
|
||||
assert!(app.key_chord_matcher.is_pending());
|
||||
assert!(app.overlay.is_none());
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(std::time::Duration::from_millis(/*millis*/ 500)).await;
|
||||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
|
||||
.await?;
|
||||
assert!(app.key_chord_matcher.is_pending());
|
||||
insta::assert_snapshot!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 80)
|
||||
.replace(&test_path_display("/tmp/project"), "/tmp/project"),
|
||||
@"
|
||||
› Ask Codex to do anything
|
||||
@r"
|
||||
› Ask Codex to do anything
|
||||
|
||||
ctrl+x … waiting for next key · esc cancel
|
||||
"
|
||||
ctrl+x then · ctrl+t open transcript · ctrl+u interrupt turn · esc cancel
|
||||
"
|
||||
);
|
||||
|
||||
press(&mut app, &mut tui, &mut app_server, ctrl('t')).await?;
|
||||
@@ -288,6 +295,65 @@ async fn legacy_terminal_preserves_active_global_alt_shortcuts() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn owned_transcript_alt_jumps_preserve_legacy_vim_insert_and_draft() -> Result<()> {
|
||||
let (mut app, mut tui, mut app_server) = chord_app().await?;
|
||||
app.enhanced_keys_supported = false;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
app.transcript_cells = vec![std::sync::Arc::new(
|
||||
crate::history_cell::PlainHistoryCell::new(vec!["Earlier transcript".into()]),
|
||||
)];
|
||||
app.chat_widget.toggle_vim_mode_and_notify();
|
||||
app.chat_widget.insert_str("draft");
|
||||
press(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
KeyCode::Char('i').into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let area = ratatui::layout::Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4,
|
||||
);
|
||||
let mut buffer = ratatui::buffer::Buffer::empty(area);
|
||||
app.transcript_view
|
||||
.render(area, &mut buffer, &app.transcript_cells);
|
||||
|
||||
for modifiers in [KeyModifiers::ALT, KeyModifiers::ALT | KeyModifiers::SHIFT] {
|
||||
app.transcript_view.history = TranscriptHistoryState::Partial;
|
||||
for (character, following, history) in [
|
||||
('<', false, TranscriptHistoryState::LoadingBeginning),
|
||||
('>', true, TranscriptHistoryState::LoadingOlder),
|
||||
] {
|
||||
press(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
KeyEvent::new(KeyCode::Char(character), modifiers),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
(
|
||||
app.transcript_view.is_following(),
|
||||
app.transcript_view.history,
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
app.chat_widget
|
||||
.should_handle_vim_insert_escape(KeyEvent::new(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::NONE,
|
||||
)),
|
||||
),
|
||||
(following, history, "draft".to_string(), true)
|
||||
);
|
||||
}
|
||||
}
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn legacy_terminal_preserves_configured_composer_alt_shortcuts() -> Result<()> {
|
||||
@@ -622,7 +688,7 @@ async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<
|
||||
let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
insta::assert_snapshot!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(),
|
||||
@" ctrl+x … waiting for next key esc cancel"
|
||||
@" ctrl+x then n new task esc cancel"
|
||||
);
|
||||
assert_eq!(
|
||||
app.route_key_chord_event(&mut tui, KeyCode::Esc.into()),
|
||||
|
||||
+1
-1
@@ -13,4 +13,4 @@ expression: buffer_text(&buffer)
|
||||
|
||||
Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
@@ -10,7 +10,6 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn fresh_vim_draft_records_typing_after_submission() {
|
||||
@@ -88,12 +87,8 @@ fn vim_search_query_edits_and_paste_preserve_the_draft() {
|
||||
KeyCode::Char(ch)
|
||||
};
|
||||
let event = KeyEvent::new(code, KeyModifiers::NONE);
|
||||
let event = match matcher.advance(
|
||||
event,
|
||||
&keymap.chords,
|
||||
composer.keymap_contexts(),
|
||||
Instant::now(),
|
||||
) {
|
||||
let event = match matcher.advance(event, &keymap.chords, composer.keymap_contexts())
|
||||
{
|
||||
KeyChordMatch::PassThrough => event,
|
||||
KeyChordMatch::Completed(event) => event,
|
||||
KeyChordMatch::Pending(_) => continue,
|
||||
|
||||
@@ -131,6 +131,7 @@ mod effort_status_line;
|
||||
mod experimental_features_view;
|
||||
mod file_search_popup;
|
||||
mod footer;
|
||||
pub(crate) use footer::footer_hint_items_line;
|
||||
mod list_selection_view;
|
||||
mod memories_settings_view;
|
||||
mod mentions_v2;
|
||||
@@ -196,6 +197,8 @@ mod selection_row_layout;
|
||||
mod selection_tabs;
|
||||
mod startup;
|
||||
mod textarea;
|
||||
pub(crate) use textarea::TextArea;
|
||||
pub(crate) use textarea::TextAreaState;
|
||||
mod unified_exec_footer;
|
||||
pub(crate) use feedback_note_view::FeedbackNoteView;
|
||||
pub(crate) use hooks_browser_view::HooksBrowserView;
|
||||
|
||||
@@ -7,7 +7,6 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::time::Instant;
|
||||
|
||||
fn vim_textarea(text: &str, cursor: usize) -> TextArea {
|
||||
let mut textarea = TextArea::new();
|
||||
@@ -28,12 +27,7 @@ fn keys(textarea: &mut TextArea, keys: &str) {
|
||||
ch => KeyCode::Char(ch),
|
||||
};
|
||||
let event = KeyEvent::new(code, KeyModifiers::NONE);
|
||||
match matcher.advance(
|
||||
event,
|
||||
&keymap.chords,
|
||||
textarea.keymap_contexts(),
|
||||
Instant::now(),
|
||||
) {
|
||||
match matcher.advance(event, &keymap.chords, textarea.keymap_contexts()) {
|
||||
KeyChordMatch::PassThrough => textarea.input(event),
|
||||
KeyChordMatch::Completed(event) => textarea.input(event),
|
||||
KeyChordMatch::Pending(_) | KeyChordMatch::Cancelled | KeyChordMatch::Ignored => {}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Render live conversation cells in compact or detailed form.
|
||||
//! Expose live activity identity and local detail rendering to the owned transcript.
|
||||
//! Canonical cells retain all available data; presentation does not modify tool execution.
|
||||
|
||||
use super::*;
|
||||
use crate::transcript_view::ActivityTranscriptLines;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn active_cell_hyperlink_lines_with(
|
||||
pub(super) fn active_cell_hyperlink_lines_with(
|
||||
&self,
|
||||
width: u16,
|
||||
render: impl Fn(&dyn HistoryCell, u16) -> Vec<HyperlinkLine>,
|
||||
@@ -31,4 +35,59 @@ impl ChatWidget {
|
||||
}
|
||||
(!lines.is_empty()).then_some(lines)
|
||||
}
|
||||
|
||||
pub(crate) fn active_activity_ids(&self) -> Vec<String> {
|
||||
self.transcript
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |cell| cell.activity_ids())
|
||||
}
|
||||
|
||||
/// Render only the chosen activity presentation, before any auxiliary live text.
|
||||
pub(crate) fn active_cell_owned_transcript_lines(
|
||||
&self,
|
||||
width: u16,
|
||||
expanded: bool,
|
||||
) -> Option<ActivityTranscriptLines> {
|
||||
let active = self.transcript.active_cell.as_deref();
|
||||
let mode = self.history_render_mode();
|
||||
let render_compact = |cell: &dyn HistoryCell, width| {
|
||||
if mode == HistoryRenderMode::Raw {
|
||||
cell.display_hyperlink_lines_for_mode(width, mode)
|
||||
} else {
|
||||
cell.compact_hyperlink_lines(width)
|
||||
}
|
||||
};
|
||||
let has_activity = mode == HistoryRenderMode::Rich
|
||||
&& active.is_some_and(|cell| !cell.activity_ids().is_empty());
|
||||
let activity = active.map_or_else(Vec::new, |cell| {
|
||||
if expanded && has_activity {
|
||||
cell.expanded_hyperlink_lines(width)
|
||||
} else {
|
||||
render_compact(cell, width)
|
||||
}
|
||||
});
|
||||
let has_hidden_details =
|
||||
has_activity && active.is_some_and(|cell| cell.has_hidden_activity_details(width));
|
||||
let mut auxiliary = self
|
||||
.active_cell_hyperlink_lines_with(width, |cell, width| {
|
||||
if active.is_some_and(|active| std::ptr::eq(active, cell)) {
|
||||
Vec::new()
|
||||
} else {
|
||||
render_compact(cell, width)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if activity.is_empty() && auxiliary.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !activity.is_empty() && !auxiliary.is_empty() {
|
||||
auxiliary.insert(/*index*/ 0, HyperlinkLine::from(""));
|
||||
}
|
||||
Some(ActivityTranscriptLines {
|
||||
activity,
|
||||
auxiliary,
|
||||
has_hidden_details,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@ use crate::bottom_pane::BottomPaneView;
|
||||
use crate::clipboard_copy::CopyFormat;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn shortcut_overlay_visible(&self) -> bool {
|
||||
self.bottom_pane.shortcut_overlay_visible()
|
||||
}
|
||||
|
||||
pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) {
|
||||
self.bottom_pane.set_agents_navigation_enabled(enabled);
|
||||
}
|
||||
@@ -307,6 +303,11 @@ impl ChatWidget {
|
||||
.replace_selection_view_if_present(view_id, params)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn shortcut_overlay_visible(&self) -> bool {
|
||||
self.bottom_pane.shortcut_overlay_visible()
|
||||
}
|
||||
|
||||
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
|
||||
self.bottom_pane.no_modal_or_popup_active()
|
||||
}
|
||||
@@ -360,6 +361,34 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Report a transcript copy without adding history and return its outcome to the viewport.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn copy_transcript_selection(
|
||||
&mut self,
|
||||
text: &str,
|
||||
) -> Result<crate::clipboard_copy::CopyStatus, String> {
|
||||
self.copy_transcript_selection_with(text, |text| {
|
||||
crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::PlainText)
|
||||
})
|
||||
}
|
||||
|
||||
/// The owned viewport renders its own copy feedback above the composer.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(super) fn copy_transcript_selection_with(
|
||||
&mut self,
|
||||
text: &str,
|
||||
copy_fn: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyOutcome, String>,
|
||||
) -> Result<crate::clipboard_copy::CopyStatus, String> {
|
||||
let result = self.write_clipboard(text, copy_fn);
|
||||
let line = match &result {
|
||||
Ok(status) => Line::from(status.message("selection").dim()),
|
||||
Err(error) => Line::from(format!("Copy failed: {error}").red()),
|
||||
};
|
||||
self.bottom_pane
|
||||
.show_footer_flash(line, Duration::from_secs(/*secs*/ 3));
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn copy_selection(&mut self, text: Arc<str>, label: String, format: CopyFormat) {
|
||||
self.copy_selection_with(&text, &label, |text| {
|
||||
crate::clipboard_copy::copy_to_clipboard(text, format)
|
||||
|
||||
@@ -721,6 +721,10 @@ async fn compact_voice_meters_keep_real_speaker_history_when_the_microphone_is_m
|
||||
#[tokio::test]
|
||||
async fn voice_toggle_shortcut_uses_the_slash_command_start_guard_and_preserves_draft() {
|
||||
let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
|
||||
let config =
|
||||
toml::from_str::<codex_config::types::TuiKeymap>("[chat]\ntoggle_voice = 'f8'").unwrap();
|
||||
let runtime = crate::keymap::RuntimeKeymap::from_config(&config).unwrap();
|
||||
chat.apply_keymap_update(config, &runtime);
|
||||
chat.set_side_conversation_active(/*active*/ true);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("draft".to_string(), Vec::new(), Vec::new());
|
||||
@@ -743,6 +747,10 @@ async fn voice_toggle_shortcut_uses_the_slash_command_start_guard_and_preserves_
|
||||
#[tokio::test]
|
||||
async fn voice_toggle_shortcut_stops_only_on_press_outside_popups() {
|
||||
let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
|
||||
let config =
|
||||
toml::from_str::<codex_config::types::TuiKeymap>("[chat]\ntoggle_voice = 'f8'").unwrap();
|
||||
let runtime = crate::keymap::RuntimeKeymap::from_config(&config).unwrap();
|
||||
chat.apply_keymap_update(config, &runtime);
|
||||
let thread_id = activate_voice(&mut chat);
|
||||
let shortcut = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE);
|
||||
for kind in [KeyEventKind::Repeat, KeyEventKind::Release] {
|
||||
|
||||
@@ -2280,9 +2280,9 @@ async fn slash_keymap_debug_opens_keypress_inspector() {
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(popup.contains("Keypress Inspector"));
|
||||
assert!(popup.contains("Waiting for a keypress"));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE));
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(popup.contains("global.copy (Copy)"));
|
||||
assert!(popup.contains("global.focus_activity"));
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"debug inspector should open without transcript messages"
|
||||
|
||||
@@ -12,7 +12,6 @@ pub(crate) fn shortcut(keys: &str, label: &str) -> Line<'static> {
|
||||
}
|
||||
|
||||
/// Choose a complete hint variant so narrow surfaces never display half a shortcut.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn first_fitting_line(
|
||||
candidates: impl IntoIterator<Item = Line<'static>>,
|
||||
width: u16,
|
||||
|
||||
@@ -258,9 +258,8 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
self.transcript_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
/// Whether differing activity presentations can contain genuinely hidden details.
|
||||
/// The caller also compares compact and expanded content; overrides suppress differences
|
||||
/// that only change headings, status decoration, or ordering of already-visible information.
|
||||
/// Whether an activity offers details beyond its compact presentation.
|
||||
/// Prefer source metadata so collapsed rendering does not materialize hidden content.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
fn has_hidden_activity_details(&self, _width: u16) -> bool {
|
||||
true
|
||||
|
||||
+82
-20
@@ -45,6 +45,10 @@ mod conflict_tests;
|
||||
#[path = "keymap/voice_tests.rs"]
|
||||
mod voice_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "keymap/global_find_tests.rs"]
|
||||
mod global_find_tests;
|
||||
|
||||
pub(crate) use bindings::KeymapContext;
|
||||
pub(crate) use bindings::bindings_for_action;
|
||||
pub(crate) use bindings::keymap_action_id;
|
||||
@@ -91,6 +95,10 @@ pub(crate) struct AppKeymap {
|
||||
pub(crate) open_agents: Vec<KeyBinding>,
|
||||
/// Open transcript overlay.
|
||||
pub(crate) open_transcript: Vec<KeyBinding>,
|
||||
/// Find text in the full transcript.
|
||||
pub(crate) find_transcript: Vec<KeyBinding>,
|
||||
/// Focus activity groups in the owned transcript to inspect their details.
|
||||
pub(crate) focus_activity: Vec<KeyBinding>,
|
||||
/// Open external editor for the current draft.
|
||||
pub(crate) open_external_editor: Vec<KeyBinding>,
|
||||
/// Copy the last agent response to the clipboard.
|
||||
@@ -295,6 +303,7 @@ pub(crate) struct PagerKeymap {
|
||||
pub(crate) jump_bottom: Vec<KeyBinding>,
|
||||
pub(crate) close: Vec<KeyBinding>,
|
||||
pub(crate) close_transcript: Vec<KeyBinding>,
|
||||
pub(crate) find: Vec<KeyBinding>,
|
||||
chord_hints: Arc<RuntimeChordKeymap>,
|
||||
}
|
||||
|
||||
@@ -629,13 +638,7 @@ impl RuntimeKeymap {
|
||||
|| configured_context_alias_is_used(&keymap.list, alias)
|
||||
|| configured_context_alias_is_used(&keymap.approval, alias)
|
||||
});
|
||||
let voice_toggle_default_is_shadowed = keymap.chat.toggle_voice.is_none()
|
||||
&& (configured_main_surface_alias_is_used(keymap, "f8")
|
||||
|| configured_context_alias_is_used(&keymap.vim_search, "f8")
|
||||
|| chords.bindings.iter().any(|binding| {
|
||||
binding.action.context.overlaps(KeymapContext::Chat)
|
||||
&& binding.chord.prefix.parts() == key_hint::plain(KeyCode::F(8)).parts()
|
||||
}));
|
||||
|
||||
// Preserve existing Ctrl+X shortcuts and chord prefixes when adding this default.
|
||||
let voice_mute_default_is_shadowed = keymap.chat.toggle_voice_mute.is_none()
|
||||
&& (configured_main_surface_alias_is_used(keymap, "ctrl-x")
|
||||
@@ -645,6 +648,20 @@ impl RuntimeKeymap {
|
||||
== key_hint::ctrl(KeyCode::Char('x')).parts()
|
||||
}));
|
||||
|
||||
// New activity defaults yield to existing custom keys and chord prefixes.
|
||||
let focus_activity_defaults: Vec<_> = defaults
|
||||
.app
|
||||
.focus_activity
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|binding| {
|
||||
!configured_context_binding_is_used(keymap, *binding)
|
||||
&& !chords.bindings.iter().any(|chord| {
|
||||
chord.chord.prefix.normalized_parts() == binding.normalized_parts()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let app = AppKeymap {
|
||||
open_agents: resolve_bindings(
|
||||
keymap.global.open_agents.as_ref(),
|
||||
@@ -656,6 +673,16 @@ impl RuntimeKeymap {
|
||||
&defaults.app.open_transcript,
|
||||
"tui.keymap.global.open_transcript",
|
||||
)?,
|
||||
find_transcript: resolve_bindings(
|
||||
keymap.global.find_transcript.as_ref(),
|
||||
&defaults.app.find_transcript,
|
||||
"tui.keymap.global.find_transcript",
|
||||
)?,
|
||||
focus_activity: resolve_bindings(
|
||||
keymap.global.focus_activity.as_ref(),
|
||||
&focus_activity_defaults,
|
||||
"tui.keymap.global.focus_activity",
|
||||
)?,
|
||||
open_external_editor: resolve_bindings(
|
||||
keymap.global.open_external_editor.as_ref(),
|
||||
&defaults.app.open_external_editor,
|
||||
@@ -697,6 +724,15 @@ impl RuntimeKeymap {
|
||||
},
|
||||
};
|
||||
|
||||
// Voice yields to explicitly configured shortcuts and chord prefixes.
|
||||
let voice_toggle_default_is_shadowed = keymap.chat.toggle_voice.is_none()
|
||||
&& (configured_main_surface_alias_is_used(keymap, "f8")
|
||||
|| configured_context_alias_is_used(&keymap.vim_search, "f8")
|
||||
|| chords.bindings.iter().any(|binding| {
|
||||
binding.action.context.overlaps(KeymapContext::Chat)
|
||||
&& binding.chord.prefix.parts() == key_hint::plain(KeyCode::F(8)).parts()
|
||||
}));
|
||||
|
||||
let mut chat = ChatKeymap {
|
||||
toggle_voice: if voice_toggle_default_is_shadowed {
|
||||
Vec::new()
|
||||
@@ -1290,6 +1326,7 @@ impl RuntimeKeymap {
|
||||
jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom),
|
||||
close: resolve_local!(keymap, defaults, pager, close),
|
||||
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
|
||||
find: resolve_local!(keymap, defaults, pager, find),
|
||||
chord_hints: Arc::clone(&chords),
|
||||
};
|
||||
|
||||
@@ -1366,6 +1403,14 @@ impl RuntimeKeymap {
|
||||
keymap.global.open_transcript.as_ref(),
|
||||
app.open_transcript.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.global.find_transcript.as_ref(),
|
||||
app.find_transcript.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.global.focus_activity.as_ref(),
|
||||
app.focus_activity.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.global.open_external_editor.as_ref(),
|
||||
app.open_external_editor.as_slice(),
|
||||
@@ -1550,7 +1595,7 @@ impl RuntimeKeymap {
|
||||
});
|
||||
}
|
||||
}
|
||||
resolved.configure_vim_search(keymap)?;
|
||||
resolved.configure_search(keymap)?;
|
||||
resolved.validate_conflicts()?;
|
||||
chords::validate_chord_conflicts(&resolved)?;
|
||||
chords::install_dispatch_bindings(&mut resolved)?;
|
||||
@@ -1578,6 +1623,8 @@ impl RuntimeKeymap {
|
||||
app: AppKeymap {
|
||||
open_agents: default_bindings![],
|
||||
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
|
||||
find_transcript: default_bindings![plain(KeyCode::F(3))],
|
||||
focus_activity: default_bindings![plain(KeyCode::F(4))],
|
||||
open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))],
|
||||
copy: default_bindings![ctrl(KeyCode::Char('o'))],
|
||||
clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))],
|
||||
@@ -1814,6 +1861,7 @@ impl RuntimeKeymap {
|
||||
jump_bottom: default_bindings![plain(KeyCode::End)],
|
||||
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
|
||||
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
|
||||
find: default_bindings![plain(KeyCode::F(3)), plain(KeyCode::Char('/'))],
|
||||
chord_hints: Arc::default(),
|
||||
},
|
||||
list: ListKeymap {
|
||||
@@ -1934,6 +1982,8 @@ impl RuntimeKeymap {
|
||||
let main_bindings = [
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
("focus_activity", self.app.focus_activity.as_slice()),
|
||||
(
|
||||
"open_external_editor",
|
||||
self.app.open_external_editor.as_slice(),
|
||||
@@ -2037,6 +2087,8 @@ impl RuntimeKeymap {
|
||||
[
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
("focus_activity", self.app.focus_activity.as_slice()),
|
||||
(
|
||||
"open_external_editor",
|
||||
self.app.open_external_editor.as_slice(),
|
||||
@@ -2093,6 +2145,8 @@ impl RuntimeKeymap {
|
||||
[
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
("focus_activity", self.app.focus_activity.as_slice()),
|
||||
(
|
||||
"open_external_editor",
|
||||
self.app.open_external_editor.as_slice(),
|
||||
@@ -2216,6 +2270,16 @@ impl RuntimeKeymap {
|
||||
)?;
|
||||
|
||||
validate_unique("list", context_bindings(KeymapContext::List))?;
|
||||
validate_unique(
|
||||
"activity",
|
||||
context_bindings(KeymapContext::List).chain([
|
||||
("global.focus_activity", self.app.focus_activity.as_slice()),
|
||||
(
|
||||
"global.find_transcript",
|
||||
self.app.find_transcript.as_slice(),
|
||||
),
|
||||
]),
|
||||
)?;
|
||||
|
||||
validate_unique("agents", context_bindings(KeymapContext::Agents))?;
|
||||
validate_no_reserved(
|
||||
@@ -2487,23 +2551,27 @@ fn configured_main_surface_alias_is_used(keymap: &TuiKeymap, alias: &str) -> boo
|
||||
}
|
||||
|
||||
fn configured_context_alias_is_used(context: &impl Serialize, alias: &str) -> bool {
|
||||
parse_keybinding(alias)
|
||||
.is_some_and(|binding| configured_context_binding_is_used(context, binding))
|
||||
}
|
||||
|
||||
fn configured_context_binding_is_used(context: &impl Serialize, binding: KeyBinding) -> bool {
|
||||
let Ok(value) = serde_json::to_value(context) else {
|
||||
return false;
|
||||
};
|
||||
keymap_value_contains_alias(&value, alias)
|
||||
keymap_value_contains_binding(&value, binding)
|
||||
}
|
||||
|
||||
fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool {
|
||||
fn keymap_value_contains_binding(value: &serde_json::Value, binding: KeyBinding) -> bool {
|
||||
match value {
|
||||
serde_json::Value::String(value) => parse_keybinding(value)
|
||||
.zip(parse_keybinding(alias))
|
||||
.is_some_and(|(a, b)| a.normalized_parts() == b.normalized_parts()),
|
||||
.is_some_and(|configured| configured.normalized_parts() == binding.normalized_parts()),
|
||||
serde_json::Value::Array(values) => values
|
||||
.iter()
|
||||
.any(|value| keymap_value_contains_alias(value, alias)),
|
||||
.any(|value| keymap_value_contains_binding(value, binding)),
|
||||
serde_json::Value::Object(values) => values
|
||||
.values()
|
||||
.any(|value| keymap_value_contains_alias(value, alias)),
|
||||
.any(|value| keymap_value_contains_binding(value, binding)),
|
||||
serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => {
|
||||
false
|
||||
}
|
||||
@@ -2781,12 +2849,6 @@ mod tests {
|
||||
assert!(err.contains("tui.keymap.global.open_external_editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_copy_binding_is_ctrl_o() {
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
assert_eq!(runtime.app.copy, vec![key_hint::ctrl(KeyCode::Char('o'))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_shortcuts_reserve_plain_text() {
|
||||
for binding in ["a", "shift-a", "2", "space"] {
|
||||
|
||||
@@ -103,6 +103,17 @@ pub(crate) struct KeymapActionId {
|
||||
}
|
||||
|
||||
impl KeymapActionId {
|
||||
/// Activity focus and Find remain active while transcript groups use list navigation.
|
||||
pub(super) fn overlaps(self, other: Self) -> bool {
|
||||
self.context.overlaps(other.context)
|
||||
|| (self.context == KeymapContext::Global
|
||||
&& matches!(self.action, "focus_activity" | "find_transcript")
|
||||
&& other.context == KeymapContext::List)
|
||||
|| (other.context == KeymapContext::Global
|
||||
&& matches!(other.action, "focus_activity" | "find_transcript")
|
||||
&& self.context == KeymapContext::List)
|
||||
}
|
||||
|
||||
pub(crate) fn config_path(self) -> String {
|
||||
format!("tui.keymap.{}.{}", self.context.config_name(), self.action)
|
||||
}
|
||||
@@ -251,6 +262,8 @@ define_runtime_action_bindings! {
|
||||
"global" => Global, app, global [
|
||||
open_agents,
|
||||
open_transcript,
|
||||
find_transcript,
|
||||
focus_activity,
|
||||
open_external_editor,
|
||||
copy,
|
||||
clear_terminal,
|
||||
@@ -380,6 +393,7 @@ define_runtime_action_bindings! {
|
||||
jump_bottom,
|
||||
close,
|
||||
close_transcript,
|
||||
find,
|
||||
],
|
||||
"list" => List, list, list [
|
||||
move_up,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Single-event bindings remain in the ordinary runtime keymaps. A completed
|
||||
//! chord becomes an internal function-key token appended to the target action,
|
||||
//! so existing handlers remain the only action dispatch table.
|
||||
//! Pending chords expire after one second or when their active context changes.
|
||||
|
||||
use super::MAIN_RESERVED_BINDINGS;
|
||||
use super::RuntimeKeymap;
|
||||
@@ -25,6 +26,8 @@ use crossterm::event::KeyModifiers;
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1);
|
||||
|
||||
const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY + 1;
|
||||
const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX;
|
||||
const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
|
||||
@@ -34,29 +37,38 @@ const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
|
||||
("resume_picker.toggle_density", ctrl(KeyCode::Char('o'))),
|
||||
];
|
||||
|
||||
/// Time allowed between the first and second strokes of a key chord.
|
||||
pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1);
|
||||
|
||||
/// Compact set of keymap contexts that share one active input path.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) struct KeymapContextSet(u16);
|
||||
pub(crate) struct KeymapContextSet(u32);
|
||||
|
||||
const TRANSCRIPT_CLOSE: u16 = 1 << 15;
|
||||
const ACTIVITY_FOCUS: u32 = 1 << 13;
|
||||
const TRANSCRIPT_CLOSE: u32 = 1 << 15;
|
||||
|
||||
impl KeymapContextSet {
|
||||
pub(crate) const fn new(context: KeymapContext) -> Self {
|
||||
Self(context_bit(context))
|
||||
}
|
||||
|
||||
pub(crate) const fn with_transcript_close(self) -> Self {
|
||||
Self(self.0 | TRANSCRIPT_CLOSE)
|
||||
/// Activity navigation combines list actions with global focus and Find.
|
||||
/// Other global actions remain inactive so existing list remaps keep priority.
|
||||
pub(crate) const fn activity() -> Self {
|
||||
Self(context_bit(KeymapContext::List) | context_bit(KeymapContext::Global) | ACTIVITY_FOCUS)
|
||||
}
|
||||
|
||||
/// Add only the detail-close action to an owned composer path.
|
||||
pub(crate) const fn with_transcript_close(self) -> Self {
|
||||
Self(self.0 | context_bit(KeymapContext::Pager) | TRANSCRIPT_CLOSE)
|
||||
}
|
||||
|
||||
/// Whether this input path can dispatch the action, including focus-specific exclusions.
|
||||
pub(crate) fn contains_action(self, action: KeymapActionId) -> bool {
|
||||
self.contains(action.context)
|
||||
|| (self.0 & TRANSCRIPT_CLOSE != 0
|
||||
&& action.context == KeymapContext::Pager
|
||||
&& action.action == "close_transcript")
|
||||
&& (self.0 & ACTIVITY_FOCUS == 0
|
||||
|| action.context != KeymapContext::Global
|
||||
|| matches!(action.action, "focus_activity" | "find_transcript"))
|
||||
&& (self.0 & TRANSCRIPT_CLOSE == 0
|
||||
|| action.context != KeymapContext::Pager
|
||||
|| action.action == "close_transcript")
|
||||
}
|
||||
|
||||
pub(crate) const fn with(self, context: KeymapContext) -> Self {
|
||||
@@ -68,7 +80,7 @@ impl KeymapContextSet {
|
||||
}
|
||||
}
|
||||
|
||||
const fn context_bit(context: KeymapContext) -> u16 {
|
||||
const fn context_bit(context: KeymapContext) -> u32 {
|
||||
1 << match context {
|
||||
KeymapContext::Global => 0,
|
||||
KeymapContext::Chat => 1,
|
||||
@@ -239,9 +251,9 @@ pub(crate) enum KeyChordMatch {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PendingChord {
|
||||
started_at: Instant,
|
||||
prefix: KeyBinding,
|
||||
contexts: KeymapContextSet,
|
||||
started_at: Instant,
|
||||
}
|
||||
|
||||
/// Tracks one pending two-stroke chord without buffering ordinary input.
|
||||
@@ -255,14 +267,65 @@ impl KeyChordMatcher {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
/// Derive one presentation for the pending chord from the matcher that owns it.
|
||||
pub(crate) fn pending_hint_items(
|
||||
&self,
|
||||
keymap: &RuntimeChordKeymap,
|
||||
width: u16,
|
||||
) -> Option<Vec<(String, String)>> {
|
||||
let pending = self.pending?;
|
||||
let mut items = vec![(pending.prefix.display_label(), "then".to_string())];
|
||||
for binding in keymap
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|binding| {
|
||||
binding.chord.prefix == pending.prefix
|
||||
&& pending.contexts.contains_action(binding.action)
|
||||
})
|
||||
.take(/*n*/ 6)
|
||||
{
|
||||
let label = match binding.action.action {
|
||||
"focus_activity" => "activity",
|
||||
"find_transcript" => "find",
|
||||
action => action,
|
||||
};
|
||||
items.push((
|
||||
binding.chord.completion.display_label(),
|
||||
label.replace('_', " "),
|
||||
));
|
||||
}
|
||||
items.push(("esc".to_string(), "cancel".to_string()));
|
||||
if crate::bottom_pane::footer_hint_items_line(&items).width() > usize::from(width) {
|
||||
let compact: Vec<_> = items[1..]
|
||||
.iter()
|
||||
.map(|(key, action)| {
|
||||
let action = match action.as_str() {
|
||||
"activity" => "inspect",
|
||||
action => action,
|
||||
};
|
||||
(key.clone(), action.to_string())
|
||||
})
|
||||
.collect();
|
||||
let mut fitting = Vec::new();
|
||||
for item in compact {
|
||||
fitting.push(item);
|
||||
if crate::bottom_pane::footer_hint_items_line(&fitting).width() > usize::from(width)
|
||||
{
|
||||
fitting.pop();
|
||||
}
|
||||
}
|
||||
return Some(fitting);
|
||||
}
|
||||
Some(items)
|
||||
}
|
||||
|
||||
pub(crate) fn cancel(&mut self) -> bool {
|
||||
self.pending.take().is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn expire(&mut self, contexts: KeymapContextSet, now: Instant) -> bool {
|
||||
pub(crate) fn expire(&mut self, contexts: KeymapContextSet) -> bool {
|
||||
if self.pending.is_some_and(|pending| {
|
||||
pending.contexts != contexts
|
||||
|| now.saturating_duration_since(pending.started_at) >= KEY_CHORD_TIMEOUT
|
||||
pending.contexts != contexts || pending.started_at.elapsed() >= KEY_CHORD_TIMEOUT
|
||||
}) {
|
||||
self.pending = None;
|
||||
return true;
|
||||
@@ -275,13 +338,12 @@ impl KeyChordMatcher {
|
||||
key_event: KeyEvent,
|
||||
keymap: &RuntimeChordKeymap,
|
||||
contexts: KeymapContextSet,
|
||||
now: Instant,
|
||||
) -> KeyChordMatch {
|
||||
if is_dispatch_token_event(key_event) {
|
||||
return KeyChordMatch::Ignored;
|
||||
}
|
||||
|
||||
self.expire(contexts, now);
|
||||
self.expire(contexts);
|
||||
|
||||
if self.pending.is_some() && key_event.kind != KeyEventKind::Press {
|
||||
return KeyChordMatch::Ignored;
|
||||
@@ -325,9 +387,9 @@ impl KeyChordMatcher {
|
||||
.map(|binding| binding.chord.prefix)
|
||||
{
|
||||
self.pending = Some(PendingChord {
|
||||
started_at: Instant::now(),
|
||||
prefix,
|
||||
contexts,
|
||||
started_at: now,
|
||||
});
|
||||
return KeyChordMatch::Pending(prefix);
|
||||
}
|
||||
@@ -436,7 +498,7 @@ pub(super) fn validate_chord_conflicts(keymap: &RuntimeKeymap) -> Result<(), Str
|
||||
validate_reserved_strokes(binding)?;
|
||||
|
||||
if let Some(conflict) = runtime_action_bindings(keymap)
|
||||
.filter(|candidate| binding.action.context.overlaps(candidate.id.context))
|
||||
.filter(|candidate| binding.action.overlaps(candidate.id))
|
||||
.find(|candidate| {
|
||||
candidate.bindings.iter().any(|single| {
|
||||
normalize_chord_binding(*single).parts() == binding.chord.prefix.parts()
|
||||
@@ -454,7 +516,7 @@ Unbind or remap the existing shortcut before using it as a chord prefix.",
|
||||
|
||||
for previous in &keymap.chords.bindings[..index] {
|
||||
if previous.action != binding.action
|
||||
&& previous.action.context.overlaps(binding.action.context)
|
||||
&& previous.action.overlaps(binding.action)
|
||||
&& previous.chord == binding.chord
|
||||
{
|
||||
return Err(format!(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::KEY_CHORD_TIMEOUT;
|
||||
use super::KeyChordMatch;
|
||||
use super::KeyChordMatcher;
|
||||
use super::KeymapContextSet;
|
||||
@@ -15,9 +14,9 @@ use codex_config::types::KeybindingsSpec;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use tokio::time::Instant;
|
||||
|
||||
fn binding(spec: &str) -> KeybindingsSpec {
|
||||
KeybindingsSpec::One(KeybindingSpec(spec.to_string()))
|
||||
@@ -122,12 +121,10 @@ fn default_vim_jump_top_uses_contextual_gg_chords() {
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
let contexts = KeymapContextSet::new(context);
|
||||
assert!(matches!(
|
||||
matcher.advance(g, &runtime.chords, contexts, Instant::now()),
|
||||
matcher.advance(g, &runtime.chords, contexts),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
let KeyChordMatch::Completed(event) =
|
||||
matcher.advance(g, &runtime.chords, contexts, Instant::now())
|
||||
else {
|
||||
let KeyChordMatch::Completed(event) = matcher.advance(g, &runtime.chords, contexts) else {
|
||||
panic!("gg must dispatch the active Vim jump action");
|
||||
};
|
||||
assert!(target.is_pressed(event));
|
||||
@@ -353,15 +350,13 @@ fn physical_dispatch_tokens_do_not_clear_pending_chords() {
|
||||
config.global.open_transcript = Some(binding("ctrl-x ctrl-t"));
|
||||
let keymap = RuntimeKeymap::from_config(&config).expect("valid chord keymap");
|
||||
let contexts = KeymapContextSet::new(KeymapContext::Global);
|
||||
let now = Instant::now();
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
|
||||
assert!(matches!(
|
||||
matcher.advance(
|
||||
key_event(key_hint::ctrl(KeyCode::Char('x'))),
|
||||
&keymap.chords,
|
||||
contexts,
|
||||
now,
|
||||
contexts
|
||||
),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
@@ -369,7 +364,7 @@ fn physical_dispatch_tokens_do_not_clear_pending_chords() {
|
||||
let action = keymap_action_id("global", "open_transcript").expect("known keymap action");
|
||||
let token = dispatch_binding(action).expect("internal dispatch token");
|
||||
assert_eq!(
|
||||
matcher.advance(key_event(token), &keymap.chords, contexts, now),
|
||||
matcher.advance(key_event(token), &keymap.chords, contexts),
|
||||
KeyChordMatch::Ignored
|
||||
);
|
||||
assert!(matcher.is_pending());
|
||||
@@ -377,8 +372,7 @@ fn physical_dispatch_tokens_do_not_clear_pending_chords() {
|
||||
matcher.advance(
|
||||
key_event(key_hint::ctrl(KeyCode::Char('t'))),
|
||||
&keymap.chords,
|
||||
contexts,
|
||||
now,
|
||||
contexts
|
||||
),
|
||||
KeyChordMatch::Completed(_)
|
||||
));
|
||||
@@ -391,7 +385,6 @@ fn matching_chords_dispatches_the_active_context_action() {
|
||||
config.editor.move_line_start = Some(binding("ctrl-x home"));
|
||||
config.list.jump_top = Some(binding("ctrl-x home"));
|
||||
let runtime = RuntimeKeymap::from_config(&config).expect("non-overlapping chord contexts");
|
||||
let now = Instant::now();
|
||||
|
||||
for (context, prefix, completion, target, other) in [
|
||||
(
|
||||
@@ -418,15 +411,12 @@ fn matching_chords_dispatches_the_active_context_action() {
|
||||
] {
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
assert!(matches!(
|
||||
matcher.advance(prefix, &runtime.chords, KeymapContextSet::new(context), now,),
|
||||
matcher.advance(prefix, &runtime.chords, KeymapContextSet::new(context)),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
let KeyChordMatch::Completed(dispatch_event) = matcher.advance(
|
||||
completion,
|
||||
&runtime.chords,
|
||||
KeymapContextSet::new(context),
|
||||
now,
|
||||
) else {
|
||||
let KeyChordMatch::Completed(dispatch_event) =
|
||||
matcher.advance(completion, &runtime.chords, KeymapContextSet::new(context))
|
||||
else {
|
||||
panic!("second stroke must complete the active context chord");
|
||||
};
|
||||
assert!(target.is_pressed(dispatch_event));
|
||||
@@ -434,45 +424,174 @@ fn matching_chords_dispatches_the_active_context_action() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_cancels_on_escape_context_change_and_timeout() {
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn matcher_cancels_on_escape_context_change_and_timeout() {
|
||||
let mut config = TuiKeymap::default();
|
||||
config.list.jump_top = Some(binding("ctrl-x ctrl-t"));
|
||||
let runtime = RuntimeKeymap::from_config(&config).expect("valid list chord");
|
||||
let list = KeymapContextSet::new(KeymapContext::List);
|
||||
let pager = KeymapContextSet::new(KeymapContext::Pager);
|
||||
let prefix = key_event(key_hint::ctrl(KeyCode::Char('x')));
|
||||
let now = Instant::now();
|
||||
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
assert!(matches!(
|
||||
matcher.advance(prefix, &runtime.chords, list, now),
|
||||
matcher.advance(prefix, &runtime.chords, list),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
assert_eq!(
|
||||
matcher.advance(
|
||||
KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE),
|
||||
&runtime.chords,
|
||||
list,
|
||||
now,
|
||||
list
|
||||
),
|
||||
KeyChordMatch::Cancelled
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
matcher.advance(prefix, &runtime.chords, list, now),
|
||||
matcher.advance(prefix, &runtime.chords, list),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
assert!(matcher.expire(pager, now));
|
||||
assert!(matcher.expire(pager));
|
||||
matcher.advance(prefix, &runtime.chords, list);
|
||||
tokio::time::advance(super::KEY_CHORD_TIMEOUT).await;
|
||||
assert_eq!(
|
||||
matcher.advance(
|
||||
key_event(key_hint::ctrl(KeyCode::Char('t'))),
|
||||
&runtime.chords,
|
||||
list
|
||||
),
|
||||
KeyChordMatch::PassThrough,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_transition_discards_prefix_before_routing_the_next_stroke() {
|
||||
let mut config = TuiKeymap::default();
|
||||
config.list.jump_top = Some(binding("ctrl-x home"));
|
||||
config.pager.jump_top = Some(binding("ctrl-x home"));
|
||||
let runtime =
|
||||
RuntimeKeymap::from_config(&config).expect("non-overlapping list and pager chords");
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
assert!(matches!(
|
||||
matcher.advance(prefix, &runtime.chords, list, now),
|
||||
matcher.advance(
|
||||
key_event(key_hint::ctrl(KeyCode::Char('x'))),
|
||||
&runtime.chords,
|
||||
KeymapContextSet::new(KeymapContext::List),
|
||||
),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
assert!(matcher.expire(list, now + KEY_CHORD_TIMEOUT));
|
||||
assert_eq!(
|
||||
matcher.advance(
|
||||
KeyCode::Home.into(),
|
||||
&runtime.chords,
|
||||
KeymapContextSet::new(KeymapContext::Pager),
|
||||
),
|
||||
KeyChordMatch::PassThrough
|
||||
);
|
||||
assert!(!matcher.is_pending());
|
||||
}
|
||||
|
||||
fn key_event(binding: crate::key_hint::KeyBinding) -> KeyEvent {
|
||||
let (code, modifiers) = binding.parts();
|
||||
KeyEvent::new(code, modifiers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_context_preserves_list_chords_and_its_remapped_focus_toggle() {
|
||||
let config = serde_json::from_value(serde_json::json!({
|
||||
"global": {"copy": "ctrl-x c", "focus_activity": "f12 t"},
|
||||
"list": {"jump_top": "ctrl-x c"},
|
||||
}))
|
||||
.unwrap();
|
||||
let runtime =
|
||||
RuntimeKeymap::from_config(&config).expect("existing disjoint contexts remain valid");
|
||||
for (contexts, prefix, completion, target, other) in [
|
||||
(
|
||||
KeymapContextSet::new(KeymapContext::Global),
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyCode::Char('c'),
|
||||
&runtime.app.copy,
|
||||
&runtime.list.jump_top,
|
||||
),
|
||||
(
|
||||
KeymapContextSet::activity(),
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyCode::Char('c'),
|
||||
&runtime.list.jump_top,
|
||||
&runtime.app.copy,
|
||||
),
|
||||
(
|
||||
KeymapContextSet::activity(),
|
||||
KeyCode::F(12).into(),
|
||||
KeyCode::Char('t'),
|
||||
&runtime.app.focus_activity,
|
||||
&runtime.list.jump_top,
|
||||
),
|
||||
] {
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
assert!(matches!(
|
||||
matcher.advance(prefix, &runtime.chords, contexts),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
let KeyChordMatch::Completed(event) =
|
||||
matcher.advance(completion.into(), &runtime.chords, contexts)
|
||||
else {
|
||||
panic!("active action chord must complete");
|
||||
};
|
||||
assert!(target.is_pressed(event));
|
||||
assert!(!other.is_pressed(event));
|
||||
}
|
||||
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
matcher.advance(
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
&runtime.chords,
|
||||
KeymapContextSet::activity(),
|
||||
);
|
||||
assert_eq!(
|
||||
matcher.pending_hint_items(&runtime.chords, /*width*/ 80),
|
||||
Some(vec![
|
||||
("ctrl+x".to_string(), "then".to_string()),
|
||||
("c".to_string(), "jump top".to_string()),
|
||||
("esc".to_string(), "cancel".to_string()),
|
||||
]),
|
||||
);
|
||||
matcher.cancel();
|
||||
let combined = KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Global);
|
||||
assert!(matches!(
|
||||
matcher.advance(KeyCode::F(12).into(), &runtime.chords, combined),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
assert!(matcher.expire(KeymapContextSet::activity()));
|
||||
assert!(!matcher.is_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_hint_skips_whole_oversized_configured_completions() {
|
||||
let config = serde_json::from_value(serde_json::json!({"global": {
|
||||
"copy": "ctrl-x ctrl-alt-shift-f12", "focus_activity": "ctrl-x alt-shift-f11"
|
||||
}}))
|
||||
.unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&config).unwrap();
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
matcher.advance(
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
&runtime.chords,
|
||||
KeymapContextSet::new(KeymapContext::Global),
|
||||
);
|
||||
let mut scenes = Vec::new();
|
||||
for width in [80, 32, 12] {
|
||||
let hints = matcher.pending_hint_items(&runtime.chords, width).unwrap();
|
||||
let line = crate::bottom_pane::footer_hint_items_line(&hints);
|
||||
assert!(line.width() <= usize::from(width));
|
||||
scenes.push(format!("{width}: {line}"));
|
||||
}
|
||||
assert_eq!(
|
||||
scenes,
|
||||
[
|
||||
"80: ctrl+x then · shift+⌥+f11 activity · ctrl+shift+⌥+f12 copy · esc cancel",
|
||||
"32: shift+⌥+f11 inspect · esc cancel",
|
||||
"12: esc cancel",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,3 +80,75 @@ fn new_agents_defaults_preserve_existing_custom_bindings() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_default_preserves_configured_keys_and_prefixes() {
|
||||
use crate::key_hint;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
for (context, action) in [
|
||||
("global", "copy"),
|
||||
("editor", "move_left"),
|
||||
("list", "move_up"),
|
||||
] {
|
||||
for configured in ["f4", "f4 f12"] {
|
||||
let keymap: TuiKeymap =
|
||||
serde_json::from_value(json!({context: {action: configured}})).unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&keymap)
|
||||
.expect("a new activity default must preserve existing configuration");
|
||||
assert!(
|
||||
!super::user_bindings(&runtime.app.focus_activity)
|
||||
.contains(&key_hint::plain(KeyCode::F(4)))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_activity_remapping_and_unbinding_replace_all_defaults() {
|
||||
use crate::key_hint;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
for (configured, expected, hint) in [
|
||||
(
|
||||
json!("f12"),
|
||||
vec![key_hint::plain(KeyCode::F(12))],
|
||||
Some("f12"),
|
||||
),
|
||||
(json!([]), Vec::new(), None),
|
||||
(json!("ctrl-x t"), Vec::new(), Some("ctrl+x t")),
|
||||
] {
|
||||
let keymap: TuiKeymap =
|
||||
serde_json::from_value(json!({"global": {"focus_activity": configured}})).unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("valid explicit activity binding");
|
||||
assert_eq!(super::user_bindings(&runtime.app.focus_activity), expected);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.primary_hint(super::KeymapContext::Global, "focus_activity")
|
||||
.map(crate::key_hint::ShortcutHint::display_label),
|
||||
hint.map(str::to_string),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_activity_bindings_cannot_shadow_list_navigation() {
|
||||
for (focus, movement) in [
|
||||
("f12", "f12"),
|
||||
("f12 t", "f12"),
|
||||
("f12", "f12 t"),
|
||||
("f12 t", "f12 t"),
|
||||
] {
|
||||
let keymap: TuiKeymap = serde_json::from_value(json!({
|
||||
"global": {"focus_activity": focus},
|
||||
"list": {"move_up": movement},
|
||||
}))
|
||||
.unwrap();
|
||||
let error = RuntimeKeymap::from_config(&keymap)
|
||||
.expect_err("activity focus shares list navigation while inspecting groups");
|
||||
assert!(
|
||||
error.contains("focus_activity") && error.contains("move_up"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Global Find joins the existing chord inventory without replacing paging or user bindings.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn global_find_resolves_remaps_unbinding_and_dispatch_in_main_and_activity_contexts() {
|
||||
for (configured, label) in [
|
||||
(json!("f12"), Some("f12")),
|
||||
(json!([]), None),
|
||||
(json!(["ctrl-x f", "f12"]), Some("ctrl+x f")),
|
||||
] {
|
||||
let config =
|
||||
serde_json::from_value(json!({"global":{"find_transcript":configured}})).unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&config).unwrap();
|
||||
assert_eq!(
|
||||
runtime
|
||||
.primary_hint(KeymapContext::Global, "find_transcript")
|
||||
.map(crate::key_hint::ShortcutHint::display_label),
|
||||
label.map(str::to_owned)
|
||||
);
|
||||
assert!(!runtime.app.find_transcript.is_pressed(KeyCode::F(3).into()));
|
||||
assert!(runtime.pager.find.is_pressed(KeyCode::F(3).into()));
|
||||
assert!(runtime.pager.find.is_pressed(KeyCode::Char('/').into()));
|
||||
assert!(
|
||||
runtime
|
||||
.pager
|
||||
.page_down
|
||||
.is_pressed(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL))
|
||||
);
|
||||
}
|
||||
let config = serde_json::from_value(json!({"global":{"find_transcript":"ctrl-x f"}})).unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&config).unwrap();
|
||||
for context in [
|
||||
KeymapContextSet::new(KeymapContext::Global),
|
||||
KeymapContextSet::activity(),
|
||||
] {
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
assert!(matches!(
|
||||
matcher.advance(
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
&runtime.chords,
|
||||
context
|
||||
),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
let KeyChordMatch::Completed(key) =
|
||||
matcher.advance(KeyCode::Char('f').into(), &runtime.chords, context)
|
||||
else {
|
||||
panic!("Find chord")
|
||||
};
|
||||
assert!(runtime.app.find_transcript.is_pressed(key));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_find_defaults_yield_to_existing_keys_and_prefixes_but_explicit_conflicts_fail() {
|
||||
for (context, action) in [
|
||||
("global", "open_transcript"),
|
||||
("editor", "move_line_start"),
|
||||
("list", "move_up"),
|
||||
("approval", "approve"),
|
||||
] {
|
||||
for binding in ["f3", "f3 x"] {
|
||||
let config = serde_json::from_value(json!({context:{action:binding}})).unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&config)
|
||||
.expect("existing binding wins over new default");
|
||||
assert!(!runtime.app.find_transcript.is_pressed(KeyCode::F(3).into()));
|
||||
}
|
||||
let config = serde_json::from_value(
|
||||
json!({"global":{"find_transcript":"f12"},context:{action:"f12"}}),
|
||||
)
|
||||
.unwrap();
|
||||
// Distinct JSON global keys would overwrite each other, so compose that case explicitly.
|
||||
let config = if context == "global" {
|
||||
serde_json::from_value(
|
||||
json!({"global":{"find_transcript":"f12","open_transcript":"f12"}}),
|
||||
)
|
||||
.unwrap()
|
||||
} else {
|
||||
config
|
||||
};
|
||||
let error = RuntimeKeymap::from_config(&config)
|
||||
.expect_err("explicit simultaneous bindings conflict");
|
||||
assert!(
|
||||
error.contains("find_transcript") && error.contains(action),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Search bindings shared by normal and operator-pending input. Existing explicit Vim
|
||||
//! bindings win over new defaults; explicit search conflicts remain configuration errors.
|
||||
//! Transcript and Vim search defaults yield to explicit bindings; explicit search conflicts
|
||||
//! remain configuration errors.
|
||||
|
||||
use super::bindings::configured_binding_for_action;
|
||||
use super::*;
|
||||
@@ -26,7 +26,51 @@ impl Default for VimSearchKeymap {
|
||||
}
|
||||
|
||||
impl RuntimeKeymap {
|
||||
pub(super) fn configure_vim_search(&mut self, config: &TuiKeymap) -> Result<(), String> {
|
||||
pub(super) fn configure_search(&mut self, config: &TuiKeymap) -> Result<(), String> {
|
||||
for context in [KeymapContext::Global, KeymapContext::Pager] {
|
||||
let setting = match context {
|
||||
KeymapContext::Global => &config.global.find_transcript,
|
||||
_ => &config.pager.find,
|
||||
};
|
||||
if setting.is_some() {
|
||||
continue;
|
||||
}
|
||||
let find_action = bindings::KeymapActionId {
|
||||
context,
|
||||
action: if context == KeymapContext::Global {
|
||||
"find_transcript"
|
||||
} else {
|
||||
"find"
|
||||
},
|
||||
};
|
||||
let conflicts = |action: bindings::KeymapActionId| {
|
||||
action.overlaps(find_action)
|
||||
|| context == KeymapContext::Global && action.context == KeymapContext::Approval
|
||||
};
|
||||
let configured: Vec<_> = runtime_action_bindings(self)
|
||||
.filter(|action| {
|
||||
conflicts(action.id)
|
||||
&& bindings::configured_binding_for_action(config, action.id)
|
||||
.is_some_and(Option::is_some)
|
||||
})
|
||||
.flat_map(|action| action.bindings.iter().copied())
|
||||
.chain(
|
||||
self.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|chord| conflicts(chord.action))
|
||||
.map(|chord| chord.chord.prefix),
|
||||
)
|
||||
.collect();
|
||||
let bindings = match context {
|
||||
KeymapContext::Global => &mut self.app.find_transcript,
|
||||
_ => &mut self.pager.find,
|
||||
};
|
||||
bindings.retain(|binding| {
|
||||
let (code, modifiers) = binding.parts();
|
||||
!configured.is_pressed(KeyEvent::new(code, modifiers))
|
||||
});
|
||||
}
|
||||
let others: Vec<_> = runtime_action_bindings(self)
|
||||
.filter(|binding| {
|
||||
matches!(
|
||||
|
||||
@@ -59,7 +59,6 @@ fn voice_mute_chord_dispatches_only_in_its_active_context() {
|
||||
let runtime = RuntimeKeymap::from_config(&config).unwrap();
|
||||
let action = keymap_action_id("chat", "toggle_voice_mute").unwrap();
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
let now = tokio::time::Instant::now();
|
||||
let prefix = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
|
||||
let completion = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
|
||||
assert_eq!(
|
||||
@@ -67,17 +66,15 @@ fn voice_mute_chord_dispatches_only_in_its_active_context() {
|
||||
prefix,
|
||||
&runtime.chords,
|
||||
KeymapContextSet::new(KeymapContext::Pager),
|
||||
now
|
||||
),
|
||||
KeyChordMatch::PassThrough
|
||||
);
|
||||
let contexts = KeymapContextSet::new(action.context);
|
||||
assert!(matches!(
|
||||
matcher.advance(prefix, &runtime.chords, contexts, now),
|
||||
matcher.advance(prefix, &runtime.chords, contexts),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
let KeyChordMatch::Completed(dispatch) =
|
||||
matcher.advance(completion, &runtime.chords, contexts, now)
|
||||
let KeyChordMatch::Completed(dispatch) = matcher.advance(completion, &runtime.chords, contexts)
|
||||
else {
|
||||
panic!("mute chord completes")
|
||||
};
|
||||
@@ -140,27 +137,24 @@ fn voice_toggle_chord_dispatches_in_chat_without_an_active_voice_session() {
|
||||
let config = toml::from_str::<TuiKeymap>("[chat]\ntoggle_voice = 'f8 v'").unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&config).unwrap();
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
let now = tokio::time::Instant::now();
|
||||
let prefix = KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE);
|
||||
assert_eq!(
|
||||
matcher.advance(
|
||||
prefix,
|
||||
&runtime.chords,
|
||||
KeymapContextSet::new(KeymapContext::Pager),
|
||||
now
|
||||
),
|
||||
KeyChordMatch::PassThrough
|
||||
);
|
||||
let contexts = KeymapContextSet::new(KeymapContext::Chat);
|
||||
assert!(matches!(
|
||||
matcher.advance(prefix, &runtime.chords, contexts, now),
|
||||
matcher.advance(prefix, &runtime.chords, contexts),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
let KeyChordMatch::Completed(dispatch) = matcher.advance(
|
||||
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE),
|
||||
&runtime.chords,
|
||||
contexts,
|
||||
now,
|
||||
) else {
|
||||
panic!("voice toggle chord completes")
|
||||
};
|
||||
|
||||
@@ -1493,7 +1493,7 @@ mod tests {
|
||||
let mut view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default());
|
||||
view.show_delayed_hint_for_test();
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
|
||||
|
||||
let rendered = render_debug(&view, /*width*/ 100);
|
||||
assert!(!rendered.contains("Still waiting?"));
|
||||
|
||||
@@ -86,6 +86,8 @@ impl KeymapActionDescriptor {
|
||||
pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("global", "Global", "open_agents", "Open the shared agent-session overview."),
|
||||
action("global", "Global", "open_transcript", "Open the transcript overlay."),
|
||||
action("global", "Global", "find_transcript", "Find text in the full transcript."),
|
||||
action("global", "Global", "focus_activity", "Focus transcript activity groups to expand or collapse their details."),
|
||||
action("global", "Global", "open_external_editor", "Open the current draft in an external editor."),
|
||||
action("global", "Global", "copy", "Copy the last agent response to the clipboard."),
|
||||
action("global", "Global", "clear_terminal", "Clear the terminal UI."),
|
||||
@@ -204,6 +206,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("pager", "Pager", "jump_bottom", "Jump to the end."),
|
||||
action("pager", "Pager", "close", "Close the pager overlay."),
|
||||
action("pager", "Pager", "close_transcript", "Close the transcript overlay."),
|
||||
action("pager", "Pager", "find", "Find text in the transcript overlay."),
|
||||
action("list", "List", "move_up", "Move list selection up."),
|
||||
action("list", "List", "move_down", "Move list selection down."),
|
||||
action("list", "List", "move_left", "Move horizontally left in list pickers."),
|
||||
@@ -268,6 +271,8 @@ pub(super) fn binding_slot<'a>(
|
||||
match (context, action) {
|
||||
("global", "open_agents") => Some(&mut keymap.global.open_agents),
|
||||
("global", "open_transcript") => Some(&mut keymap.global.open_transcript),
|
||||
("global", "find_transcript") => Some(&mut keymap.global.find_transcript),
|
||||
("global", "focus_activity") => Some(&mut keymap.global.focus_activity),
|
||||
("global", "open_external_editor") => Some(&mut keymap.global.open_external_editor),
|
||||
("global", "copy") => Some(&mut keymap.global.copy),
|
||||
("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal),
|
||||
@@ -386,6 +391,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("pager", "jump_bottom") => Some(&mut keymap.pager.jump_bottom),
|
||||
("pager", "close") => Some(&mut keymap.pager.close),
|
||||
("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript),
|
||||
("pager", "find") => Some(&mut keymap.pager.find),
|
||||
("list", "move_up") => Some(&mut keymap.list.move_up),
|
||||
("list", "move_down") => Some(&mut keymap.list.move_down),
|
||||
("list", "move_left") => Some(&mut keymap.list.move_left),
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ last
|
||||
|
||||
Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · hom
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ recent transcript
|
||||
|
||||
Earlier messages available. Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
--- failed ---
|
||||
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / /
|
||||
@@ -45,4 +45,4 @@ recent transcript
|
||||
|
||||
Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ recent transcript
|
||||
|
||||
Earlier messages available. Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
--- failed ---
|
||||
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / /
|
||||
@@ -45,4 +45,4 @@ recent transcript
|
||||
|
||||
Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::*;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::history_cell::SessionHeaderHistoryCell;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::transcript_view::TranscriptView;
|
||||
use crate::transcript_view::ViewAction;
|
||||
@@ -23,6 +24,7 @@ pub(crate) struct TranscriptOverlay {
|
||||
content_area: Rect,
|
||||
cursor: Option<(u16, u16)>,
|
||||
notice: Option<String>,
|
||||
pub(crate) key_chord_hint: Option<Vec<(String, String)>>,
|
||||
is_done: bool,
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ impl TranscriptOverlay {
|
||||
content_area: Rect::default(),
|
||||
cursor: None,
|
||||
notice: None,
|
||||
key_chord_hint: None,
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
@@ -89,7 +92,12 @@ impl TranscriptOverlay {
|
||||
.map(|column| (status.x + column, status.y));
|
||||
if let Some(notice) = &self.notice {
|
||||
let notice = Line::from(notice.clone()).dim();
|
||||
footer.text = notice.into();
|
||||
if self.view.is_search_active() {
|
||||
footer.text.lines.truncate(/*len*/ 1);
|
||||
footer.text.lines.push(notice);
|
||||
} else {
|
||||
footer.text = notice.into();
|
||||
}
|
||||
}
|
||||
Paragraph::new(footer.text).render(
|
||||
Rect::new(status.x, status.y, status.width, /*height*/ 3).intersection(area),
|
||||
@@ -105,17 +113,25 @@ impl TranscriptOverlay {
|
||||
}
|
||||
self.render_hints(hints, buf);
|
||||
}
|
||||
if let Some(items) = &self.key_chord_hint {
|
||||
let row = Rect::new(hints.x, hints.y, hints.width, 1).intersection(area);
|
||||
Clear.render(row, buf);
|
||||
crate::bottom_pane::footer_hint_items_line(items).render(row, buf);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn draw(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
let mut scanning = false;
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
self.view.prepare_width(frame.area().width);
|
||||
scanning = self.view.advance_search(&self.cells);
|
||||
self.render(frame.area(), frame.buffer);
|
||||
if let Some(cursor) = self.cursor {
|
||||
frame.set_cursor_position(cursor);
|
||||
}
|
||||
})?;
|
||||
let loading = self.view.is_loading_history() && self.motion == MotionMode::Animated;
|
||||
if loading {
|
||||
if scanning || loading {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
}
|
||||
@@ -145,11 +161,14 @@ impl TranscriptOverlay {
|
||||
}
|
||||
self.view.handle_mouse(mouse, &self.cells)
|
||||
}
|
||||
TuiEvent::Paste(_) => None,
|
||||
TuiEvent::Paste(text) => {
|
||||
if self.view.is_search_active() {
|
||||
self.view.end_selection(&self.cells);
|
||||
}
|
||||
self.view.paste_search(&text).then_some(ViewAction::Changed)
|
||||
}
|
||||
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::FocusGained | TuiEvent::Resume => {
|
||||
let dragging =
|
||||
matches!(event, TuiEvent::Draw) && self.view.tick_selection(&self.cells);
|
||||
if dragging {
|
||||
if matches!(event, TuiEvent::Draw) && self.view.tick_selection(&self.cells) {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
}
|
||||
@@ -176,6 +195,18 @@ impl TranscriptOverlay {
|
||||
self.view.is_following()
|
||||
}
|
||||
|
||||
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||
self.view.set_keymap_bindings(keymap);
|
||||
}
|
||||
|
||||
pub(crate) fn begin_search(&mut self) {
|
||||
self.view.begin_search();
|
||||
}
|
||||
|
||||
pub(crate) fn is_search_active(&self) -> bool {
|
||||
self.view.is_search_active()
|
||||
}
|
||||
|
||||
pub(crate) fn owns_interaction_key(&self, key: KeyEvent) -> bool {
|
||||
self.view.owns_interaction_key(key)
|
||||
}
|
||||
@@ -252,6 +283,7 @@ impl TranscriptOverlay {
|
||||
|
||||
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
|
||||
self.cells = cells;
|
||||
self.view.restart_search();
|
||||
self.view.history_loaded(&self.cells, 0..0);
|
||||
self.highlight_cell = self
|
||||
.highlight_cell
|
||||
@@ -326,6 +358,13 @@ impl TranscriptOverlay {
|
||||
if key.kind == KeyEventKind::Release {
|
||||
return None;
|
||||
}
|
||||
if !self.view.is_search_active()
|
||||
&& (!self.view.has_active_interaction() || !self.view.owns_interaction_key(key))
|
||||
&& self.keymap.find.is_pressed(key)
|
||||
{
|
||||
self.view.begin_search();
|
||||
return Some(ViewAction::Changed);
|
||||
}
|
||||
if self.view.has_active_interaction()
|
||||
&& let Some(action) = self.view.handle_key(key, &self.cells)
|
||||
{
|
||||
@@ -338,7 +377,6 @@ impl TranscriptOverlay {
|
||||
if self.navigate(key) {
|
||||
return Some(ViewAction::Changed);
|
||||
}
|
||||
|
||||
self.view.handle_key(key, &self.cells)
|
||||
}
|
||||
|
||||
@@ -411,6 +449,12 @@ impl TranscriptOverlay {
|
||||
first_or_empty(&self.keymap, "close", &self.keymap.close),
|
||||
"close",
|
||||
)];
|
||||
if !self.keymap.find.is_empty() {
|
||||
pairs.push((
|
||||
first_or_empty(&self.keymap, "find", &self.keymap.find),
|
||||
"find",
|
||||
));
|
||||
}
|
||||
pairs.push((vec![key_hint::plain(KeyCode::Esc).into()], "edit previous"));
|
||||
if self.highlight_cell.is_some() {
|
||||
pairs.push((vec![key_hint::plain(KeyCode::Right).into()], "to edit next"));
|
||||
|
||||
@@ -246,6 +246,54 @@ async fn focus_loss_stops_edge_drag_and_preserves_selection_after_focus_returns(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opening_find_retains_the_selected_mutable_revision() -> Result<()> {
|
||||
let cell = history_cell::DynamicToolCallCell::from_item(
|
||||
codex_app_server_protocol::ThreadItem::DynamicToolCall {
|
||||
id: "tool".into(),
|
||||
namespace: None,
|
||||
tool: "inspect".into(),
|
||||
arguments: serde_json::json!({}),
|
||||
status: codex_app_server_protocol::DynamicToolCallStatus::InProgress,
|
||||
content_items: None,
|
||||
success: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(cell.clone())]);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 12,
|
||||
);
|
||||
let mut before = Buffer::empty(area);
|
||||
overlay.render(area, &mut before);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
overlay.handle_event(
|
||||
&mut tui,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
|
||||
)?;
|
||||
cell.mark_interrupted();
|
||||
overlay.handle_event(
|
||||
&mut tui,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
|
||||
)?;
|
||||
assert!(overlay.view.is_search_active());
|
||||
overlay.handle_event(
|
||||
&mut tui,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
)?;
|
||||
let mut after = Buffer::empty(area);
|
||||
overlay.render(area, &mut after);
|
||||
let content = Rect::new(
|
||||
/*x*/ 0, /*y*/ 1, /*width*/ 60, /*height*/ 7,
|
||||
);
|
||||
assert_eq!(
|
||||
buffer_to_text(&after, content),
|
||||
buffer_to_text(&before, content)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn buffer_to_text(buf: &Buffer, area: Rect) -> String {
|
||||
let mut out = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
|
||||
@@ -11,9 +11,6 @@ use crate::color::is_light;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::is_plain_text_key_event;
|
||||
use crate::keymap::ListAction;
|
||||
use crate::keymap::ListKeymap;
|
||||
use crate::keymap::PagerKeymap;
|
||||
use crate::keymap::RuntimeChordKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::legacy_core::config::edit::ConfigEditsBuilder;
|
||||
@@ -346,10 +343,8 @@ struct SessionPickerRunOptions {
|
||||
provider_filter: ProviderFilter,
|
||||
initial_density: SessionListDensity,
|
||||
view_persistence: Option<SessionPickerViewPersistence>,
|
||||
pager_keymap: PagerKeymap,
|
||||
list_keymap: ListKeymap,
|
||||
keymap: RuntimeKeymap,
|
||||
initial_page_mode: PageLoadMode,
|
||||
chord_keymap: Arc<RuntimeChordKeymap>,
|
||||
}
|
||||
|
||||
/// Interactive session picker that lists app-server threads with simple search,
|
||||
@@ -462,14 +457,12 @@ async fn run_resume_picker_with_launch_context(
|
||||
view_persistence: Some(SessionPickerViewPersistence {
|
||||
codex_home: local_settings.codex_home.to_path_buf(),
|
||||
}),
|
||||
pager_keymap: runtime_keymap.pager,
|
||||
list_keymap: runtime_keymap.list,
|
||||
keymap: runtime_keymap,
|
||||
initial_page_mode: if uses_remote_workspace {
|
||||
PageLoadMode::StoreDefault
|
||||
} else {
|
||||
PageLoadMode::StateDbOnly
|
||||
},
|
||||
chord_keymap: runtime_keymap.chords,
|
||||
};
|
||||
run_session_picker_with_loader(
|
||||
tui,
|
||||
@@ -523,14 +516,12 @@ pub async fn run_fork_picker_with_app_server(
|
||||
view_persistence: Some(SessionPickerViewPersistence {
|
||||
codex_home: local_settings.codex_home.to_path_buf(),
|
||||
}),
|
||||
pager_keymap: runtime_keymap.pager,
|
||||
list_keymap: runtime_keymap.list,
|
||||
keymap: runtime_keymap,
|
||||
initial_page_mode: if uses_remote_workspace {
|
||||
PageLoadMode::StoreDefault
|
||||
} else {
|
||||
PageLoadMode::StateDbOnly
|
||||
},
|
||||
chord_keymap: runtime_keymap.chords,
|
||||
};
|
||||
run_session_picker_with_loader(
|
||||
tui,
|
||||
@@ -569,9 +560,7 @@ async fn run_session_picker_with_loader(
|
||||
state.worktrees_enabled = options.worktrees_enabled;
|
||||
state.density = options.initial_density;
|
||||
state.view_persistence = options.view_persistence;
|
||||
state.pager_keymap = options.pager_keymap;
|
||||
state.list_keymap = options.list_keymap;
|
||||
state.chord_keymap = options.chord_keymap;
|
||||
state.keymap = options.keymap;
|
||||
state.launch_context = options.launch_context;
|
||||
state.initial_page_mode = options.initial_page_mode;
|
||||
state.start_initial_load();
|
||||
@@ -877,10 +866,8 @@ struct PickerState {
|
||||
pending_transcript_cancellation: Option<oneshot::Sender<()>>,
|
||||
transcript_loading_frame_shown: bool,
|
||||
overlay: Option<Overlay>,
|
||||
pager_keymap: PagerKeymap,
|
||||
list_keymap: ListKeymap,
|
||||
keymap: RuntimeKeymap,
|
||||
initial_page_mode: PageLoadMode,
|
||||
chord_keymap: Arc<RuntimeChordKeymap>,
|
||||
chord_matcher: crate::keymap::KeyChordMatcher,
|
||||
}
|
||||
|
||||
@@ -1077,10 +1064,8 @@ impl PickerState {
|
||||
pending_transcript_cancellation: None,
|
||||
transcript_loading_frame_shown: false,
|
||||
overlay: None,
|
||||
pager_keymap: RuntimeKeymap::defaults().pager,
|
||||
list_keymap: RuntimeKeymap::defaults().list,
|
||||
keymap: RuntimeKeymap::defaults(),
|
||||
initial_page_mode: PageLoadMode::StoreDefault,
|
||||
chord_keymap: Arc::default(),
|
||||
chord_matcher: crate::keymap::KeyChordMatcher::default(),
|
||||
}
|
||||
}
|
||||
@@ -1091,16 +1076,18 @@ impl PickerState {
|
||||
self.chord_matcher.cancel();
|
||||
return Some(key);
|
||||
}
|
||||
let context = if self.overlay.is_some() {
|
||||
let context = if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.is_search_active())
|
||||
{
|
||||
crate::keymap::KeymapContext::Editor
|
||||
} else if self.overlay.is_some() {
|
||||
crate::keymap::KeymapContext::Pager
|
||||
} else {
|
||||
crate::keymap::KeymapContext::List
|
||||
};
|
||||
match self.chord_matcher.advance(
|
||||
key,
|
||||
&self.chord_keymap,
|
||||
&self.keymap.chords,
|
||||
crate::keymap::KeymapContextSet::new(context),
|
||||
tokio::time::Instant::now(),
|
||||
) {
|
||||
crate::keymap::KeyChordMatch::PassThrough => Some(key),
|
||||
crate::keymap::KeyChordMatch::Completed(dispatch_event) => Some(dispatch_event),
|
||||
@@ -1138,10 +1125,11 @@ impl PickerState {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.overlay = Some(Overlay::new_transcript(
|
||||
cells.clone(),
|
||||
self.pager_keymap.clone(),
|
||||
));
|
||||
let mut overlay = Overlay::new_transcript(cells.clone(), self.keymap.pager.clone());
|
||||
if let Overlay::Transcript(view) = &mut overlay {
|
||||
view.set_keymap_bindings(&self.keymap);
|
||||
}
|
||||
self.overlay = Some(overlay);
|
||||
self.pending_transcript_open = None;
|
||||
self.transcript_loading_frame_shown = false;
|
||||
self.request_frame();
|
||||
@@ -1203,7 +1191,7 @@ impl PickerState {
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit),
|
||||
key if self.list_keymap.cancel.is_pressed(key) => {
|
||||
key if self.keymap.list.cancel.is_pressed(key) => {
|
||||
if let Some(thread_id) = self.pending_transcript_open.take()
|
||||
&& matches!(
|
||||
self.transcript_cells.get(&thread_id),
|
||||
@@ -1228,7 +1216,7 @@ impl PickerState {
|
||||
if self.is_transcript_loading() {
|
||||
return Ok(self.handle_transcript_loading_key(key));
|
||||
}
|
||||
if !self.list_keymap.page_down.is_pressed(key) {
|
||||
if !self.keymap.list.page_down.is_pressed(key) {
|
||||
self.pending_page_down_target = None;
|
||||
}
|
||||
// The session picker is always searchable, so plain text belongs to
|
||||
@@ -1243,7 +1231,7 @@ impl PickerState {
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return Ok(Some(SessionSelection::Exit));
|
||||
}
|
||||
_ if self.list_keymap.cancel.is_pressed(key) => {
|
||||
_ if self.keymap.list.cancel.is_pressed(key) => {
|
||||
if self.query.is_empty() {
|
||||
return Ok(Some(SessionSelection::StartFresh));
|
||||
}
|
||||
@@ -1291,9 +1279,9 @@ impl PickerState {
|
||||
} /* ^O */ => {
|
||||
self.toggle_density().await;
|
||||
}
|
||||
_ if self.list_keymap.accept.is_pressed(key)
|
||||
_ if self.keymap.list.accept.is_pressed(key)
|
||||
&& !matches!(self.archive_state, archive::ArchiveState::Idle) => {}
|
||||
_ if self.list_keymap.accept.is_pressed(key) => {
|
||||
_ if self.keymap.list.accept.is_pressed(key) => {
|
||||
if let Some(row) = self.filtered_rows.get(self.selected) {
|
||||
let path = row.path.clone();
|
||||
if let Some(thread_id) = row.thread_id {
|
||||
@@ -1319,14 +1307,14 @@ impl PickerState {
|
||||
self.request_frame();
|
||||
}
|
||||
}
|
||||
_ if allow_plain_char_navigation && self.list_keymap.move_up.is_pressed(key) => {
|
||||
_ if allow_plain_char_navigation && self.keymap.list.move_up.is_pressed(key) => {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
self.ensure_selected_visible();
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
_ if allow_plain_char_navigation && self.list_keymap.move_down.is_pressed(key) => {
|
||||
_ if allow_plain_char_navigation && self.keymap.list.move_down.is_pressed(key) => {
|
||||
if self.selected + 1 < self.filtered_rows.len() {
|
||||
self.selected += 1;
|
||||
self.ensure_selected_visible();
|
||||
@@ -1334,7 +1322,7 @@ impl PickerState {
|
||||
self.maybe_load_more_for_scroll();
|
||||
self.request_frame();
|
||||
}
|
||||
_ if allow_plain_char_navigation && self.list_keymap.page_up.is_pressed(key) => {
|
||||
_ if allow_plain_char_navigation && self.keymap.list.page_up.is_pressed(key) => {
|
||||
let step = self.view_rows.unwrap_or(10).max(1);
|
||||
if self.selected > 0 {
|
||||
self.selected = self.selected.saturating_sub(step);
|
||||
@@ -1342,20 +1330,20 @@ impl PickerState {
|
||||
self.request_frame();
|
||||
}
|
||||
}
|
||||
_ if allow_plain_char_navigation && self.list_keymap.jump_top.is_pressed(key)
|
||||
_ if allow_plain_char_navigation && self.keymap.list.jump_top.is_pressed(key)
|
||||
&& !self.filtered_rows.is_empty() => {
|
||||
self.selected = 0;
|
||||
self.ensure_selected_visible();
|
||||
self.request_frame();
|
||||
}
|
||||
_ if allow_plain_char_navigation && self.list_keymap.jump_bottom.is_pressed(key)
|
||||
_ if allow_plain_char_navigation && self.keymap.list.jump_bottom.is_pressed(key)
|
||||
&& !self.filtered_rows.is_empty() => {
|
||||
self.selected = self.filtered_rows.len().saturating_sub(1);
|
||||
self.ensure_selected_visible();
|
||||
self.maybe_load_more_for_scroll();
|
||||
self.request_frame();
|
||||
}
|
||||
_ if allow_plain_char_navigation && self.list_keymap.page_down.is_pressed(key)
|
||||
_ if allow_plain_char_navigation && self.keymap.list.page_down.is_pressed(key)
|
||||
&& !self.filtered_rows.is_empty() => {
|
||||
let step = self.view_rows.unwrap_or(10).max(1);
|
||||
let target = self.selected.saturating_add(step);
|
||||
@@ -1384,8 +1372,8 @@ impl PickerState {
|
||||
self.request_frame();
|
||||
}
|
||||
_ if allow_plain_char_navigation
|
||||
&& (self.list_keymap.move_left.is_pressed(key)
|
||||
|| self.list_keymap.move_right.is_pressed(key)) =>
|
||||
&& (self.keymap.list.move_left.is_pressed(key)
|
||||
|| self.keymap.list.move_right.is_pressed(key)) =>
|
||||
{
|
||||
self.change_focused_toolbar_value();
|
||||
self.request_frame();
|
||||
@@ -2449,7 +2437,7 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
|
||||
SessionListDensity::Dense => "comfy",
|
||||
};
|
||||
let mut first_row_hints = Vec::new();
|
||||
if let Some(accept) = state.list_keymap.primary_hint(ListAction::Accept) {
|
||||
if let Some(accept) = state.keymap.list.primary_hint(ListAction::Accept) {
|
||||
first_row_hints.push(PickerFooterHint {
|
||||
key: accept.display_label(),
|
||||
wide_label: action_label.to_string(),
|
||||
@@ -2465,7 +2453,7 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
|
||||
priority: 2,
|
||||
});
|
||||
}
|
||||
if let Some(cancel) = state.list_keymap.primary_hint(ListAction::Cancel) {
|
||||
if let Some(cancel) = state.keymap.list.primary_hint(ListAction::Cancel) {
|
||||
first_row_hints.push(PickerFooterHint {
|
||||
key: cancel.display_label(),
|
||||
wide_label: esc_label.to_string(),
|
||||
@@ -2489,7 +2477,7 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
|
||||
]);
|
||||
let option_keys = [ListAction::MoveLeft, ListAction::MoveRight]
|
||||
.into_iter()
|
||||
.filter_map(|action| state.list_keymap.primary_hint(action))
|
||||
.filter_map(|action| state.keymap.list.primary_hint(action))
|
||||
.map(super::key_hint::ShortcutHint::display_label)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
@@ -2523,7 +2511,7 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
|
||||
];
|
||||
let browse_keys = [ListAction::MoveUp, ListAction::MoveDown]
|
||||
.into_iter()
|
||||
.filter_map(|action| state.list_keymap.primary_hint(action))
|
||||
.filter_map(|action| state.keymap.list.primary_hint(action))
|
||||
.map(super::key_hint::ShortcutHint::display_label)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
@@ -4547,15 +4535,15 @@ mod tests {
|
||||
assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o dense view"));
|
||||
assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+t transcript"));
|
||||
assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+e expand"));
|
||||
state.list_keymap.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('h'))];
|
||||
state.list_keymap.move_right = vec![crate::key_hint::ctrl(KeyCode::Char('l'))];
|
||||
state.keymap.list.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('h'))];
|
||||
state.keymap.list.move_right = vec![crate::key_hint::ctrl(KeyCode::Char('l'))];
|
||||
let remapped_footer = footer_lines_text(&state, /*width*/ 220);
|
||||
assert!(
|
||||
remapped_footer.contains("ctrl+h/ctrl+l change option"),
|
||||
"{remapped_footer}"
|
||||
);
|
||||
state.list_keymap.move_left.clear();
|
||||
state.list_keymap.move_right.clear();
|
||||
state.keymap.list.move_left.clear();
|
||||
state.keymap.list.move_right.clear();
|
||||
assert!(!footer_lines_text(&state, /*width*/ 220).contains("change option"));
|
||||
|
||||
state.density = SessionListDensity::Dense;
|
||||
@@ -5156,6 +5144,14 @@ mod tests {
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.keymap = RuntimeKeymap::from_config(
|
||||
&serde_json::from_value(serde_json::json!({
|
||||
"pager": {"scroll_up": ["ctrl-space x", "ctrl-x ctrl-space"]},
|
||||
"editor": {"move_line_start": ["alt-a", "ctrl-x h"]}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
state.pending_transcript_open = Some(thread_id);
|
||||
let cells: TranscriptCells =
|
||||
vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))];
|
||||
@@ -5180,14 +5176,6 @@ mod tests {
|
||||
|
||||
assert!(matches!(state.overlay, Some(Overlay::Transcript(_))));
|
||||
assert_eq!(state.pending_transcript_open, None);
|
||||
let keymap = RuntimeKeymap::from_config(
|
||||
&serde_json::from_value(serde_json::json!({
|
||||
"pager": {"scroll_up": ["ctrl-space x", "ctrl-x ctrl-space"]}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
state.chord_keymap = keymap.chords;
|
||||
let select = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL);
|
||||
assert_eq!(state.route_key_chord(select), Some(select));
|
||||
assert_eq!(
|
||||
@@ -5197,6 +5185,54 @@ mod tests {
|
||||
assert!(state.chord_matcher.is_pending());
|
||||
assert_eq!(state.route_key_chord(select), Some(select));
|
||||
assert!(!state.chord_matcher.is_pending());
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("tui");
|
||||
state
|
||||
.handle_overlay_event(
|
||||
&mut tui,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.handle_overlay_event(&mut tui, TuiEvent::Paste("tail".into()))
|
||||
.unwrap();
|
||||
for (keys, insertion) in [
|
||||
(
|
||||
vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)],
|
||||
"A",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
|
||||
],
|
||||
"B",
|
||||
),
|
||||
] {
|
||||
for key in keys {
|
||||
if let Some(key) = state.route_key_chord(key) {
|
||||
state
|
||||
.handle_overlay_event(&mut tui, TuiEvent::Key(key))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
state
|
||||
.handle_overlay_event(&mut tui, TuiEvent::Paste(insertion.into()))
|
||||
.unwrap();
|
||||
}
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12,
|
||||
);
|
||||
let mut buffer = ratatui::buffer::Buffer::empty(area);
|
||||
let Some(Overlay::Transcript(overlay)) = &mut state.overlay else {
|
||||
panic!("transcript preview");
|
||||
};
|
||||
overlay.render(area, &mut buffer);
|
||||
let text = buffer
|
||||
.content()
|
||||
.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<String>();
|
||||
assert!(text.contains("BAtail"), "Find query: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -6367,10 +6403,10 @@ session_picker_view = "dense"
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.list_keymap.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))];
|
||||
state.list_keymap.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))];
|
||||
state.list_keymap.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('y'))];
|
||||
state.list_keymap.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))];
|
||||
state.keymap.list.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))];
|
||||
state.keymap.list.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))];
|
||||
state.keymap.list.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('y'))];
|
||||
state.keymap.list.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))];
|
||||
|
||||
let mut items = Vec::new();
|
||||
for idx in 0..20 {
|
||||
@@ -6429,7 +6465,7 @@ session_picker_view = "dense"
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.list_keymap.cancel = vec![crate::key_hint::ctrl(KeyCode::Char('c'))];
|
||||
state.keymap.list.cancel = vec![crate::key_hint::ctrl(KeyCode::Char('c'))];
|
||||
|
||||
let selection = state
|
||||
.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
|
||||
|
||||
@@ -36,8 +36,8 @@ impl PickerState {
|
||||
}
|
||||
|
||||
let archive_key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
|
||||
self.list_keymap.action_for(archive_key).is_none()
|
||||
&& !self.chord_keymap.bindings.iter().any(|binding| {
|
||||
self.keymap.list.action_for(archive_key).is_none()
|
||||
&& !self.keymap.chords.bindings.iter().any(|binding| {
|
||||
binding.action.context == KeymapContext::List
|
||||
&& binding.chord.prefix.is_press(archive_key)
|
||||
})
|
||||
|
||||
@@ -139,7 +139,7 @@ fn archive_failure_preserves_session_and_reports_server_error() {
|
||||
#[test]
|
||||
fn archive_shortcut_preserves_configured_list_binding() {
|
||||
let (mut state, _requests) = archive_picker_state();
|
||||
state.list_keymap.move_up.push(KeyBinding::new(
|
||||
state.keymap.list.move_up.push(KeyBinding::new(
|
||||
KeyCode::Char('\u{0001}'),
|
||||
KeyModifiers::NONE,
|
||||
));
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ before:
|
||||
|
||||
Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
|
||||
after:
|
||||
@@ -28,4 +28,4 @@ after:
|
||||
|
||||
Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
+4
-4
@@ -6,9 +6,9 @@ Keypress Inspector
|
||||
Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes.
|
||||
Tip: Codex can only inspect keys your terminal sends.
|
||||
|
||||
Detected: ctrl+o
|
||||
Config key: ctrl-o
|
||||
Raw event: code=Char('o'), modifiers=ctrl, kind=Press
|
||||
Detected: f8
|
||||
Config key: f8
|
||||
Raw event: code=F(8), modifiers=none, kind=Press
|
||||
|
||||
Assigned actions:
|
||||
- global.copy (Copy) - Copy the last agent response to the clipboard. [Default]
|
||||
- chat.toggle_voice (Toggle Voice) - Start or stop a voice conversation. [Default]
|
||||
|
||||
+2
-2
@@ -4,6 +4,8 @@ expression: snapshot
|
||||
---
|
||||
Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default
|
||||
Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default
|
||||
Find Transcript | f3 | Global find_transcript Find Transcript Find text in the full transcript. f3 Default
|
||||
Focus Activity | f4 | Global focus_activity Focus Activity Focus transcript activity groups to expand or collapse their details. f4 Default
|
||||
Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default
|
||||
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
@@ -12,5 +14,3 @@ Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle ra
|
||||
Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default
|
||||
Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default
|
||||
Previous Permission Mode | unbound | Chat previous_permission_mode Previous Permission Mode Switch to the previous available permission mode. unbound Default
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
147 actions, 1 customized, 5 unbound.
|
||||
150 actions, 1 customized, 5 unbound.
|
||||
|
||||
All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -13,6 +13,8 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
› Global - Open Agents unbound
|
||||
Global Open Transcript ctrl-t
|
||||
Global Find Transcript f3
|
||||
Global Focus Activity f4
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
@@ -33,7 +35,5 @@ expression: "render_picker(params, 120)"
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
Composer History Search Next ctrl-s
|
||||
Editor Insert Newline ctrl-j, ctrl-m, enter, shift-enter, alt-enter
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
148 actions, 0 customized, 6 unbound.
|
||||
151 actions, 0 customized, 6 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -13,6 +13,8 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
› Global - Open Agents unbound
|
||||
Global Open Transcript ctrl-t
|
||||
Global Find Transcript f3
|
||||
Global Focus Activity f4
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
@@ -33,7 +35,5 @@ expression: "render_picker(params, 120)"
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
Composer History Search Next ctrl-s
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+5
-5
@@ -2,20 +2,22 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (147 selectable)
|
||||
tab: All (150 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (5) (5 selectable)
|
||||
tab: App (18 selectable)
|
||||
tab: App (20 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (69 selectable)
|
||||
tab: Navigation (20 selectable)
|
||||
tab: Navigation (21 selectable)
|
||||
tab: Agents (10 selectable)
|
||||
tab: Approval (8 selectable)
|
||||
tab: Debug (1 selectable)
|
||||
Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default
|
||||
Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default
|
||||
Find Transcript | f3 | Global find_transcript Find Transcript Find text in the full transcript. f3 Default
|
||||
Focus Activity | f4 | Global focus_activity Focus Activity Focus transcript activity groups to expand or collapse their details. f4 Default
|
||||
Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default
|
||||
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
@@ -25,5 +27,3 @@ Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default
|
||||
Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default
|
||||
Previous Permission Mode | unbound | Chat previous_permission_mode Previous Permission Mode Switch to the previous available permission mode. unbound Default
|
||||
Next Permission Mode | unbound | Chat next_permission_mode Next Permission Mode Switch to the next available permission mode. unbound Default
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
147 actions, 1 customized, 6 unbound.
|
||||
150 actions, 1 customized, 6 unbound.
|
||||
|
||||
All Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -13,6 +13,8 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
› Global - Open Agents unbound
|
||||
Global * Open Transcript ctrl-t, ctrl-x ctrl-t
|
||||
Global Find Transcript f3
|
||||
Global Focus Activity f4
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
@@ -33,7 +35,5 @@ expression: "render_picker(params, 120)"
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
Composer History Search Next ctrl-s
|
||||
Editor Insert Newline ctrl-j, ctrl-m, enter, shift-enter, alt-enter
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
147 actions, 0 customized, 5 unbound.
|
||||
150 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor ›
|
||||
|
||||
@@ -13,6 +13,8 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
› Global - Open Agents unbound
|
||||
Global Open Transcript ctrl-t
|
||||
Global Find Transcript f3
|
||||
Global Focus Activity f4
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
@@ -33,7 +35,5 @@ expression: "render_picker(params, 78)"
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
Composer History Search Next ctrl-s
|
||||
Editor Insert Newline ctrl-j, ctrl-m, enter, shift-ente…
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
147 actions, 0 customized, 5 unbound.
|
||||
150 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -13,6 +13,8 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
› Global - Open Agents unbound
|
||||
Global Open Transcript ctrl-t
|
||||
Global Find Transcript f3
|
||||
Global Focus Activity f4
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
@@ -33,7 +35,5 @@ expression: "render_picker(params, 120)"
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
Composer History Search Next ctrl-s
|
||||
Editor Insert Newline ctrl-j, ctrl-m, enter, shift-enter, alt-enter
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
147 actions, 0 customized, 5 unbound.
|
||||
150 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -13,6 +13,8 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Global - Open Agents unbound
|
||||
Global Open Transcript ctrl-t
|
||||
Global Find Transcript f3
|
||||
Global Focus Activity f4
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
@@ -33,7 +35,5 @@ expression: "render_picker(params, 120)"
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
Composer History Search Next ctrl-s
|
||||
Editor Insert Newline ctrl-j, ctrl-m, enter, shift-enter, alt-enter
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
assertion_line: 584
|
||||
assertion_line: 583
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ S T A T I C / / / / / / / / / / / / / "
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
assertion_line: 596
|
||||
assertion_line: 595
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ S T A T I C / / / / / "
|
||||
|
||||
@@ -119,7 +119,6 @@ async fn run_startup_hooks_review_app(
|
||||
key_event,
|
||||
&keymap.chords,
|
||||
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List),
|
||||
tokio::time::Instant::now(),
|
||||
) {
|
||||
crate::keymap::KeyChordMatch::PassThrough => key_event,
|
||||
crate::keymap::KeyChordMatch::Completed(dispatch_event) => dispatch_event,
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ before:
|
||||
|
||||
Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
|
||||
after:
|
||||
@@ -64,4 +64,4 @@ next message
|
||||
|
||||
Ctrl+Space select
|
||||
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
|
||||
q close · esc edit previous
|
||||
q close · f3 find · esc edit previous
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
mod activity;
|
||||
mod composer_gap;
|
||||
mod disclosure;
|
||||
mod follow_control;
|
||||
mod footer;
|
||||
mod input;
|
||||
mod layout;
|
||||
mod mutations;
|
||||
mod search;
|
||||
mod selection;
|
||||
mod snapshot;
|
||||
mod text;
|
||||
@@ -30,12 +32,14 @@ use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use layout::LayoutCache;
|
||||
use search::Search;
|
||||
use selection::Selection;
|
||||
use snapshot::ViewSnapshot;
|
||||
use text::TextLayout;
|
||||
|
||||
pub(crate) use input::JumpTarget;
|
||||
pub(crate) use input::ViewAction;
|
||||
pub(crate) use layout::ActivityTranscriptLines;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
enum EntryKey {
|
||||
@@ -71,13 +75,14 @@ struct VisibleRow {
|
||||
row: usize,
|
||||
layout: Arc<TextLayout>,
|
||||
key: EntryKey,
|
||||
activity_ids: Arc<[String]>,
|
||||
}
|
||||
|
||||
/// Shared scrolling and interaction state for compact and detailed transcript presentations.
|
||||
pub(crate) struct TranscriptView {
|
||||
position: Position,
|
||||
follow_control: follow_control::FollowControl,
|
||||
copy_feedback: Option<composer_gap::CopyFeedback>,
|
||||
position: Position,
|
||||
cache: LayoutCache,
|
||||
live: Option<Arc<TextLayout>>,
|
||||
live_separated: Option<Arc<TextLayout>>,
|
||||
@@ -87,6 +92,7 @@ pub(crate) struct TranscriptView {
|
||||
visible: Vec<VisibleRow>,
|
||||
selection: Option<Selection>,
|
||||
held_reading: Option<ViewSnapshot>,
|
||||
search: Search,
|
||||
detailed: bool,
|
||||
mode: HistoryRenderMode,
|
||||
pub(crate) history: TranscriptHistoryState,
|
||||
@@ -96,6 +102,7 @@ pub(crate) struct TranscriptView {
|
||||
tail_visible: bool,
|
||||
last_tail: Option<EntryKey>,
|
||||
last_click: Option<(std::time::Instant, u16, u16, u8)>,
|
||||
disclosure: disclosure::Disclosure,
|
||||
}
|
||||
|
||||
impl Default for TranscriptView {
|
||||
@@ -113,6 +120,7 @@ impl Default for TranscriptView {
|
||||
visible: Vec::new(),
|
||||
selection: None,
|
||||
held_reading: None,
|
||||
search: Search::default(),
|
||||
detailed: false,
|
||||
mode: HistoryRenderMode::Rich,
|
||||
history: TranscriptHistoryState::Idle,
|
||||
@@ -122,15 +130,12 @@ impl Default for TranscriptView {
|
||||
tail_visible: true,
|
||||
last_tail: None,
|
||||
last_click: None,
|
||||
disclosure: disclosure::Disclosure::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptView {
|
||||
pub(crate) fn is_detailed(&self) -> bool {
|
||||
self.detailed
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cells: &[Arc<dyn HistoryCell>]) {
|
||||
self.cache.begin_frame();
|
||||
self.sync_history_tail(cells);
|
||||
@@ -138,9 +143,7 @@ impl TranscriptView {
|
||||
let snapshot = self.snapshot_cells();
|
||||
let cells = snapshot.as_deref().unwrap_or(cells);
|
||||
Clear.render(area, buf);
|
||||
if area.width != self.area.width {
|
||||
self.rewrap_snapshot(area.width);
|
||||
}
|
||||
self.prepare_width(area.width);
|
||||
self.area = area;
|
||||
self.normalize_selection(cells);
|
||||
self.visible.clear();
|
||||
@@ -150,6 +153,7 @@ impl TranscriptView {
|
||||
}
|
||||
let start = self.start(cells);
|
||||
let (mut index, mut row) = start;
|
||||
let mut entry_activity_ids: Option<(usize, Arc<[String]>)> = None;
|
||||
for y in area.top()..area.bottom() {
|
||||
let Some(layout) = self.layout(cells, index) else {
|
||||
break;
|
||||
@@ -167,9 +171,19 @@ impl TranscriptView {
|
||||
break;
|
||||
};
|
||||
let key = self.entry_key(cells, index);
|
||||
let activity_ids = match &entry_activity_ids {
|
||||
Some((previous, ids)) if *previous == index => Arc::clone(ids),
|
||||
_ => {
|
||||
let ids = self.displayed_activity_ids(cells, index);
|
||||
entry_activity_ids = Some((index, Arc::clone(&ids)));
|
||||
ids
|
||||
}
|
||||
};
|
||||
let row_area = Rect::new(area.x, y, area.width, /*height*/ 1);
|
||||
layout.render(row_area, buf, row);
|
||||
if self.highlight == Some(index) && self.selection.is_none() {
|
||||
self.render_disclosure(&activity_ids, &layout, row, row_area, buf);
|
||||
if self.highlight == Some(index) && self.selection.is_none() && !self.search.is_active()
|
||||
{
|
||||
layout.highlight(0..layout.text().len(), row_area, buf, row);
|
||||
}
|
||||
self.visible.push(VisibleRow {
|
||||
@@ -177,6 +191,7 @@ impl TranscriptView {
|
||||
row,
|
||||
layout,
|
||||
key,
|
||||
activity_ids,
|
||||
});
|
||||
row += 1;
|
||||
}
|
||||
@@ -196,6 +211,7 @@ impl TranscriptView {
|
||||
if self.history == TranscriptHistoryState::LoadingBeginning && self.is_following() {
|
||||
self.scroll(current_cells, /*rows*/ 0);
|
||||
}
|
||||
self.render_search(buf);
|
||||
}
|
||||
|
||||
pub(crate) fn sync_live_tail(
|
||||
@@ -209,6 +225,18 @@ impl TranscriptView {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn sync_live_activity_tail(
|
||||
&mut self,
|
||||
width: u16,
|
||||
key: Option<ActiveCellTranscriptKey>,
|
||||
expanded: bool,
|
||||
lines: impl FnOnce(u16) -> Option<ActivityTranscriptLines>,
|
||||
) -> bool {
|
||||
self.sync_live_layout(width, key, |width| {
|
||||
lines(width).map(|lines| layout::activity_layout(lines, width, expanded))
|
||||
})
|
||||
}
|
||||
|
||||
fn sync_live_layout(
|
||||
&mut self,
|
||||
width: u16,
|
||||
@@ -227,11 +255,11 @@ impl TranscriptView {
|
||||
let live = layout(width).map(Arc::new);
|
||||
if self.live.as_ref().map(|layout| layout.text())
|
||||
!= live.as_ref().map(|layout| layout.text())
|
||||
&& !self.is_following()
|
||||
&& revision_changed
|
||||
&& live.is_some()
|
||||
{
|
||||
self.unseen_activity = true;
|
||||
if !self.is_following() && revision_changed && live.is_some() {
|
||||
self.unseen_activity = true;
|
||||
}
|
||||
self.invalidate_live_search();
|
||||
}
|
||||
let changed = self.live.is_some() || live.is_some();
|
||||
self.live = live;
|
||||
@@ -247,17 +275,22 @@ impl TranscriptView {
|
||||
self.cache.clear();
|
||||
self.live_key = None;
|
||||
// Search temporarily expands content without changing either presentation's position.
|
||||
if self.detailed != detailed {
|
||||
if self.detailed != detailed && !self.search.is_active() {
|
||||
let previous = self.position;
|
||||
self.position = self.saved_position.take().unwrap_or(previous);
|
||||
self.saved_position = Some(previous);
|
||||
}
|
||||
self.restart_search();
|
||||
self.detailed = detailed;
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
pub(crate) fn has_active_interaction(&self) -> bool {
|
||||
self.selection.is_some()
|
||||
self.selection.is_some() || self.search.is_active() || self.is_activity_focused()
|
||||
}
|
||||
|
||||
pub(crate) fn is_detailed(&self) -> bool {
|
||||
self.detailed
|
||||
}
|
||||
|
||||
pub(crate) fn is_following(&self) -> bool {
|
||||
@@ -278,11 +311,13 @@ impl TranscriptView {
|
||||
}
|
||||
|
||||
pub(crate) fn jump_to_latest(&mut self) {
|
||||
self.cancel_search();
|
||||
self.cancel_beginning();
|
||||
self.position = Position::Latest;
|
||||
self.selection = None;
|
||||
self.release_live_reading();
|
||||
self.unseen_activity = false;
|
||||
self.disclosure.focused = None;
|
||||
}
|
||||
|
||||
pub(crate) fn scroll(&mut self, cells: &[Arc<dyn HistoryCell>], rows: isize) {
|
||||
@@ -373,7 +408,8 @@ impl TranscriptView {
|
||||
}
|
||||
|
||||
pub(crate) fn needs_history(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
|
||||
self.near_start(cells)
|
||||
self.search.needs_history(self.history)
|
||||
|| (!self.search.is_active() && self.near_start(cells))
|
||||
}
|
||||
|
||||
pub(crate) fn near_start(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,52 @@ fn beginning_wait_keeps_live_content_through_new_output_prepend_and_resize() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_interaction_cancels_pending_beginning_without_losing_received_history() {
|
||||
for key in [
|
||||
None,
|
||||
Some(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)),
|
||||
Some(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
|
||||
Some(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
|
||||
] {
|
||||
let mut cells = (0..20)
|
||||
.map(|index| cell(format!("line {index}")))
|
||||
.collect::<Vec<_>>();
|
||||
let mut view = TranscriptView {
|
||||
history: TranscriptHistoryState::Partial,
|
||||
..TranscriptView::default()
|
||||
};
|
||||
render(&mut view, &cells, /*width*/ 24, /*height*/ 3);
|
||||
view.jump_to_beginning(&cells);
|
||||
match key {
|
||||
Some(key) if key.code == KeyCode::F(3) => view.begin_search(),
|
||||
Some(key) => {
|
||||
view.handle_key(key, &cells);
|
||||
}
|
||||
None => {
|
||||
view.handle_mouse(
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::ScrollUp,
|
||||
column: 0,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
&cells,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(view.history, TranscriptHistoryState::LoadingOlder);
|
||||
let before = render(&mut view, &cells, /*width*/ 24, /*height*/ 3);
|
||||
cells.insert(/*index*/ 0, cell("received older history"));
|
||||
view.history_loaded(&cells, 0..1);
|
||||
view.history = TranscriptHistoryState::Complete;
|
||||
assert_eq!(
|
||||
render(&mut view, &cells, /*width*/ 24, /*height*/ 3),
|
||||
before
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_to_latest_cancels_beginning_intent_while_retaining_the_loaded_page() {
|
||||
for (beginning, latest) in [
|
||||
@@ -518,6 +564,29 @@ fn mutable_history_formats_once_per_frame_and_refreshes_the_next_frame() {
|
||||
version 2 row 6
|
||||
version 2 row 7
|
||||
");
|
||||
view.begin_search();
|
||||
view.paste_search("version 2");
|
||||
assert!(!view.advance_search(&cells));
|
||||
let matched = text(&render(
|
||||
&mut view, &cells, /*width*/ 24, /*height*/ 5,
|
||||
));
|
||||
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
|
||||
version.store(/*val*/ 3, Ordering::Relaxed);
|
||||
view.end_selection(&cells);
|
||||
assert_eq!(
|
||||
text(&render(
|
||||
&mut view, &cells, /*width*/ 24, /*height*/ 5
|
||||
)),
|
||||
matched
|
||||
);
|
||||
view.handle_key(KeyCode::Enter.into(), &cells);
|
||||
assert!(!view.advance_search(&cells));
|
||||
assert!(
|
||||
text(&render(
|
||||
&mut view, &cells, /*width*/ 24, /*height*/ 5
|
||||
))
|
||||
.contains("version 3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -575,6 +644,15 @@ fn live_reader_survives_commit_new_tail_resize_and_selection_then_rejoins_latest
|
||||
);
|
||||
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
|
||||
assert_eq!(view.selected_text(&cells), Some("live two\n".to_string()));
|
||||
view.begin_search();
|
||||
assert!(!view.advance_search(&cells));
|
||||
view.handle_key(KeyCode::Esc.into(), &cells);
|
||||
assert_eq!(
|
||||
text(&render(
|
||||
&mut view, &cells, /*width*/ 20, /*height*/ 2
|
||||
)),
|
||||
before
|
||||
);
|
||||
view.jump_to_latest();
|
||||
assert!(view.snapshot().is_none());
|
||||
insta::assert_snapshot!(text(&render(&mut view, &cells, /*width*/ 20, /*height*/ 2)), @"
|
||||
|
||||
Reference in New Issue
Block a user