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

## What changed

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

## Testing

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

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