mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Add a fork shortcut to the TUI command center (#49517)
## What changed - Fork the selected conversation and open the new session with `f`, configurable through `agents.fork` in the TUI keymap. - Move the default search shortcuts to `F3` and `/`, preserving unshadowed defaults when custom bindings conflict. - Keep queued input on the source conversation while attaching and forking, and require closing an open side conversation before forking another task. - Expose the fork action in command-center help and the keymap picker. ## Testing Add regression coverage for queued-input preservation, side-conversation protection, configurable fork shortcuts, metadata editing, disconnected actions, and search fallback bindings. Update help and keymap snapshots. GitOrigin-RevId: b11ae21ef6a2053609b31ce97c646104f18806a3
This commit is contained in:
@@ -449,6 +449,8 @@ pub struct TuiAgentsKeymap {
|
||||
pub new_task: Option<KeybindingsSpec>,
|
||||
/// Open a new session in a worktree from the project default branch.
|
||||
pub new_worktree: Option<KeybindingsSpec>,
|
||||
/// Fork the selected conversation and open the new session.
|
||||
pub fork: Option<KeybindingsSpec>,
|
||||
/// Rename the selected task.
|
||||
pub rename: Option<KeybindingsSpec>,
|
||||
/// Stop the selected running task.
|
||||
|
||||
@@ -4435,6 +4435,7 @@
|
||||
"agents": {
|
||||
"archive": null,
|
||||
"delete": null,
|
||||
"fork": null,
|
||||
"hide": null,
|
||||
"new_task": null,
|
||||
"new_worktree": null,
|
||||
@@ -4789,6 +4790,14 @@
|
||||
],
|
||||
"description": "Permanently delete the selected task and its child agents after confirmation."
|
||||
},
|
||||
"fork": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Fork the selected conversation and open the new session."
|
||||
},
|
||||
"hide": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -5373,6 +5382,7 @@
|
||||
"default": {
|
||||
"archive": null,
|
||||
"delete": null,
|
||||
"fork": null,
|
||||
"hide": null,
|
||||
"new_task": null,
|
||||
"new_worktree": null,
|
||||
|
||||
@@ -29,6 +29,7 @@ impl AgentsOverviewView {
|
||||
&self.agents_keymap.search,
|
||||
&self.agents_keymap.new_task,
|
||||
&self.agents_keymap.new_worktree,
|
||||
&self.agents_keymap.fork,
|
||||
&self.agents_keymap.rename,
|
||||
&self.agents_keymap.stop,
|
||||
&self.agents_keymap.archive,
|
||||
@@ -82,6 +83,7 @@ impl AgentsOverviewView {
|
||||
"New worktree",
|
||||
),
|
||||
("resume", &self.agents_keymap.resume, "Resume"),
|
||||
("fork", &self.agents_keymap.fork, "Fork"),
|
||||
("rename", &self.agents_keymap.rename, "Rename"),
|
||||
("stop", &self.agents_keymap.stop, "Stop"),
|
||||
("archive", &self.agents_keymap.archive, "Archive"),
|
||||
|
||||
@@ -137,7 +137,7 @@ async fn live_center_pages_visible_rows_without_wrapping() {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.connection_notice = Some("Reconnecting…");
|
||||
view.handle_key_event(KeyCode::Char('f').into());
|
||||
view.handle_key_event(KeyCode::Char('/').into());
|
||||
view.handle_paste("Paging".into());
|
||||
}
|
||||
screen(&view, /*width*/ 110, /*height*/ 18);
|
||||
@@ -185,7 +185,7 @@ async fn live_center_metadata_clips_at_grapheme_boundaries() {
|
||||
/*selected_thread_id*/ None,
|
||||
);
|
||||
let input = format!("{}日本語 e\u{301} 👩\u{200d}💻", "界".repeat(/*n*/ 100_000));
|
||||
for (key, label) in [('r', "Rename › "), ('f', "Search › ")] {
|
||||
for (key, label) in [('r', "Rename › "), ('/', "Search › ")] {
|
||||
view.handle_key_event(KeyCode::Char(key).into());
|
||||
view.handle_paste(input.clone());
|
||||
let rendered = screen(&view, /*width*/ 40, /*height*/ 18);
|
||||
@@ -340,7 +340,7 @@ async fn live_center_fixed_shortcuts_yield_to_configured_actions() {
|
||||
let mut app = make_test_app().await;
|
||||
let config: TuiKeymap = toml::from_str("[list]\ncancel = 'q'").unwrap();
|
||||
app.keymap = RuntimeKeymap::from_config(&config).unwrap();
|
||||
for editor_key in ['f', 'r'] {
|
||||
for editor_key in ['/', 'r'] {
|
||||
let mut view = app.agents_overview_view(
|
||||
vec![overview_thread(
|
||||
ThreadId::from_u128(/*value*/ 42),
|
||||
@@ -411,7 +411,7 @@ async fn live_center_fixed_shortcuts_yield_to_configured_actions() {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.server_version_notice = None;
|
||||
view.handle_key_event(KeyCode::Char('f').into());
|
||||
view.handle_key_event(KeyCode::Char('/').into());
|
||||
assert!(!screen(&view, /*width*/ 100, /*height*/ 24).contains("enter open"));
|
||||
}
|
||||
view.handle_key_event(key);
|
||||
@@ -479,14 +479,16 @@ async fn live_center_search_row_appears_only_while_editing() {
|
||||
["Tasks", "Status", "Updated"].map(|label| header[text.find(label).unwrap()].style());
|
||||
assert_eq!(styles, [styles[0]; 3]);
|
||||
let idle = screen(&view, area.width, area.height);
|
||||
view.handle_key_event(KeyCode::Char('f').into());
|
||||
view.handle_paste("find".into());
|
||||
insta::assert_snapshot!(
|
||||
"live_center_search_active",
|
||||
screen(&view, area.width, area.height)
|
||||
);
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
assert_eq!(screen(&view, area.width, area.height), idle);
|
||||
for key in [KeyCode::Char('/'), KeyCode::F(3)] {
|
||||
view.handle_key_event(key.into());
|
||||
view.handle_paste("find".into());
|
||||
insta::assert_snapshot!(
|
||||
"live_center_search_active",
|
||||
screen(&view, area.width, area.height)
|
||||
);
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
assert_eq!(screen(&view, area.width, area.height), idle);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -503,7 +505,7 @@ async fn backspace_edits_search_and_rename_without_deleting_tasks() {
|
||||
)],
|
||||
/*selected_thread_id*/ None,
|
||||
);
|
||||
for key in ['f', 'r'] {
|
||||
for key in ['/', 'r'] {
|
||||
view.handle_key_event(KeyCode::Char(key).into());
|
||||
let initial = screen(&view, /*width*/ 88, /*height*/ 16);
|
||||
view.handle_paste("!".into());
|
||||
@@ -583,7 +585,7 @@ async fn live_center_show_more_navigation_search_and_loading() {
|
||||
state.loading = false;
|
||||
state.refresh_failed = true;
|
||||
}
|
||||
view.handle_key_event(KeyCode::Char('f').into());
|
||||
view.handle_key_event(KeyCode::Char('/').into());
|
||||
view.handle_paste("Older task".into());
|
||||
insta::assert_snapshot!(
|
||||
"live_center_show_more_search_retry",
|
||||
|
||||
@@ -862,7 +862,7 @@ impl App {
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
}
|
||||
}
|
||||
if !read_only && !is_new_session {
|
||||
if !read_only && !is_new_session && !self.chat_widget.fork_in_progress {
|
||||
self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
|
||||
keymap.agents.archive = Some(KeybindingsSpec::One(KeybindingSpec("f5".into())));
|
||||
keymap.agents.delete = Some(KeybindingsSpec::One(KeybindingSpec("f6".into())));
|
||||
keymap.agents.hide = Some(KeybindingsSpec::One(KeybindingSpec("f7".into())));
|
||||
keymap.agents.fork = Some(KeybindingsSpec::One(KeybindingSpec("f8".into())));
|
||||
app.keymap = RuntimeKeymap::from_config(&keymap).unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.app_event_tx = AppEventSender::new(tx);
|
||||
@@ -84,7 +85,7 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
|
||||
Some(target),
|
||||
);
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
|
||||
for character in "Target".chars() {
|
||||
view.handle_key_event(KeyCode::Char(character).into());
|
||||
}
|
||||
@@ -99,6 +100,10 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
|
||||
"unexpected event: {event:?}"
|
||||
);
|
||||
}
|
||||
view.handle_key_event(KeyCode::F(8).into());
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::ForkAgentsOverviewThread { thread_id }) if thread_id == target)
|
||||
);
|
||||
view.handle_key_event(KeyCode::F(7).into());
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::HideAgentsOverviewThread { thread_id }) if thread_id == target)
|
||||
@@ -163,7 +168,7 @@ async fn hiding_tasks_keeps_selection_adjacent_in_display_order() -> Result<()>
|
||||
// Clear retained search without dismissing the command center.
|
||||
view.on_ctrl_c();
|
||||
if filtered {
|
||||
view.handle_key_event(KeyCode::Char('f').into());
|
||||
view.handle_key_event(KeyCode::Char('/').into());
|
||||
view.handle_paste("Task".into());
|
||||
// Search selects the first match; move back to Task 3.
|
||||
view.handle_key_event(KeyCode::Down.into());
|
||||
@@ -753,3 +758,28 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fork_shortcut_respects_metadata_editing() {
|
||||
let (app, mut rx, _) = crate::app::tests::make_test_app_with_channels().await;
|
||||
let target = ThreadId::new();
|
||||
let mut view = app.agents_overview_view(
|
||||
vec![overview_thread(
|
||||
target,
|
||||
/*parent_thread_id*/ None,
|
||||
"Target",
|
||||
ThreadStatus::Idle,
|
||||
)],
|
||||
Some(target),
|
||||
);
|
||||
for editor in ['r', '/'] {
|
||||
view.handle_key_event(KeyCode::Char(editor).into());
|
||||
view.handle_key_event(KeyCode::Char('f').into());
|
||||
assert!(rx.try_recv().is_err());
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
}
|
||||
view.handle_key_event(KeyCode::Char('f').into());
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::ForkAgentsOverviewThread { thread_id }) if thread_id == target)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ mod command_center;
|
||||
|
||||
use super::super::agents_overview_view::AgentsOverviewGrouping;
|
||||
use super::*;
|
||||
use crate::app::tests::make_test_app_with_channels;
|
||||
use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1263,7 +1264,7 @@ async fn shared_overview_shows_only_root_sessions() {
|
||||
state.lock().unwrap().grouping,
|
||||
AgentsOverviewGrouping::Project
|
||||
);
|
||||
action_view.handle_key_event(KeyCode::Char('f').into());
|
||||
action_view.handle_key_event(KeyCode::Char('/').into());
|
||||
assert!(action_view.handle_paste("Repair authentication".into()));
|
||||
action_view.handle_key_event(KeyCode::Enter.into());
|
||||
assert!(
|
||||
@@ -2088,7 +2089,7 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()>
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE),
|
||||
KeyCode::Esc.into(),
|
||||
] {
|
||||
@@ -2139,7 +2140,7 @@ async fn command_center_cursor_tracks_fixed_footer() {
|
||||
)],
|
||||
/*selected_thread_id*/ None,
|
||||
);
|
||||
for (key, label) in [('f', "Search ›"), ('r', "Rename ›")] {
|
||||
for (key, label) in [('/', "Search ›"), ('r', "Rename ›")] {
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
|
||||
for width in [48, 96, 120] {
|
||||
@@ -2620,7 +2621,7 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res
|
||||
let mut app = make_test_app().await;
|
||||
let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
view.handle_key_event(KeyCode::Char('f').into());
|
||||
view.handle_key_event(KeyCode::Char('/').into());
|
||||
view.handle_paste("Keep searching".into());
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let before = render_bottom_popup(&app.chat_widget, /*width*/ 48);
|
||||
@@ -2720,6 +2721,155 @@ async fn command_center_action_failures_remain_visible() -> Result<()> {
|
||||
#[path = "agents_overview_actions_tests.rs"]
|
||||
mod actions;
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_fork_preserves_an_open_side_conversation() -> Result<()> {
|
||||
let mut app = Box::pin(make_test_app()).await;
|
||||
let side = ThreadId::new();
|
||||
app.active_thread_id = Some(side);
|
||||
app.side_threads.insert(
|
||||
side,
|
||||
super::super::side::SideThreadState::new(ThreadId::new()),
|
||||
);
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let RequestId::Integer(request) = server.next_request_id() else {
|
||||
panic!("integer request ID")
|
||||
};
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::ForkAgentsOverviewThread {
|
||||
thread_id: ThreadId::new(),
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(side));
|
||||
assert!(app.side_threads.contains_key(&side));
|
||||
assert!(!app.chat_widget.fork_in_progress);
|
||||
assert_eq!(server.next_request_id(), RequestId::Integer(request + 1));
|
||||
insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80));
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_fork_keeps_idle_source_input_queued() -> Result<()> {
|
||||
for cached_primary in [false, true] {
|
||||
let (app, mut events, _) = make_test_app_with_channels().await;
|
||||
let mut app = Box::pin(app);
|
||||
trust_fixture_folders(&mut app);
|
||||
std::fs::write(
|
||||
app.config.codex_home.join("config.toml"),
|
||||
"[tui]\nresume_cwd = \"session\"\n",
|
||||
)?;
|
||||
let source = ThreadId::from_string(
|
||||
&app_test_support::create_fake_rollout(
|
||||
app.config.codex_home.as_path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Source task",
|
||||
Some(&app.config.model_provider_id),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.expect("saved rollout"),
|
||||
)?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
if cached_primary {
|
||||
Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, source)).await?;
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(source));
|
||||
app.store_active_thread_receiver().await;
|
||||
}
|
||||
app.chat_widget
|
||||
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||||
app.chat_widget.queue_user_message_with_options(
|
||||
"Keep this on the source".into(),
|
||||
crate::bottom_pane::QueuedInputAction::Plain,
|
||||
Vec::new(),
|
||||
);
|
||||
let input = app.chat_widget.capture_thread_input_state().unwrap();
|
||||
app.agents_overview
|
||||
.input_states
|
||||
.insert(source, input.clone());
|
||||
if cached_primary {
|
||||
app.ensure_thread_channel(source)
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.input_state = Some(input);
|
||||
let child = ThreadId::new();
|
||||
app.active_thread_id = Some(child);
|
||||
let mut session = app
|
||||
.ensure_thread_channel(source)
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.session
|
||||
.clone()
|
||||
.unwrap();
|
||||
session.thread_id = child;
|
||||
app.chat_widget.handle_thread_session(session);
|
||||
}
|
||||
while events.try_recv().is_ok() {}
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::ForkAgentsOverviewThread { thread_id: source },
|
||||
))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.current_displayed_thread_id(),
|
||||
Some(source),
|
||||
"{}",
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 96)
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.queued_user_message_texts(),
|
||||
vec!["Keep this on the source"]
|
||||
);
|
||||
let retained = app.agents_overview.input_states[&source].clone();
|
||||
let mut ready = None;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
assert!(!matches!(
|
||||
event,
|
||||
AppEvent::CodexOp(AppCommand::UserTurn { .. })
|
||||
));
|
||||
if matches!(event, AppEvent::ForkAgentsOverviewThreadReady { .. }) {
|
||||
ready = Some(event);
|
||||
}
|
||||
}
|
||||
// Failed reattachment can leave the source displayed as a saved replay.
|
||||
app.app_server_target = AppServerTarget::LocalDaemon {
|
||||
allow_embedded_fallback: true,
|
||||
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
|
||||
};
|
||||
app.ensure_thread_channel(source).mark_replay_only();
|
||||
let RequestId::Integer(request) = server.next_request_id() else {
|
||||
panic!("integer request ID")
|
||||
};
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::ForkAgentsOverviewThreadReady { thread_id: source },
|
||||
))
|
||||
.await?;
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(source));
|
||||
assert_eq!(server.next_request_id(), RequestId::Integer(request + 1));
|
||||
assert!(!app.chat_widget.fork_in_progress);
|
||||
app.app_server_target = AppServerTarget::Embedded;
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, ready.expect("fork continuation")))
|
||||
.await?;
|
||||
assert_ne!(app.current_displayed_thread_id(), Some(source));
|
||||
assert_eq!(app.agents_overview.input_states[&source], retained);
|
||||
assert!(app.chat_widget.queued_user_message_texts().is_empty());
|
||||
assert!(!app.chat_widget.fork_in_progress);
|
||||
server.shutdown().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trust_fixture_folders(app: &mut App) {
|
||||
let projects = serde_json::json!({
|
||||
test_path_buf("/").display().to_string(): {"trust_level": "trusted"},
|
||||
|
||||
@@ -600,6 +600,14 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.agents_keymap.fork.is_pressed(key) {
|
||||
if let Some(row) = self.selected_row() {
|
||||
self.app_event_tx.send(AppEvent::ForkAgentsOverviewThread {
|
||||
thread_id: row.thread_id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.agents_keymap.rename.is_pressed(key) {
|
||||
if let Some(row) = self.selected_row() {
|
||||
let mut state = self.state();
|
||||
|
||||
@@ -31,6 +31,19 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
event: AppEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
let from_agents_overview = matches!(event, AppEvent::ForkAgentsOverviewThreadReady { .. });
|
||||
let event = if let AppEvent::ForkAgentsOverviewThreadReady { thread_id } = event {
|
||||
if self.current_displayed_thread_id() != Some(thread_id)
|
||||
|| (self.thread_unavailable(thread_id)
|
||||
&& !self.chat_widget.is_external_writer_view())
|
||||
{
|
||||
self.chat_widget.fork_in_progress = false;
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
AppEvent::ForkCurrentSession { name: None }
|
||||
} else {
|
||||
event
|
||||
};
|
||||
// Release the shortcut's input guard even when a fork is rejected below.
|
||||
if matches!(event, AppEvent::ForkCurrentSession { .. }) {
|
||||
self.chat_widget.fork_in_progress = false;
|
||||
@@ -496,7 +509,9 @@ impl App {
|
||||
}
|
||||
AppEvent::ForkCurrentSession { name } => {
|
||||
let from_locked_thread = self.chat_widget.is_external_writer_view();
|
||||
let source = if from_locked_thread {
|
||||
let source = if from_agents_overview {
|
||||
"agents_overview_shortcut"
|
||||
} else if from_locked_thread {
|
||||
"locked_thread_shortcut"
|
||||
} else {
|
||||
"slash_command"
|
||||
@@ -600,8 +615,8 @@ impl App {
|
||||
));
|
||||
}
|
||||
}
|
||||
if from_locked_thread {
|
||||
// Repeated locked-view shortcuts must not act on the resulting view.
|
||||
if from_locked_thread || from_agents_overview {
|
||||
// Repeated fork shortcuts must not act on the resulting view.
|
||||
if let Err(err) = tui.discard_pending_input_before_interactive_screen() {
|
||||
tracing::warn!(%err, "failed to discard input after forking");
|
||||
}
|
||||
@@ -615,7 +630,9 @@ impl App {
|
||||
}
|
||||
|
||||
self.chat_widget.fork_in_progress = false;
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
if !from_agents_overview {
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::RevertSessionForPromptEdit {
|
||||
@@ -2709,6 +2726,34 @@ impl App {
|
||||
AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)),
|
||||
}
|
||||
}
|
||||
AppEvent::ForkAgentsOverviewThread { thread_id } => {
|
||||
if self.reconnect.offline {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
if !self.side_threads.is_empty()
|
||||
&& self.current_displayed_thread_id() != Some(thread_id)
|
||||
{
|
||||
self.add_agents_overview_error(
|
||||
"Close the side conversation before forking another task.".into(),
|
||||
);
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
// Attachment replay must not submit queued input on the source task.
|
||||
self.chat_widget.fork_in_progress = true;
|
||||
let control = self.select_agents_overview_thread(tui, app_server, thread_id).await;
|
||||
if matches!(&control, Ok(AppRunControl::Continue))
|
||||
&& self.current_displayed_thread_id() == Some(thread_id)
|
||||
{
|
||||
if let Some(input) = self.chat_widget.capture_thread_input_state() {
|
||||
self.agents_overview.input_states.insert(thread_id, input);
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::ForkAgentsOverviewThreadReady { thread_id });
|
||||
} else {
|
||||
self.chat_widget.fork_in_progress = false;
|
||||
}
|
||||
return control;
|
||||
}
|
||||
AppEvent::ForkAgentsOverviewThreadReady { .. } => unreachable!("normalized above"),
|
||||
AppEvent::NewAgentsOverviewWorktree { cwd } => {
|
||||
Box::pin(self.new_agents_overview_worktree(tui, app_server, cwd)).await;
|
||||
}
|
||||
|
||||
@@ -490,6 +490,7 @@ impl App {
|
||||
/// This helper copies every known nickname/role from `AgentNavigationState` into the
|
||||
/// replacement widget so that replayed collab items render agent names immediately.
|
||||
pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
|
||||
chat_widget.fork_in_progress = self.chat_widget.fork_in_progress;
|
||||
self.pending_right_click_paste = None;
|
||||
if !self.chat_widget.realtime_conversation_is_running() {
|
||||
self.retain_realtime_replay_state_before_replace();
|
||||
|
||||
+5
-5
@@ -9,10 +9,11 @@ expression: "screen(&view, 100, 24)"
|
||||
|
||||
Navigate Tasks View
|
||||
k Up n New tab/shift+tab Filter
|
||||
j Down o Resume f Search
|
||||
enter Open z r Rename g Group
|
||||
pgdn Page down x Stop
|
||||
ctrl+c Quit h Hide
|
||||
j Down o Resume f3 Search
|
||||
enter Open f Fork g Group
|
||||
pgdn Page down z r Rename
|
||||
ctrl+c Quit x Stop
|
||||
h Hide
|
||||
backspace Delete
|
||||
|
||||
|
||||
@@ -24,5 +25,4 @@ expression: "screen(&view, 100, 24)"
|
||||
|
||||
|
||||
|
||||
|
||||
esc back
|
||||
|
||||
+2
-2
@@ -19,6 +19,7 @@ expression: "screen(&view, width, height)"
|
||||
n New
|
||||
w New worktree
|
||||
o Resume
|
||||
f Fork
|
||||
r Rename
|
||||
x Stop
|
||||
a Archive
|
||||
@@ -27,10 +28,9 @@ expression: "screen(&view, width, height)"
|
||||
|
||||
View
|
||||
tab/shift+tab Filter
|
||||
f Search
|
||||
f3 Search
|
||||
g Group
|
||||
|
||||
|
||||
|
||||
|
||||
esc back
|
||||
|
||||
+5
-5
@@ -9,11 +9,12 @@ expression: "screen(&view, width, height)"
|
||||
|
||||
Navigate Tasks View
|
||||
↑ Up n New tab/shift+tab Filter
|
||||
↓ Down w New worktree f Search
|
||||
↓ Down w New worktree f3 Search
|
||||
enter Open o Resume g Group
|
||||
pgup Page up r Rename
|
||||
pgdn Page down x Stop
|
||||
ctrl+c Quit a Archive
|
||||
pgup Page up f Fork
|
||||
pgdn Page down r Rename
|
||||
ctrl+c Quit x Stop
|
||||
a Archive
|
||||
h Hide
|
||||
backspace Delete
|
||||
|
||||
@@ -24,5 +25,4 @@ expression: "screen(&view, width, height)"
|
||||
|
||||
|
||||
|
||||
|
||||
esc back
|
||||
|
||||
+5
-5
@@ -11,18 +11,18 @@ expression: "screen(&view, width, height)"
|
||||
↑ Up n New
|
||||
↓ Down w New worktree
|
||||
enter Open o Resume
|
||||
pgup Page up r Rename
|
||||
pgdn Page down x Stop
|
||||
ctrl+c Quit a Archive
|
||||
pgup Page up f Fork
|
||||
pgdn Page down r Rename
|
||||
ctrl+c Quit x Stop
|
||||
a Archive
|
||||
h Hide
|
||||
backspace Delete
|
||||
|
||||
View
|
||||
tab/shift+tab Filter
|
||||
f Search
|
||||
f3 Search
|
||||
g Group
|
||||
|
||||
|
||||
|
||||
|
||||
esc back
|
||||
|
||||
+1
-1
@@ -19,10 +19,10 @@ expression: "screen(&view, width, height)"
|
||||
n New
|
||||
w New worktree
|
||||
o Resume
|
||||
f Fork
|
||||
r Rename
|
||||
x Stop
|
||||
a Archive
|
||||
h Hide
|
||||
backspace Delete
|
||||
… resize to see all
|
||||
esc back
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/app/agents_overview_tests.rs
|
||||
assertion_line: 2740
|
||||
expression: "render_bottom_popup(&app.chat_widget, 80)"
|
||||
---
|
||||
Unable to complete action
|
||||
Close the side conversation before forking another task.
|
||||
|
||||
|
||||
› 1. Return to command center
|
||||
|
||||
enter select · esc back
|
||||
@@ -201,12 +201,8 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(KeyCode::F(3).into()))
|
||||
.await?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
@@ -224,6 +220,7 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
|
||||
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
|
||||
] {
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
|
||||
.await?;
|
||||
@@ -240,6 +237,7 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
|
||||
AppEvent::NewAgentsOverviewSession { .. }
|
||||
| AppEvent::NewAgentsOverviewWorktree { .. }
|
||||
| AppEvent::OpenResumePicker
|
||||
| AppEvent::ForkAgentsOverviewThread { .. }
|
||||
))
|
||||
);
|
||||
assert!(app.chat_widget.has_active_view());
|
||||
|
||||
@@ -713,7 +713,7 @@ async fn command_center_chords_do_not_capture_search_text() -> Result<()> {
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
app.chat_widget.handle_key_event(KeyCode::Char('f').into());
|
||||
app.chat_widget.handle_key_event(KeyCode::Char('/').into());
|
||||
for key in "new".chars() {
|
||||
let event = KeyCode::Char(key).into();
|
||||
assert_eq!(app.route_key_chord_event(&mut tui, event), Some(event));
|
||||
|
||||
@@ -306,6 +306,14 @@ pub(crate) enum AppEvent {
|
||||
cwd: Option<AbsolutePathBuf>,
|
||||
},
|
||||
AgentsOverviewWorktreeCreated(Result<crate::app::PendingWorktree, String>),
|
||||
/// Fork the selected dashboard conversation and open the new session.
|
||||
ForkAgentsOverviewThread {
|
||||
thread_id: ThreadId,
|
||||
},
|
||||
/// Run the existing fork action after selection events have been processed.
|
||||
ForkAgentsOverviewThreadReady {
|
||||
thread_id: ThreadId,
|
||||
},
|
||||
/// Rename a task directly from the shared dashboard.
|
||||
RenameAgentsOverviewThread {
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -235,6 +235,7 @@ impl ChatWidget {
|
||||
/// If idle and there are queued inputs, submit exactly one to start the next turn.
|
||||
pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool {
|
||||
if !self.is_session_configured()
|
||||
|| self.fork_in_progress
|
||||
|| self.has_misalignment_policy_violation()
|
||||
|| self.input_queue.suppress_queue_autosend
|
||||
|| self.input_queue.rate_limit_recovery_pending
|
||||
|
||||
+30
-27
@@ -425,6 +425,7 @@ pub(crate) struct AgentsKeymap {
|
||||
pub(crate) search: Vec<KeyBinding>,
|
||||
pub(crate) new_task: Vec<KeyBinding>,
|
||||
pub(crate) new_worktree: Vec<KeyBinding>,
|
||||
pub(crate) fork: Vec<KeyBinding>,
|
||||
pub(crate) rename: Vec<KeyBinding>,
|
||||
pub(crate) stop: Vec<KeyBinding>,
|
||||
pub(crate) archive: Vec<KeyBinding>,
|
||||
@@ -1353,6 +1354,7 @@ impl RuntimeKeymap {
|
||||
search: resolve_local!(keymap, defaults, agents, search),
|
||||
new_task: resolve_local!(keymap, defaults, agents, new_task),
|
||||
new_worktree: resolve_local!(keymap, defaults, agents, new_worktree),
|
||||
fork: resolve_local!(keymap, defaults, agents, fork),
|
||||
rename: resolve_local!(keymap, defaults, agents, rename),
|
||||
stop: resolve_local!(keymap, defaults, agents, stop),
|
||||
archive: resolve_local!(keymap, defaults, agents, archive),
|
||||
@@ -1363,40 +1365,35 @@ impl RuntimeKeymap {
|
||||
};
|
||||
|
||||
// New defaults yield to explicit bindings, including existing list shortcuts.
|
||||
for (configured, bindings, alias) in [
|
||||
(keymap.agents.resume.as_ref(), &mut agents.resume, "o"),
|
||||
(keymap.agents.search.as_ref(), &mut agents.search, "f"),
|
||||
(keymap.agents.new_task.as_ref(), &mut agents.new_task, "n"),
|
||||
for (configured, bindings) in [
|
||||
(keymap.agents.resume.as_ref(), &mut agents.resume),
|
||||
(keymap.agents.search.as_ref(), &mut agents.search),
|
||||
(keymap.agents.new_task.as_ref(), &mut agents.new_task),
|
||||
(
|
||||
keymap.agents.new_worktree.as_ref(),
|
||||
&mut agents.new_worktree,
|
||||
"w",
|
||||
),
|
||||
(keymap.agents.rename.as_ref(), &mut agents.rename, "r"),
|
||||
(keymap.agents.stop.as_ref(), &mut agents.stop, "x"),
|
||||
(keymap.agents.archive.as_ref(), &mut agents.archive, "a"),
|
||||
(
|
||||
keymap.agents.delete.as_ref(),
|
||||
&mut agents.delete,
|
||||
"backspace",
|
||||
),
|
||||
(keymap.agents.hide.as_ref(), &mut agents.hide, "h"),
|
||||
(keymap.agents.fork.as_ref(), &mut agents.fork),
|
||||
(keymap.agents.rename.as_ref(), &mut agents.rename),
|
||||
(keymap.agents.stop.as_ref(), &mut agents.stop),
|
||||
(keymap.agents.archive.as_ref(), &mut agents.archive),
|
||||
(keymap.agents.delete.as_ref(), &mut agents.delete),
|
||||
(keymap.agents.hide.as_ref(), &mut agents.hide),
|
||||
(
|
||||
keymap.agents.toggle_grouping.as_ref(),
|
||||
&mut agents.toggle_grouping,
|
||||
"g",
|
||||
),
|
||||
] {
|
||||
if configured.is_none()
|
||||
&& (configured_context_alias_is_used(&keymap.agents, alias)
|
||||
|| configured_context_alias_is_used(&keymap.list, alias)
|
||||
|| configured_context_alias_is_used(&keymap.global, alias)
|
||||
|| chords.bindings.iter().any(|chord| {
|
||||
chord.action.context.overlaps(KeymapContext::Agents)
|
||||
&& bindings.contains(&chord.chord.prefix)
|
||||
}))
|
||||
{
|
||||
bindings.clear();
|
||||
if configured.is_none() {
|
||||
bindings.retain(|binding| {
|
||||
!configured_context_binding_is_used(&keymap.agents, *binding)
|
||||
&& !configured_context_binding_is_used(&keymap.list, *binding)
|
||||
&& !configured_context_binding_is_used(&keymap.global, *binding)
|
||||
&& !chords.bindings.iter().any(|chord| {
|
||||
chord.action.context.overlaps(KeymapContext::Agents)
|
||||
&& chord.chord.prefix == *binding
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1542,6 +1539,7 @@ impl RuntimeKeymap {
|
||||
keymap.agents.new_worktree.as_ref(),
|
||||
&mut agents.new_worktree,
|
||||
),
|
||||
(keymap.agents.fork.as_ref(), &mut agents.fork),
|
||||
(keymap.agents.rename.as_ref(), &mut agents.rename),
|
||||
(keymap.agents.stop.as_ref(), &mut agents.stop),
|
||||
(keymap.agents.archive.as_ref(), &mut agents.archive),
|
||||
@@ -1912,9 +1910,10 @@ impl RuntimeKeymap {
|
||||
},
|
||||
agents: AgentsKeymap {
|
||||
resume: default_bindings![plain(KeyCode::Char('o'))],
|
||||
search: default_bindings![plain(KeyCode::Char('f'))],
|
||||
search: default_bindings![plain(KeyCode::F(3)), plain(KeyCode::Char('/'))],
|
||||
new_task: default_bindings![plain(KeyCode::Char('n'))],
|
||||
new_worktree: default_bindings![plain(KeyCode::Char('w'))],
|
||||
fork: default_bindings![plain(KeyCode::Char('f'))],
|
||||
rename: default_bindings![plain(KeyCode::Char('r'))],
|
||||
stop: default_bindings![plain(KeyCode::Char('x'))],
|
||||
archive: default_bindings![plain(KeyCode::Char('a'))],
|
||||
@@ -2312,7 +2311,11 @@ impl RuntimeKeymap {
|
||||
"agents",
|
||||
context_bindings(KeymapContext::Agents),
|
||||
MAIN_RESERVED_BINDINGS,
|
||||
[],
|
||||
[(
|
||||
"search",
|
||||
"fixed.slash_command",
|
||||
key_hint::plain(KeyCode::Char('/')),
|
||||
)],
|
||||
)?;
|
||||
for (action, bindings) in context_bindings(KeymapContext::Agents) {
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -422,6 +422,7 @@ define_runtime_action_bindings! {
|
||||
search,
|
||||
new_task,
|
||||
new_worktree,
|
||||
fork,
|
||||
rename,
|
||||
stop,
|
||||
archive,
|
||||
|
||||
@@ -45,9 +45,11 @@ fn new_agents_defaults_preserve_existing_custom_bindings() {
|
||||
("delete", "backspace"),
|
||||
("hide", "h"),
|
||||
("new_worktree", "w"),
|
||||
("fork", "f"),
|
||||
] {
|
||||
for (context, existing, suffix) in [
|
||||
("agents", "stop", ""),
|
||||
("agents", "search", ""),
|
||||
("list", "move_down", ""),
|
||||
("global", "copy", ""),
|
||||
("agents", "stop", " f12"),
|
||||
@@ -74,6 +76,7 @@ fn new_agents_defaults_preserve_existing_custom_bindings() {
|
||||
"delete" => &runtime.agents.delete,
|
||||
"hide" => &runtime.agents.hide,
|
||||
"new_worktree" => &runtime.agents.new_worktree,
|
||||
"fork" => &runtime.agents.fork,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(
|
||||
@@ -150,3 +153,16 @@ fn warnings_defaults_preserve_custom_keys_and_chord_prefixes() {
|
||||
assert!(runtime.app.open_warnings.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_search_keeps_unshadowed_fallbacks() {
|
||||
use crate::key_hint;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
for (configured, remaining) in [("/", KeyCode::F(3)), ("f3", KeyCode::Char('/'))] {
|
||||
let keymap: TuiKeymap =
|
||||
serde_json::from_value(json!({"list": {"move_up": configured}})).unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).unwrap();
|
||||
assert_eq!(runtime.agents.search, vec![key_hint::plain(remaining)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("agents", "Agents", "search", "Search the available agent tasks."),
|
||||
action("agents", "Agents", "new_task", "Open a new session in the selected checkout."),
|
||||
action("agents", "Agents", "new_worktree", "Open a new session in a worktree from the project default branch."),
|
||||
action("agents", "Agents", "fork", "Fork the selected conversation and open the new session."),
|
||||
action("agents", "Agents", "rename", "Rename the selected task."),
|
||||
action("agents", "Agents", "stop", "Stop the selected running task."),
|
||||
action("agents", "Agents", "archive", "Archive the selected task and its child agents."),
|
||||
@@ -408,6 +409,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("agents", "search") => Some(&mut keymap.agents.search),
|
||||
("agents", "new_task") => Some(&mut keymap.agents.new_task),
|
||||
("agents", "new_worktree") => Some(&mut keymap.agents.new_worktree),
|
||||
("agents", "fork") => Some(&mut keymap.agents.fork),
|
||||
("agents", "rename") => Some(&mut keymap.agents.rename),
|
||||
("agents", "stop") => Some(&mut keymap.agents.stop),
|
||||
("agents", "archive") => Some(&mut keymap.agents.archive),
|
||||
|
||||
+2
-1
@@ -3,9 +3,10 @@ source: tui/src/keymap_setup.rs
|
||||
expression: agents
|
||||
---
|
||||
Resume | o | Agents resume Resume Open the session resume picker. o Default
|
||||
Search | f | Agents search Search Search the available agent tasks. f Default
|
||||
Search | f3, / | Agents search Search Search the available agent tasks. f3, / Default
|
||||
New Task | n | Agents new_task New Task Open a new session in the selected checkout. n Default
|
||||
New Worktree | w | Agents new_worktree New Worktree Open a new session in a worktree from the project default branch. w Default
|
||||
Fork | f | Agents fork Fork Fork the selected conversation and open the new session. f Default
|
||||
Rename | r | Agents rename Rename Rename the selected task. r Default
|
||||
Stop | x | Agents stop Stop Stop the selected running task. x Default
|
||||
Archive | a | Agents archive Archive Archive the selected task and its child agents. a Default
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
150 actions, 1 customized, 5 unbound.
|
||||
151 actions, 1 customized, 5 unbound.
|
||||
|
||||
All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
151 actions, 0 customized, 6 unbound.
|
||||
152 actions, 0 customized, 6 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (150 selectable)
|
||||
tab: All (151 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (5) (5 selectable)
|
||||
@@ -11,7 +11,7 @@ tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (69 selectable)
|
||||
tab: Navigation (21 selectable)
|
||||
tab: Agents (10 selectable)
|
||||
tab: Agents (11 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
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
150 actions, 1 customized, 6 unbound.
|
||||
151 actions, 1 customized, 6 unbound.
|
||||
|
||||
All Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
150 actions, 0 customized, 5 unbound.
|
||||
151 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor ›
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
150 actions, 0 customized, 5 unbound.
|
||||
151 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
150 actions, 0 customized, 5 unbound.
|
||||
151 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
|
||||
Reference in New Issue
Block a user