feat(tui): add transcript search

This commit is contained in:
Felipe Coury
2026-06-01 11:00:20 -03:00
parent edc9effc01
commit f2f5767c0c
11 changed files with 869 additions and 116 deletions
+10 -4
View File
@@ -315,6 +315,8 @@ pub struct TuiVimTextObjectKeymap {
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct TuiPagerKeymap {
/// Start transcript search.
pub start_search: Option<KeybindingsSpec>,
/// Scroll up by one row.
pub scroll_up: Option<KeybindingsSpec>,
/// Scroll down by one row.
@@ -331,14 +333,18 @@ pub struct TuiPagerKeymap {
pub jump_top: Option<KeybindingsSpec>,
/// Jump to the end.
pub jump_bottom: Option<KeybindingsSpec>,
/// Move to the previous user prompt in transcript search mode.
pub previous_user_prompt: Option<KeybindingsSpec>,
/// Move to the next user prompt in transcript search mode.
pub next_user_prompt: Option<KeybindingsSpec>,
/// Move to the next transcript search match.
pub next_search_match: Option<KeybindingsSpec>,
/// Move to the previous transcript search match.
pub previous_search_match: Option<KeybindingsSpec>,
/// Close the pager overlay.
pub close: Option<KeybindingsSpec>,
/// Close the transcript overlay via its dedicated toggle key.
pub close_transcript: Option<KeybindingsSpec>,
/// Jump to the previous user prompt in the transcript overlay.
pub previous_user_prompt: Option<KeybindingsSpec>,
/// Jump to the next user prompt in the transcript overlay.
pub next_user_prompt: Option<KeybindingsSpec>,
}
/// List selection context keybindings for popup-style selectable lists.
+34 -4
View File
@@ -2842,12 +2842,15 @@
"half_page_up": null,
"jump_bottom": null,
"jump_top": null,
"next_search_match": null,
"next_user_prompt": null,
"page_down": null,
"page_up": null,
"previous_search_match": null,
"previous_user_prompt": null,
"scroll_down": null,
"scroll_up": null
"scroll_up": null,
"start_search": null
},
"vim_normal": {
"append_after_cursor": null,
@@ -3528,12 +3531,15 @@
"half_page_up": null,
"jump_bottom": null,
"jump_top": null,
"next_search_match": null,
"next_user_prompt": null,
"page_down": null,
"page_up": null,
"previous_search_match": null,
"previous_user_prompt": null,
"scroll_down": null,
"scroll_up": null
"scroll_up": null,
"start_search": null
}
},
"vim_normal": {
@@ -3753,13 +3759,21 @@
],
"description": "Jump to the beginning."
},
"next_search_match": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move to the next transcript search match."
},
"next_user_prompt": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Jump to the next user prompt in the transcript overlay."
"description": "Move to the next user prompt in transcript search mode."
},
"page_down": {
"allOf": [
@@ -3777,13 +3791,21 @@
],
"description": "Scroll up by one page."
},
"previous_search_match": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move to the previous transcript search match."
},
"previous_user_prompt": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Jump to the previous user prompt in the transcript overlay."
"description": "Move to the previous user prompt in transcript search mode."
},
"scroll_down": {
"allOf": [
@@ -3800,6 +3822,14 @@
}
],
"description": "Scroll up by one row."
},
"start_search": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Start transcript search."
}
},
"type": "object"
+29
View File
@@ -114,6 +114,24 @@ impl App {
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
if let Some(Overlay::Transcript(transcript)) = &mut self.overlay {
let key_event = match event {
TuiEvent::Key(key_event) => Some(key_event),
_ => None,
};
if transcript.search_is_active() {
self.overlay_forward_event(tui, event)?;
return Ok(true);
}
if let Some(key_event) = key_event
&& transcript.is_start_search_key(key_event)
{
self.cancel_overlay_backtrack_preview();
self.overlay_forward_event(tui, TuiEvent::Key(key_event))?;
return Ok(true);
}
}
if self.backtrack.overlay_preview_active {
match event {
TuiEvent::Key(KeyEvent {
@@ -591,6 +609,17 @@ impl App {
self.chat_widget.clear_esc_backtrack_hint();
}
fn cancel_overlay_backtrack_preview(&mut self) {
self.backtrack.overlay_preview_active = false;
self.backtrack.primed = false;
self.backtrack.base_id = None;
self.backtrack.nth_user_message = usize::MAX;
if let Some(Overlay::Transcript(transcript)) = &mut self.overlay {
transcript.set_highlight_cell(/*cell*/ None);
}
self.chat_widget.clear_esc_backtrack_hint();
}
pub(crate) fn apply_backtrack_selection(
&mut self,
tui: &mut tui::Tui,
+59 -17
View File
@@ -211,6 +211,7 @@ pub(crate) struct VimTextObjectKeymap {
/// Pager/overlay keybindings for transcript and static help views.
#[derive(Clone, Debug)]
pub(crate) struct PagerKeymap {
pub(crate) start_search: Vec<KeyBinding>,
pub(crate) scroll_up: Vec<KeyBinding>,
pub(crate) scroll_down: Vec<KeyBinding>,
pub(crate) page_up: Vec<KeyBinding>,
@@ -219,10 +220,12 @@ pub(crate) struct PagerKeymap {
pub(crate) half_page_down: Vec<KeyBinding>,
pub(crate) jump_top: Vec<KeyBinding>,
pub(crate) jump_bottom: Vec<KeyBinding>,
pub(crate) close: Vec<KeyBinding>,
pub(crate) close_transcript: Vec<KeyBinding>,
pub(crate) previous_user_prompt: Vec<KeyBinding>,
pub(crate) next_user_prompt: Vec<KeyBinding>,
pub(crate) next_search_match: Vec<KeyBinding>,
pub(crate) previous_search_match: Vec<KeyBinding>,
pub(crate) close: Vec<KeyBinding>,
pub(crate) close_transcript: Vec<KeyBinding>,
}
/// Generic list picker keybindings shared across popup list views.
@@ -741,6 +744,7 @@ impl RuntimeKeymap {
};
let pager = PagerKeymap {
start_search: resolve_local!(keymap, defaults, pager, start_search),
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
page_up: resolve_local!(keymap, defaults, pager, page_up),
@@ -749,10 +753,12 @@ impl RuntimeKeymap {
half_page_down: resolve_local!(keymap, defaults, pager, half_page_down),
jump_top: resolve_local!(keymap, defaults, pager, jump_top),
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),
previous_user_prompt: resolve_local!(keymap, defaults, pager, previous_user_prompt),
next_user_prompt: resolve_local!(keymap, defaults, pager, next_user_prompt),
next_search_match: resolve_local!(keymap, defaults, pager, next_search_match),
previous_search_match: resolve_local!(keymap, defaults, pager, previous_search_match),
close: resolve_local!(keymap, defaults, pager, close),
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
};
let approval = ApprovalKeymap {
@@ -1073,6 +1079,7 @@ impl RuntimeKeymap {
cancel: default_bindings![plain(KeyCode::Esc)],
},
pager: PagerKeymap {
start_search: default_bindings![plain(KeyCode::Char('/'))],
scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))],
scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))],
page_up: default_bindings![
@@ -1089,10 +1096,18 @@ impl RuntimeKeymap {
half_page_down: default_bindings![ctrl(KeyCode::Char('d'))],
jump_top: default_bindings![plain(KeyCode::Home)],
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'))],
previous_user_prompt: default_bindings![plain(KeyCode::Left), alt(KeyCode::Up)],
next_user_prompt: default_bindings![plain(KeyCode::Right), alt(KeyCode::Down)],
next_search_match: default_bindings![
plain(KeyCode::Enter),
plain(KeyCode::Char('n'))
],
previous_search_match: default_bindings![
shift(KeyCode::Char('n')),
plain(KeyCode::Char('N'))
],
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
},
list: ListKeymap {
move_up: default_bindings![
@@ -1567,9 +1582,10 @@ impl RuntimeKeymap {
],
)?;
validate_no_reserved(
validate_unique(
"pager",
[
("start_search", self.pager.start_search.as_slice()),
("scroll_up", self.pager.scroll_up.as_slice()),
("scroll_down", self.pager.scroll_down.as_slice()),
("page_up", self.pager.page_up.as_slice()),
@@ -1585,6 +1601,38 @@ impl RuntimeKeymap {
self.pager.previous_user_prompt.as_slice(),
),
("next_user_prompt", self.pager.next_user_prompt.as_slice()),
("next_search_match", self.pager.next_search_match.as_slice()),
(
"previous_search_match",
self.pager.previous_search_match.as_slice(),
),
],
)?;
validate_no_reserved(
"pager",
[
("start_search", self.pager.start_search.as_slice()),
("scroll_up", self.pager.scroll_up.as_slice()),
("scroll_down", self.pager.scroll_down.as_slice()),
("page_up", self.pager.page_up.as_slice()),
("page_down", self.pager.page_down.as_slice()),
("half_page_up", self.pager.half_page_up.as_slice()),
("half_page_down", self.pager.half_page_down.as_slice()),
("jump_top", self.pager.jump_top.as_slice()),
("jump_bottom", self.pager.jump_bottom.as_slice()),
("close", self.pager.close.as_slice()),
("close_transcript", self.pager.close_transcript.as_slice()),
(
"previous_user_prompt",
self.pager.previous_user_prompt.as_slice(),
),
("next_user_prompt", self.pager.next_user_prompt.as_slice()),
("next_search_match", self.pager.next_search_match.as_slice()),
(
"previous_search_match",
self.pager.previous_search_match.as_slice(),
),
],
TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
[],
@@ -1798,16 +1846,10 @@ const MAIN_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
),
];
const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
(
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Esc),
),
(
"fixed.transcript_confirm_edit",
key_hint::plain(KeyCode::Enter),
),
];
const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[(
"fixed.transcript_edit_previous",
key_hint::plain(KeyCode::Esc),
)];
/// Resolve one action with context -> global -> default precedence.
///
+15
View File
@@ -167,6 +167,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."),
action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."),
action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."),
action("pager", "Pager", "start_search", "Start transcript search."),
action("pager", "Pager", "scroll_up", "Scroll up by one row."),
action("pager", "Pager", "scroll_down", "Scroll down by one row."),
action("pager", "Pager", "page_up", "Scroll up by one page."),
@@ -175,6 +176,10 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("pager", "Pager", "half_page_down", "Scroll down by half a page."),
action("pager", "Pager", "jump_top", "Jump to the beginning."),
action("pager", "Pager", "jump_bottom", "Jump to the end."),
action("pager", "Pager", "previous_user_prompt", "Move to the previous user prompt in transcript search mode."),
action("pager", "Pager", "next_user_prompt", "Move to the next user prompt in transcript search mode."),
action("pager", "Pager", "next_search_match", "Move to the next transcript search match."),
action("pager", "Pager", "previous_search_match", "Move to the previous transcript search match."),
action("pager", "Pager", "close", "Close the pager overlay."),
action("pager", "Pager", "close_transcript", "Close the transcript overlay."),
action("list", "List", "move_up", "Move list selection up."),
@@ -310,6 +315,7 @@ pub(super) fn binding_slot<'a>(
("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote),
("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick),
("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel),
("pager", "start_search") => Some(&mut keymap.pager.start_search),
("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up),
("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down),
("pager", "page_up") => Some(&mut keymap.pager.page_up),
@@ -318,6 +324,10 @@ pub(super) fn binding_slot<'a>(
("pager", "half_page_down") => Some(&mut keymap.pager.half_page_down),
("pager", "jump_top") => Some(&mut keymap.pager.jump_top),
("pager", "jump_bottom") => Some(&mut keymap.pager.jump_bottom),
("pager", "previous_user_prompt") => Some(&mut keymap.pager.previous_user_prompt),
("pager", "next_user_prompt") => Some(&mut keymap.pager.next_user_prompt),
("pager", "next_search_match") => Some(&mut keymap.pager.next_search_match),
("pager", "previous_search_match") => Some(&mut keymap.pager.previous_search_match),
("pager", "close") => Some(&mut keymap.pager.close),
("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript),
("list", "move_up") => Some(&mut keymap.list.move_up),
@@ -435,6 +445,7 @@ pub(super) fn bindings_for_action<'a>(
("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()),
("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()),
("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()),
("pager", "start_search") => Some(runtime_keymap.pager.start_search.as_slice()),
("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()),
("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()),
("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()),
@@ -443,6 +454,10 @@ pub(super) fn bindings_for_action<'a>(
("pager", "half_page_down") => Some(runtime_keymap.pager.half_page_down.as_slice()),
("pager", "jump_top") => Some(runtime_keymap.pager.jump_top.as_slice()),
("pager", "jump_bottom") => Some(runtime_keymap.pager.jump_bottom.as_slice()),
("pager", "previous_user_prompt") => Some(runtime_keymap.pager.previous_user_prompt.as_slice()),
("pager", "next_user_prompt") => Some(runtime_keymap.pager.next_user_prompt.as_slice()),
("pager", "next_search_match") => Some(runtime_keymap.pager.next_search_match.as_slice()),
("pager", "previous_search_match") => Some(runtime_keymap.pager.previous_search_match.as_slice()),
("pager", "close") => Some(runtime_keymap.pager.close.as_slice()),
("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()),
("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()),
File diff suppressed because it is too large Load Diff
@@ -12,4 +12,4 @@ expression: snapshot
2 +world
─────────────────────────────────────────────────────────────────── 0 / 0 · 0% ─
↑/↓ scroll ←/→ prompts pgup/pgdn page home/end jump
q quit ctrl + o copy ⌥ + r raw esc/← prev
q quit ctrl + o copy ⌥ + r raw esc/← prev / search
@@ -8,4 +8,4 @@ expression: "render_snapshot(&mut overlay, Rect::new(0, 0, 80, 8),)"
───────────────────────────────────────────────────────────────── 1 / 1 · 100% ─
↑/↓ scroll ←/→ prompts pgup/pgdn page … Copied selected turn to clipboard
q quit ctrl + o copy ⌥ + r raw esc/← prev
q quit ctrl + o copy ⌥ + r raw esc/← prev / search
@@ -10,5 +10,5 @@ expression: term.backend()
"~ "
"───────────────────────── 0 / 0 · 100% ─"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← "
" q ctrl + o ⌥ + r esc/← / "
" "
@@ -0,0 +1,14 @@
---
source: tui/src/pager_overlay.rs
expression: term.backend()
---
" Transcript · 1 prompt · 100% ────────────────────"
"Search: needle 1/1"
"› find this prompt "
" "
" "
"• assistant needle result "
"──────────────────────────────────────────────────"
" ↑/↓ ←/→ pgup/pgdn home/end "
" ctrl + t esc enter shift + n / "
" "
@@ -10,5 +10,5 @@ expression: term.backend()
"gamma "
"───────────────────────── 0 / 0 · 100% ─"
" ↑/↓ ←/→ pgup/pgdn home/end "
" q ctrl + o ⌥ + r esc/← "
" q ctrl + o ⌥ + r esc/← / "
" "