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
+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"] {