Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
  • Loading branch information
etraut-openai authored and copyberry committed Sep 30, 2026
commit d42056091aded7feb1d88ac7e83972108b2aa478
2 changes: 2 additions & 0 deletions codex-rs/config/src/tui_keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4435,6 +4435,7 @@
"agents": {
"archive": null,
"delete": null,
"fork": null,
"hide": null,
"new_task": null,
"new_worktree": null,
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -5373,6 +5382,7 @@
"default": {
"archive": null,
"delete": null,
"fork": null,
"hide": null,
"new_task": null,
"new_worktree": null,
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/app/agent_center/hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
30 changes: 16 additions & 14 deletions codex-rs/tui/src/app/agent_center_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]
Expand All @@ -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());
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/app/agents_overview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
34 changes: 32 additions & 2 deletions codex-rs/tui/src/app/agents_overview_actions_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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());
}
Expand All @@ -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)
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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)
);
}
Loading
Loading