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:
Eric Traut
2026-09-30 07:15:14 +00:00
committed by copyberry
parent bcd6d9ab6b
commit d42056091a
32 changed files with 378 additions and 86 deletions
+2
View File
@@ -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.
+10
View File
@@ -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"),
+16 -14
View File
@@ -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",
+1 -1
View File
@@ -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)
);
}
+154 -4
View File
@@ -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();
+49 -4
View File
@@ -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();
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
+1 -1
View File
@@ -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));
+8
View File
@@ -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
View File
@@ -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)]
+1
View File
@@ -422,6 +422,7 @@ define_runtime_action_bindings! {
search,
new_task,
new_worktree,
fork,
rename,
stop,
archive,
+16
View File
@@ -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)]);
}
}
+2
View File
@@ -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),
@@ -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
@@ -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 ›
@@ -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,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
@@ -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 ›
@@ -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 ›
@@ -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 ›