mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Add shortcuts to focus the main composer and side conversation (#59208)
## What changed - Add configurable `focus_main_composer` and `focus_sidebar_content` actions, with default `leader c` and `leader s` bindings in workspace mode. - Let users return to the main composer while keeping the side pane open, or focus the side conversation and hide it when already focused. - Clear transcript search, selection, and activity focus when returning to the main composer, including when hiding the side pane. ## Testing Extend tests for focus switching, blocked thread switches, offline composer focus, key capture, and binding conflicts. GitOrigin-RevId: da624ce7e00a63c4f13870e55c4bc95f09b32cc4
This commit is contained in:
@@ -137,6 +137,10 @@ pub struct TuiGlobalKeymap {
|
||||
pub leader: Option<KeybindingsSpec>,
|
||||
/// Change where a new conversation runs.
|
||||
pub change_host: Option<KeybindingsSpec>,
|
||||
/// Focus the main conversation composer.
|
||||
pub focus_main_composer: Option<KeybindingsSpec>,
|
||||
/// Focus the side conversation, or hide it when already focused.
|
||||
pub focus_sidebar_content: Option<KeybindingsSpec>,
|
||||
/// Hide the focused sidebar pane.
|
||||
pub hide_pane: Option<KeybindingsSpec>,
|
||||
/// Open the shared agent-session overview.
|
||||
|
||||
@@ -5279,6 +5279,8 @@
|
||||
"copy": null,
|
||||
"find_transcript": null,
|
||||
"focus_activity": null,
|
||||
"focus_main_composer": null,
|
||||
"focus_sidebar_content": null,
|
||||
"hide_pane": null,
|
||||
"leader": null,
|
||||
"open_agents": null,
|
||||
@@ -6109,6 +6111,22 @@
|
||||
],
|
||||
"description": "Focus activity groups in the owned transcript to inspect their details."
|
||||
},
|
||||
"focus_main_composer": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Focus the main conversation composer."
|
||||
},
|
||||
"focus_sidebar_content": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Focus the side conversation, or hide it when already focused."
|
||||
},
|
||||
"hide_pane": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -6330,6 +6348,8 @@
|
||||
"copy": null,
|
||||
"find_transcript": null,
|
||||
"focus_activity": null,
|
||||
"focus_main_composer": null,
|
||||
"focus_sidebar_content": null,
|
||||
"hide_pane": null,
|
||||
"leader": null,
|
||||
"open_agents": null,
|
||||
|
||||
+3
-14
@@ -270,6 +270,7 @@ mod server_version_notice;
|
||||
mod session_lifecycle;
|
||||
mod session_picker;
|
||||
mod side;
|
||||
mod side_focus;
|
||||
mod side_pane;
|
||||
mod startup;
|
||||
pub(crate) mod startup_bootstrap;
|
||||
@@ -1011,24 +1012,12 @@ impl App {
|
||||
event
|
||||
};
|
||||
|
||||
self.cancel_primed_browsing_for_event(&event);
|
||||
if let TuiEvent::Key(key) = &event
|
||||
&& self.keymap.app.hide_pane.is_pressed(*key)
|
||||
&& self.side_pane_shortcuts_available(tui)
|
||||
&& self.handle_side_focus_key(tui, app_server, *key).await
|
||||
{
|
||||
if key.kind == KeyEventKind::Press {
|
||||
let activation = self
|
||||
.routed_key_activation
|
||||
.take()
|
||||
.unwrap_or_else(|| KeyBinding::from_event(*key));
|
||||
if let Err(err) = self.hide_focused_side_pane(tui, app_server).await {
|
||||
self.chat_widget.add_error_message(err.to_string());
|
||||
}
|
||||
self.chat_widget
|
||||
.suppress_current_view_key_until_release(activation, Instant::now());
|
||||
}
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.cancel_primed_browsing_for_event(&event);
|
||||
let voice_toggle = |app: &Self, key: KeyEvent| {
|
||||
key.kind == KeyEventKind::Press
|
||||
&& app
|
||||
|
||||
@@ -239,7 +239,11 @@ impl App {
|
||||
|
||||
fn key_chord_contexts(&self, tui: &tui::Tui) -> crate::keymap::KeymapContextSet {
|
||||
let contexts = self.active_keymap_contexts();
|
||||
if self.side_pane_shortcuts_available(tui) {
|
||||
if !self.main_composer_shortcut_available(tui) {
|
||||
return contexts;
|
||||
}
|
||||
let contexts = contexts.with_main_composer_focus();
|
||||
if self.side_pane_switch_available() {
|
||||
contexts.with_side_pane()
|
||||
} else {
|
||||
contexts
|
||||
|
||||
@@ -1664,6 +1664,22 @@ async fn find_refreshes_live_details_before_searching_the_first_query() -> Resul
|
||||
))
|
||||
.contains("needle")
|
||||
);
|
||||
crate::app::test_support::enable_side_retention(&mut app)?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
TuiEvent::Key(KeyCode::F(4).into()),
|
||||
)
|
||||
.await?;
|
||||
assert!(app.transcript_view.is_activity_focused());
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyCode::Char('c').into(),
|
||||
] {
|
||||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))
|
||||
.await?;
|
||||
}
|
||||
assert!(!app.transcript_view.is_activity_focused());
|
||||
for event in [
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
|
||||
TuiEvent::Paste("needle".into()),
|
||||
|
||||
@@ -322,11 +322,15 @@ impl App {
|
||||
self.local_settings.workspace_enabled(&self.config) && tui.is_owned_screen()
|
||||
}
|
||||
|
||||
pub(super) fn side_pane_shortcuts_available(&self, tui: &tui::Tui) -> bool {
|
||||
pub(super) fn main_composer_shortcut_available(&self, tui: &tui::Tui) -> bool {
|
||||
self.side_retention_enabled(tui)
|
||||
&& !self.reconnect.offline
|
||||
&& self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& (!self.reconnect.offline || self.active_side_parent_thread_id().is_none())
|
||||
}
|
||||
|
||||
pub(super) fn side_pane_switch_available(&self) -> bool {
|
||||
!self.reconnect.offline
|
||||
&& self
|
||||
.current_displayed_thread_id()
|
||||
.and_then(|id| self.side_pair_key(id))
|
||||
@@ -483,8 +487,7 @@ impl App {
|
||||
{
|
||||
side.pane_open = false;
|
||||
tui.frame_requester().schedule_frame();
|
||||
self.surface_pending_inactive_thread_interactive_requests()
|
||||
.await?;
|
||||
self.focus_returned_parent_composer(parent).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Composer and side-conversation focus shortcuts. Thread selection replaces the widget
|
||||
//! immediately, but Park and Finish transfer transcript views through the app event queue.
|
||||
|
||||
use super::*;
|
||||
use crate::transcript_view::TranscriptView;
|
||||
|
||||
impl App {
|
||||
pub(super) async fn handle_side_focus_key(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
key: KeyEvent,
|
||||
) -> bool {
|
||||
let bindings = &self.keymap.app;
|
||||
let main = bindings.focus_main_composer.is_pressed(key);
|
||||
let sidebar = bindings.focus_sidebar_content.is_pressed(key);
|
||||
if !(main || sidebar || bindings.hide_pane.is_pressed(key))
|
||||
|| !self.main_composer_shortcut_available(tui)
|
||||
|| (!main && !self.side_pane_switch_available())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return true;
|
||||
}
|
||||
let activation = self
|
||||
.routed_key_activation
|
||||
.take()
|
||||
.unwrap_or_else(|| KeyBinding::from_event(key));
|
||||
let result = if main {
|
||||
self.focus_main_composer(tui, app_server).await
|
||||
} else if sidebar
|
||||
&& let Some(parent) = self.current_displayed_thread_id()
|
||||
&& let Some(child) = self.side_pair_key(parent)
|
||||
&& child != parent
|
||||
{
|
||||
let result = self.toggle_side_conversation(tui, app_server).await;
|
||||
if result.is_ok() && self.current_displayed_thread_id() == Some(child) {
|
||||
// The outgoing parent's view is still here until Park runs.
|
||||
self.focus_current_parent_view(tui);
|
||||
}
|
||||
result
|
||||
} else {
|
||||
self.hide_focused_side_pane(tui, app_server).await
|
||||
};
|
||||
if let Err(err) = result {
|
||||
self.chat_widget.add_error_message(err.to_string());
|
||||
}
|
||||
self.chat_widget
|
||||
.suppress_current_view_key_until_release(activation, Instant::now());
|
||||
true
|
||||
}
|
||||
|
||||
async fn focus_main_composer(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = self.active_side_parent_thread_id() {
|
||||
self.select_agent_thread(tui, app_server, parent).await?;
|
||||
if self.current_displayed_thread_id() == Some(parent) {
|
||||
self.focus_returned_parent_composer(parent).await?;
|
||||
}
|
||||
} else {
|
||||
self.focus_current_parent_view(tui);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn focus_current_parent_view(&mut self, tui: &mut tui::Tui) {
|
||||
self.cancel_transcript_browsing(tui);
|
||||
clear_transcript_interaction(&mut self.transcript_view, &self.transcript_cells);
|
||||
self.cancel_pending_key_chord();
|
||||
}
|
||||
|
||||
pub(super) async fn focus_returned_parent_composer(&mut self, parent: ThreadId) -> Result<()> {
|
||||
// Finish will restore this view. The live transcript still belongs to the child.
|
||||
if let Some(companion) = self.side_companions.get_mut(&parent) {
|
||||
companion.view.set_highlight(/*index*/ None);
|
||||
clear_transcript_interaction(&mut companion.view, &companion.cells);
|
||||
}
|
||||
self.cancel_pending_key_chord();
|
||||
self.surface_pending_inactive_thread_interactive_requests()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_transcript_interaction(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>]) {
|
||||
view.end_select_mode();
|
||||
view.end_selection(cells);
|
||||
view.cancel_search();
|
||||
view.clear_activity_focus();
|
||||
}
|
||||
@@ -3497,7 +3497,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
parent_thread_id,
|
||||
"background",
|
||||
"reply",
|
||||
"still working\n\n",
|
||||
&"still working\n\n".repeat(40),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
@@ -3543,7 +3543,8 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
KeyEventKind::Release,
|
||||
]
|
||||
.map(|kind| KeyEvent::new_with_kind(KeyCode::Char('-'), KeyModifiers::NONE, kind));
|
||||
assert!(app.side_pane_shortcuts_available(&tui));
|
||||
let main = KeyEvent::from(KeyCode::Char('c'));
|
||||
let side = KeyEvent::from(KeyCode::Char('s'));
|
||||
// Hide is consumed on the parent without closing the visible pane or typing its completion.
|
||||
keys!(leader, minus, repeat, release);
|
||||
assert!(!app.chat_widget.is_in_paste_burst());
|
||||
@@ -3616,7 +3617,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
|
||||
// The pending permission change refuses a parent switch even though selection returns Ok.
|
||||
app.pending_server_profiles
|
||||
.insert(side_thread_id, read_only);
|
||||
.insert(side_thread_id, read_only.clone());
|
||||
keys!(leader, minus, repeat, release);
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "child draft!");
|
||||
@@ -3640,10 +3641,33 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
enable_side_retention(&mut app)?;
|
||||
KeyCode::F(12).into()
|
||||
};
|
||||
// Park an interaction on the parent so both hide shortcuts must clear it on return.
|
||||
keys!(prefix, main);
|
||||
app.transcript_view.begin_search();
|
||||
app.select_agent_thread(&mut tui, &mut app_server, side_thread_id)
|
||||
.await?;
|
||||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||||
side_frame(&mut app, &mut tui, size)?;
|
||||
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
|
||||
keys!(prefix, minus);
|
||||
if width == 100 {
|
||||
app.pending_server_profiles
|
||||
.insert(side_thread_id, read_only.clone());
|
||||
keys!(prefix, main);
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
|
||||
assert!(app.transcript_view.is_select_mode());
|
||||
assert!(
|
||||
app.side_companions[&parent_thread_id]
|
||||
.view
|
||||
.is_search_editing()
|
||||
);
|
||||
app.pending_server_profiles.remove(&side_thread_id);
|
||||
}
|
||||
keys!(prefix, if width == 100 { minus } else { side });
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
|
||||
keys!(repeat, release);
|
||||
assert!(!app.transcript_view.has_active_interaction());
|
||||
if width == 100 {
|
||||
keys!(repeat, release);
|
||||
}
|
||||
assert!(!app.chat_widget.is_in_paste_burst());
|
||||
let hidden = side_frame(&mut app, &mut tui, size)?;
|
||||
assert!(
|
||||
@@ -3690,6 +3714,45 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
}
|
||||
assert_app_snapshot!("hide_and_reopen_side_conversation", frames.join("\n\n"));
|
||||
|
||||
// The parent is browsing a saved prompt and the child has a selection.
|
||||
let prefix = KeyCode::F(12).into();
|
||||
keys!(toggle);
|
||||
app.apply_backtrack_selection_internal(/*nth_user_message*/ 0);
|
||||
let highlighted = side_frame(&mut app, &mut tui, size)?;
|
||||
assert!(
|
||||
highlighted.contains("Saved user message") && !highlighted.contains("Back to bottom"),
|
||||
"{highlighted}"
|
||||
);
|
||||
keys!(toggle);
|
||||
side_frame(&mut app, &mut tui, size)?;
|
||||
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
|
||||
|
||||
keys!(prefix, main);
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
|
||||
let restored = side_frame(&mut app, &mut tui, size)?;
|
||||
assert!(
|
||||
restored.contains("Saved user message")
|
||||
&& restored.contains("Back to bottom")
|
||||
&& restored.contains('│'),
|
||||
"{restored}"
|
||||
);
|
||||
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
|
||||
app.pending_server_profiles
|
||||
.insert(parent_thread_id, read_only);
|
||||
keys!(prefix, side);
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
|
||||
assert!(app.transcript_view.is_select_mode());
|
||||
app.pending_server_profiles.remove(&parent_thread_id);
|
||||
keys!(prefix, side);
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
|
||||
assert!(app.transcript_view.is_select_mode());
|
||||
assert!(
|
||||
!app.side_companions[&parent_thread_id]
|
||||
.view
|
||||
.has_active_interaction()
|
||||
);
|
||||
app.transcript_view.end_select_mode();
|
||||
|
||||
read_side_history(&mut app, &mut tui, "child")?;
|
||||
// The upload finishes before the app event loop can park the now-inactive child.
|
||||
app.app_event_tx.send(AppEvent::FeedbackSubmitted {
|
||||
|
||||
@@ -1173,12 +1173,15 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> {
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
let parent = ThreadId::new();
|
||||
app.active_thread_id = Some(parent);
|
||||
let child = ThreadId::new();
|
||||
app.side_threads
|
||||
.insert(ThreadId::new(), super::SideThreadState::new(parent));
|
||||
.insert(child, super::SideThreadState::new(parent));
|
||||
for (config, key) in [
|
||||
("", 8),
|
||||
("[chat]\ntoggle_voice = 'f8 v'", 8),
|
||||
("[global]\nhide_pane = 'f12'", 12),
|
||||
("[global]\nfocus_main_composer = 'f12'", 12),
|
||||
("[global]\nfocus_sidebar_content = 'f12 s'", 12),
|
||||
] {
|
||||
app.keymap =
|
||||
RuntimeKeymap::for_session(&toml::from_str(config)?, /*workspace*/ true).unwrap();
|
||||
@@ -1219,5 +1222,25 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> {
|
||||
}
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
|
||||
}
|
||||
|
||||
// Offline, the same printable binding focuses the local composer but cannot switch threads.
|
||||
let mut stopped = app_server.request_view();
|
||||
app_server.shutdown().await?;
|
||||
app.local_settings.tui.keymap = toml::from_str("[global]\nfocus_main_composer = '%'")?;
|
||||
super::enable_side_retention(&mut app)?;
|
||||
app.active_thread_id = Some(ThreadId::new());
|
||||
app.reconnect.offline = true;
|
||||
app.chat_widget.apply_external_edit("draft".into());
|
||||
app.transcript_view.begin_search();
|
||||
for key in [KeyCode::Char('%'), KeyCode::Char('!'), KeyCode::Right] {
|
||||
press(&mut app, &mut tui, &mut stopped, key.into()).await?;
|
||||
}
|
||||
assert!(!app.transcript_view.has_active_interaction());
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft!");
|
||||
app.active_thread_id = Some(child);
|
||||
for key in [KeyCode::Char('%'), KeyCode::Right] {
|
||||
press(&mut app, &mut tui, &mut stopped, key.into()).await?;
|
||||
}
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft!%");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+70
-11
@@ -104,6 +104,8 @@ pub(crate) struct RuntimeKeymap {
|
||||
pub(crate) struct AppKeymap {
|
||||
/// Change where a new conversation runs.
|
||||
pub(crate) change_host: Vec<KeyBinding>,
|
||||
pub(crate) focus_main_composer: Vec<KeyBinding>,
|
||||
pub(crate) focus_sidebar_content: Vec<KeyBinding>,
|
||||
pub(crate) hide_pane: Vec<KeyBinding>,
|
||||
/// Open the agent command center.
|
||||
pub(crate) open_agents: Vec<KeyBinding>,
|
||||
@@ -739,6 +741,16 @@ impl RuntimeKeymap {
|
||||
&defaults.app.change_host,
|
||||
"tui.keymap.global.change_host",
|
||||
)?,
|
||||
focus_main_composer: resolve_bindings(
|
||||
keymap.global.focus_main_composer.as_ref(),
|
||||
&[],
|
||||
"tui.keymap.global.focus_main_composer",
|
||||
)?,
|
||||
focus_sidebar_content: resolve_bindings(
|
||||
keymap.global.focus_sidebar_content.as_ref(),
|
||||
&[],
|
||||
"tui.keymap.global.focus_sidebar_content",
|
||||
)?,
|
||||
hide_pane: resolve_bindings(
|
||||
keymap.global.hide_pane.as_ref(),
|
||||
&[],
|
||||
@@ -1719,6 +1731,8 @@ impl RuntimeKeymap {
|
||||
Self {
|
||||
app: AppKeymap {
|
||||
change_host: Vec::new(),
|
||||
focus_main_composer: Vec::new(),
|
||||
focus_sidebar_content: Vec::new(),
|
||||
hide_pane: Vec::new(),
|
||||
open_agents: default_bindings![],
|
||||
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
|
||||
@@ -2071,6 +2085,11 @@ impl RuntimeKeymap {
|
||||
for (action, bindings) in [
|
||||
("global.change_host", &self.app.change_host),
|
||||
("global.open_agents", &self.app.open_agents),
|
||||
("global.focus_main_composer", &self.app.focus_main_composer),
|
||||
(
|
||||
"global.focus_sidebar_content",
|
||||
&self.app.focus_sidebar_content,
|
||||
),
|
||||
("global.hide_pane", &self.app.hide_pane),
|
||||
("chat.open_presets", &self.chat.open_presets),
|
||||
("chat.toggle_voice", &self.chat.toggle_voice),
|
||||
@@ -2101,6 +2120,14 @@ impl RuntimeKeymap {
|
||||
|
||||
let main_bindings = [
|
||||
("change_host", self.app.change_host.as_slice()),
|
||||
(
|
||||
"focus_main_composer",
|
||||
self.app.focus_main_composer.as_slice(),
|
||||
),
|
||||
(
|
||||
"focus_sidebar_content",
|
||||
self.app.focus_sidebar_content.as_slice(),
|
||||
),
|
||||
("hide_pane", self.app.hide_pane.as_slice()),
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
@@ -2270,6 +2297,14 @@ impl RuntimeKeymap {
|
||||
validate_no_shadow_with_allowed_overlaps(
|
||||
"main",
|
||||
[
|
||||
(
|
||||
"focus_main_composer",
|
||||
self.app.focus_main_composer.as_slice(),
|
||||
),
|
||||
(
|
||||
"focus_sidebar_content",
|
||||
self.app.focus_sidebar_content.as_slice(),
|
||||
),
|
||||
("hide_pane", self.app.hide_pane.as_slice()),
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
@@ -2396,6 +2431,14 @@ impl RuntimeKeymap {
|
||||
validate_unique(
|
||||
"transcript",
|
||||
[
|
||||
(
|
||||
"global.focus_main_composer",
|
||||
self.app.focus_main_composer.as_slice(),
|
||||
),
|
||||
(
|
||||
"global.focus_sidebar_content",
|
||||
self.app.focus_sidebar_content.as_slice(),
|
||||
),
|
||||
("global.hide_pane", self.app.hide_pane.as_slice()),
|
||||
(
|
||||
"global.select_transcript",
|
||||
@@ -2429,6 +2472,14 @@ impl RuntimeKeymap {
|
||||
validate_unique(
|
||||
"activity",
|
||||
context_bindings(KeymapContext::List).chain([
|
||||
(
|
||||
"global.focus_main_composer",
|
||||
self.app.focus_main_composer.as_slice(),
|
||||
),
|
||||
(
|
||||
"global.focus_sidebar_content",
|
||||
self.app.focus_sidebar_content.as_slice(),
|
||||
),
|
||||
("global.hide_pane", self.app.hide_pane.as_slice()),
|
||||
("global.focus_activity", self.app.focus_activity.as_slice()),
|
||||
(
|
||||
@@ -3891,21 +3942,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() {
|
||||
for (section, action) in [("pager", "page_up"), ("list", "move_down")] {
|
||||
for (pane, section, action) in [
|
||||
("hide_pane", "pager", "page_up"),
|
||||
("hide_pane", "list", "move_down"),
|
||||
("focus_main_composer", "pager", "page_up"),
|
||||
("focus_sidebar_content", "list", "move_down"),
|
||||
] {
|
||||
let config: TuiKeymap = toml::from_str(&format!(
|
||||
"[global]\nhide_pane = 'f8'\n[{section}]\n{action} = 'f8'"
|
||||
"[global]\n{pane} = 'f8'\n[{section}]\n{action} = 'f8'"
|
||||
))
|
||||
.unwrap();
|
||||
let error = RuntimeKeymap::for_session(&config, /*workspace*/ true).unwrap_err();
|
||||
assert!(
|
||||
error.contains("global.hide_pane") && error.contains(action),
|
||||
error.contains(&format!("global.{pane}")) && error.contains(action),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut config = TuiKeymap::default();
|
||||
config.global.hide_pane = Some(one("ctrl-z"));
|
||||
for action in ["hide_pane", "focus_main_composer", "focus_sidebar_content"] {
|
||||
let config: TuiKeymap =
|
||||
toml::from_str(&format!("[global]\n{action} = 'ctrl-z'")).unwrap();
|
||||
assert!(
|
||||
RuntimeKeymap::for_session(&config, /*workspace*/ true)
|
||||
.unwrap_err()
|
||||
@@ -4105,12 +4161,15 @@ mod tests {
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
assert!(runtime.composer.toggle_shortcuts.is_empty());
|
||||
keymap.global.hide_pane = Some(KeybindingsSpec::Many(vec![]));
|
||||
keymap.global.focus_sidebar_content = Some(KeybindingsSpec::Many(vec![]));
|
||||
let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.primary_hint(KeymapContext::Global, "hide_pane")
|
||||
.is_none()
|
||||
);
|
||||
for action in ["hide_pane", "focus_sidebar_content"] {
|
||||
assert!(
|
||||
runtime
|
||||
.primary_hint(KeymapContext::Global, action)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -104,7 +104,11 @@ pub(crate) struct KeymapActionId {
|
||||
|
||||
impl KeymapActionId {
|
||||
pub(crate) fn is_side_pane(self) -> bool {
|
||||
self.context == KeymapContext::Global && self.action == "hide_pane"
|
||||
self.context == KeymapContext::Global
|
||||
&& matches!(
|
||||
self.action,
|
||||
"focus_main_composer" | "focus_sidebar_content" | "hide_pane"
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether two actions can be active together, including list-specific exceptions.
|
||||
@@ -299,6 +303,8 @@ define_runtime_action_bindings! {
|
||||
find_transcript,
|
||||
select_transcript,
|
||||
focus_activity,
|
||||
focus_main_composer,
|
||||
focus_sidebar_content,
|
||||
hide_pane,
|
||||
open_warnings => runtime_only,
|
||||
open_external_editor,
|
||||
|
||||
@@ -55,6 +55,7 @@ const VOICE_TOGGLE_BLOCKED: u32 = 1 << 19;
|
||||
const TRANSCRIPT_ACTIONS: u32 = 1 << 20;
|
||||
const BACKEND_CYCLING: u32 = 1 << 21;
|
||||
const SIDE_PANE: u32 = 1 << 22;
|
||||
const MAIN_COMPOSER_FOCUS: u32 = 1 << 23;
|
||||
|
||||
const TRANSCRIPT_BROWSING: u32 = 1 << 14;
|
||||
|
||||
@@ -157,6 +158,10 @@ impl KeymapContextSet {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn with_main_composer_focus(self) -> Self {
|
||||
Self(self.0 | MAIN_COMPOSER_FOCUS)
|
||||
}
|
||||
|
||||
pub(crate) const fn with_side_pane(self) -> Self {
|
||||
Self(self.0 | SIDE_PANE)
|
||||
}
|
||||
@@ -167,7 +172,12 @@ impl KeymapContextSet {
|
||||
return self.0 & BACKEND_CYCLING != 0;
|
||||
}
|
||||
if action.is_side_pane() {
|
||||
return self.0 & SIDE_PANE != 0;
|
||||
let required = if action.action == "focus_main_composer" {
|
||||
MAIN_COMPOSER_FOCUS
|
||||
} else {
|
||||
SIDE_PANE
|
||||
};
|
||||
return self.0 & required != 0;
|
||||
}
|
||||
if action.context == KeymapContext::Chat && action.action == "open_presets" {
|
||||
return self.contains(KeymapContext::Chat)
|
||||
|
||||
@@ -32,6 +32,8 @@ pub(in crate::keymap) fn register(
|
||||
(Global, "change_host", 'b'),
|
||||
(Global, "select_transcript", '['),
|
||||
(Chat, "open_presets", 'p'),
|
||||
(Global, "focus_main_composer", 'c'),
|
||||
(Global, "focus_sidebar_content", 's'),
|
||||
(Global, "hide_pane", '-'),
|
||||
] {
|
||||
if remaining == 0 {
|
||||
|
||||
@@ -118,6 +118,8 @@ macro_rules! define_keymap_actions {
|
||||
define_keymap_actions! {
|
||||
global "Global" {
|
||||
change_host [MultiBackend] => "Change where a new conversation runs.",
|
||||
focus_main_composer [Workspace] => "Focus the main conversation composer.",
|
||||
focus_sidebar_content [Workspace] => "Focus the side conversation, or hide it when already focused.",
|
||||
hide_pane [Workspace] => "Hide the focused sidebar pane.",
|
||||
open_agents => "Open the agent command center.",
|
||||
open_transcript => "Open the transcript overlay.",
|
||||
|
||||
+2
-2
@@ -2,6 +2,8 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Focus Main Composer | leader c (ctrl-x c) | Global focus_main_composer Focus Main Composer Focus the main conversation composer. leader c (ctrl-x c) Default
|
||||
Focus Sidebar Content | leader s (ctrl-x s) | Global focus_sidebar_content Focus Sidebar Content Focus the side conversation, or hide it when already focused. leader s (ctrl-x s) Default
|
||||
Hide Pane | leader - (ctrl-x -) | Global hide_pane Hide Pane Hide the focused sidebar pane. leader - (ctrl-x -) Default
|
||||
Open Agents | unbound | Global open_agents Open Agents Open the agent command center. unbound Default
|
||||
Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default
|
||||
@@ -13,5 +15,3 @@ Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the termina
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default
|
||||
Leader | ctrl-x | Global leader Leader Set the prefix used by shared leader shortcuts. ctrl-x Default
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
|
||||
Reference in New Issue
Block a user