Refactor shared TUI input routing (#42199)

## What changed

- Centralize app-level keymap actions in a shared dispatcher, including raw-output and external-editor chords.
- Move read-only transcript input handling into its own module while preserving prompt backtracking and older-history loading.
- Add regression coverage for transcript draft isolation, backtrack selection, global chords, and cached history when a thread is unavailable.

GitOrigin-RevId: a1465942d1ea6c57c2f54e5e313025aceb9f8510
This commit is contained in:
Benjamin Carlsson
2026-09-02 03:50:43 +00:00
committed by copyberry
parent 798833fe97
commit 0b509e930e
8 changed files with 327 additions and 145 deletions

View File

@@ -308,42 +308,11 @@ impl App {
}
let app_keymap_shortcuts_available = self.app_keymap_shortcuts_available();
let side_toggle_bindings = &self.keymap.app.toggle_side_conversation;
if app_keymap_shortcuts_available
&& (side_toggle_bindings.is_pressed(key_event)
|| side_toggle_bindings.contains(&crate::key_hint::ctrl(KeyCode::Char('/')))
&& crate::key_hint::ctrl(KeyCode::Char('7')).is_press(key_event))
&& self
.handle_shared_app_keymap_action(tui, app_server, key_event)
.await
{
if let Err(err) = self.toggle_side_conversation(tui, app_server).await {
self.chat_widget
.add_error_message(format!("Failed to switch side conversation: {err}"));
}
return;
}
if app_keymap_shortcuts_available && self.keymap.app.toggle_vim_mode.is_pressed(key_event) {
self.chat_widget.toggle_vim_mode_and_notify();
return;
}
if app_keymap_shortcuts_available
&& self.keymap.app.toggle_fast_mode.is_pressed(key_event)
&& self.chat_widget.can_toggle_fast_mode_from_keybinding()
{
self.chat_widget.toggle_fast_mode_from_ui();
return;
}
if app_keymap_shortcuts_available && self.keymap.app.toggle_raw_output.is_pressed(key_event)
{
let enabled = !self.chat_widget.raw_output_mode();
self.apply_raw_output_mode(tui, enabled, /*notify*/ false);
return;
}
if app_keymap_shortcuts_available && self.keymap.app.open_agents.is_pressed(key_event) {
self.open_agents_overview(app_server);
return;
}
@@ -356,27 +325,7 @@ impl App {
return;
}
if app_keymap_shortcuts_available
&& self.keymap.app.open_external_editor.is_pressed(key_event)
{
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
if self.overlay.is_none()
&& self.chat_widget.can_launch_external_editor()
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
{
self.request_external_editor_launch(tui);
}
return;
}
if !self.chat_widget.has_active_view()
&& self
.current_displayed_thread_id()
.is_some_and(|id| self.thread_unavailable(id))
&& !(key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c' | 'd')))
{
if self.should_handle_unavailable_thread_key(key_event) {
self.chat_widget.handle_disconnected_key(key_event);
return;
}
@@ -399,22 +348,6 @@ impl App {
}
match key_event {
_ if app_keymap_shortcuts_available
&& self.keymap.app.clear_terminal.is_pressed(key_event) =>
{
if !self.chat_widget.can_run_ctrl_l_clear_now() {
return;
}
if let Err(err) = self.clear_terminal_ui(tui, /*redraw_header*/ false) {
tracing::warn!(error = %err, "failed to clear terminal UI");
self.chat_widget
.add_error_message(format!("Failed to clear terminal UI: {err}"));
} else {
self.reset_app_ui_state_after_clear();
self.queue_clear_ui_header(tui);
tui.frame_requester().schedule_frame();
}
}
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
KeyEvent {
code: KeyCode::Enter,
@@ -447,6 +380,89 @@ impl App {
};
}
pub(crate) async fn handle_shared_app_keymap_action(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
key_event: KeyEvent,
) -> bool {
let side_toggle_bindings = &self.keymap.app.toggle_side_conversation;
if side_toggle_bindings.is_pressed(key_event)
|| side_toggle_bindings.contains(&crate::key_hint::ctrl(KeyCode::Char('/')))
&& crate::key_hint::ctrl(KeyCode::Char('7')).is_press(key_event)
{
if let Err(err) = self.toggle_side_conversation(tui, app_server).await {
self.chat_widget
.add_error_message(format!("Failed to switch side conversation: {err}"));
}
return true;
}
if self.keymap.app.toggle_vim_mode.is_pressed(key_event) {
self.chat_widget.toggle_vim_mode_and_notify();
return true;
}
if self.keymap.app.toggle_fast_mode.is_pressed(key_event)
&& self.chat_widget.can_toggle_fast_mode_from_keybinding()
{
self.chat_widget.toggle_fast_mode_from_ui();
return true;
}
if self.keymap.app.toggle_raw_output.is_pressed(key_event) {
let enabled = !self.chat_widget.raw_output_mode();
self.apply_raw_output_mode(tui, enabled, /*notify*/ false);
return true;
}
if self.keymap.app.open_agents.is_pressed(key_event) {
self.open_agents_overview(app_server);
return true;
}
if self.keymap.app.open_external_editor.is_pressed(key_event) {
if self.overlay.is_none()
&& self.chat_widget.can_launch_external_editor()
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
{
self.request_external_editor_launch(tui);
}
return true;
}
if self.keymap.app.clear_terminal.is_pressed(key_event) {
// Leave cached history intact and let the unavailable-thread input path handle this key.
if self.should_handle_unavailable_thread_key(key_event) {
return false;
}
if !self.chat_widget.can_run_ctrl_l_clear_now() {
return true;
}
if let Err(err) = self.clear_terminal_ui(tui, /*redraw_header*/ false) {
tracing::warn!(error = %err, "failed to clear terminal UI");
self.chat_widget
.add_error_message(format!("Failed to clear terminal UI: {err}"));
} else {
self.reset_app_ui_state_after_clear();
self.queue_clear_ui_header(tui);
tui.frame_requester().schedule_frame();
}
return true;
}
false
}
fn should_handle_unavailable_thread_key(&self, key_event: KeyEvent) -> bool {
!self.chat_widget.has_active_view()
&& self
.current_displayed_thread_id()
.is_some_and(|id| self.thread_unavailable(id))
&& !(key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c' | 'd')))
}
pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool {
!self.chat_widget.side_conversation_active()
&& self.chat_widget.is_normal_backtrack_mode()

View File

@@ -38,6 +38,8 @@ mod startup;
mod stream_animation_tests;
#[path = "tests/thread_usage.rs"]
mod thread_usage;
#[path = "tests/transcript_composer.rs"]
mod transcript_composer;
#[path = "tests/turn_submission.rs"]
mod turn_submission;

View File

@@ -115,6 +115,30 @@ async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<(
Ok(())
}
#[tokio::test]
async fn completed_global_chords_toggle_output_and_request_external_editor() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
let config = toml::from_str(
"[global]\ntoggle_raw_output = [\"ctrl-x r\"]\nopen_external_editor = [\"ctrl-x e\"]",
)?;
app.keymap = RuntimeKeymap::from_config(&config).expect("valid global chords");
app.chat_widget.apply_keymap_update(config, &app.keymap);
for key in [ctrl('x'), KeyCode::Char('r').into()] {
press(&mut app, &mut tui, &mut app_server, key).await?;
}
assert!(app.chat_widget.raw_output_mode());
for key in [ctrl('x'), KeyCode::Char('e').into()] {
press(&mut app, &mut tui, &mut app_server, key).await?;
}
assert_eq!(
app.chat_widget.external_editor_state(),
super::ExternalEditorState::Requested
);
Ok(())
}
#[tokio::test]
async fn wrong_second_stroke_passes_through_but_escape_is_consumed() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;

View File

@@ -390,7 +390,19 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
assert!(history.contains("Cached previous conversation"));
let content = &history[history.find("Cached previous conversation").unwrap()..];
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)),
)
.await?;
let preserved_history =
drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
assert_eq!(preserved_history, history);
let content = &preserved_history[preserved_history
.find("Cached previous conversation")
.unwrap()..];
assert_snapshot!(
"reconnected_unavailable_conversation",
format!(

View File

@@ -0,0 +1,15 @@
---
source: tui/src/app/tests/transcript_composer.rs
expression: text
---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
first
second
───────────────────────────────────────────────────────────────────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev

View File

@@ -0,0 +1,109 @@
//! Regression coverage for transcript viewer input and prompt selection.
//!
//! The default-off feature must leave the existing viewer and its draft intact.
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
async fn press_key(
app: &mut App,
tui: &mut crate::tui::Tui,
app_server: &mut AppServerSession,
code: KeyCode,
) -> Result<()> {
app.handle_tui_event(
tui,
app_server,
TuiEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
)
.await?;
Ok(())
}
#[tokio::test]
async fn transcript_flag_off_preserves_viewer_and_backtracking() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let keymap_config = toml::from_str("[composer]\nsubmit = [\"ctrl-x enter\"]")?;
app.keymap =
crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid composer chord");
app.chat_widget
.apply_keymap_update(keymap_config, &app.keymap);
let mut app_server = start_config_write_test_app_server(&app).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf());
app.chat_widget.handle_thread_session(session);
app.transcript_cells = ["first", "second"]
.map(|message| {
Arc::new(UserHistoryCell {
message: message.into(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>
})
.to_vec();
app.chat_widget
.apply_external_edit("preserved draft".into());
app.open_transcript_overlay(&mut tui);
for event in [
TuiEvent::Paste("not composer input".into()),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
] {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
}
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"preserved draft"
);
let chord_prefix = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(chord_prefix))
.await?;
assert!(!app.key_chord_matcher.is_pending());
assert!(!app.backtrack.overlay_preview_active);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 100, /*height*/ 12,
);
let mut buffer = ratatui::buffer::Buffer::empty(area);
let Some(Overlay::Transcript(overlay)) = &mut app.overlay else {
panic!("viewer closed")
};
overlay.render(area, &mut buffer);
let text = buffer
.content()
.chunks(usize::from(area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("transcript_flag_off_viewer", text);
for (key, selected) in [
(KeyCode::Esc, 1),
(KeyCode::Esc, 0),
(KeyCode::Right, 1),
(KeyCode::Right, 1),
] {
press_key(&mut app, &mut tui, &mut app_server, key).await?;
assert_eq!(app.backtrack.nth_user_message, selected);
}
press_key(&mut app, &mut tui, &mut app_server, KeyCode::Enter).await?;
assert!(app.overlay.is_none());
assert!(
std::iter::from_fn(|| app_event_rx.try_recv().ok()).any(|event| matches!(
event,
AppEvent::ForkSessionForPromptEdit {
nth_user_message: 1,
..
}
))
);
Ok(())
}

View File

@@ -21,6 +21,8 @@
//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects
//! both committed history and in-flight activity without changing flush or coalescing behavior.
mod legacy_input;
use std::any::TypeId;
use std::sync::Arc;
@@ -93,79 +95,7 @@ impl App {
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<bool> {
if let TuiEvent::Key(key_event) = &event
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_ref()
&& (overlay.should_load_older(*key_event)
|| (self.backtrack.overlay_preview_active
&& self.backtrack.nth_user_message == 0
&& matches!(key_event.code, KeyCode::Esc | KeyCode::Left)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)))
&& let Some(thread_id) = self.chat_widget.thread_id()
&& app_server.has_older_history(thread_id)
&& self.request_older_history_page(app_server, thread_id)
{
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
overlay.set_history_state(if overlay.should_load_from_start(*key_event) {
TranscriptHistoryState::LoadingBeginning
} else {
TranscriptHistoryState::LoadingOlder
});
}
tui.frame_requester().schedule_frame();
}
if self.backtrack.overlay_preview_active {
match event {
TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Left,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Right,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack_forward(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
}) => {
self.overlay_confirm_backtrack(tui);
Ok(true)
}
_ => {
self.overlay_forward_event(tui, event)?;
Ok(true)
}
}
} else if let TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) = event
{
// First Esc in transcript overlay: begin backtrack preview at latest user message.
self.begin_overlay_backtrack_preview(tui);
Ok(true)
} else {
// Not in backtrack mode: forward events to the overlay widget.
self.overlay_forward_event(tui, event)?;
Ok(true)
}
self.handle_legacy_transcript_event(tui, app_server, event)
}
/// Handle global Esc presses for backtracking when no overlay is present.

View File

@@ -0,0 +1,74 @@
//! Input routing for the read-only transcript viewer.
//!
//! Preview selection is shared with the interactive transcript path so both
//! modes retain the same prompt navigation and confirmation behavior.
use super::*;
impl App {
pub(super) fn handle_legacy_transcript_event(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<bool> {
if let TuiEvent::Key(key_event) = &event
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_ref()
&& (overlay.should_load_older(*key_event)
|| (self.backtrack.overlay_preview_active
&& self.backtrack.nth_user_message == 0
&& matches!(key_event.code, KeyCode::Esc | KeyCode::Left)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)))
&& let Some(thread_id) = self.chat_widget.thread_id()
&& app_server.has_older_history(thread_id)
&& self.request_older_history_page(app_server, thread_id)
{
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
overlay.set_history_state(if overlay.should_load_from_start(*key_event) {
TranscriptHistoryState::LoadingBeginning
} else {
TranscriptHistoryState::LoadingOlder
});
}
tui.frame_requester().schedule_frame();
}
if self.backtrack.overlay_preview_active {
return self.handle_backtrack_preview_event(tui, event);
}
match event {
TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => self.begin_overlay_backtrack_preview(tui),
event => self.overlay_forward_event(tui, event)?,
}
Ok(true)
}
pub(super) fn handle_backtrack_preview_event(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
match event {
TuiEvent::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Left,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => self.overlay_step_backtrack(tui, event)?,
TuiEvent::Key(KeyEvent {
code: KeyCode::Right,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => self.overlay_step_backtrack_forward(tui, event)?,
TuiEvent::Key(KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
}) => self.overlay_confirm_backtrack(tui),
event => self.overlay_forward_event(tui, event)?,
}
Ok(true)
}
}