From 7298a6e02003ec6975843dcf8068197a315793ee Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 6 Oct 2026 20:20:20 +0000 Subject: [PATCH] Add shared task pinning to the agent command center (#51500) ## What changed - Use `p` to pin or unpin the selected task, with a configurable `agents.toggle_pin` binding. - Display pinned tasks first in a `Pinned` group, preserving shared section order and applying search and status filters. - Load pins from the shared thread section, including tasks outside the recent-task window, and refresh after pin changes. Disable pinning when the server does not support shared sections. ## Testing Add coverage for shared pin/unpin state, paginated pin discovery and refreshing pins on reopen, shortcut dispatch and duplicate-action suppression, and pinned-group and custom-help snapshots. GitOrigin-RevId: d1a2cab6642d059c3c8df8480b5c59a10c493936 --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 14 ++- codex-rs/tui/src/app/agent_center/hints.rs | 6 +- codex-rs/tui/src/app/agent_center/rows.rs | 24 +++-- codex-rs/tui/src/app/agent_center_tests.rs | 16 +++- codex-rs/tui/src/app/agents_overview.rs | 23 ++++- .../tui/src/app/agents_overview_actions.rs | 92 +++++++++++++++++++ .../src/app/agents_overview_actions_tests.rs | 49 ++++++++++ .../tui/src/app/agents_overview_discovery.rs | 81 ++++++++++++++-- .../app/agents_overview_discovery_tests.rs | 52 +++++++++++ .../tui/src/app/agents_overview_grouping.rs | 53 +++++++++++ codex-rs/tui/src/app/agents_overview_tests.rs | 20 +++- .../tui/src/app/agents_overview_threads.rs | 24 +++++ .../src/app/agents_overview_usage_tests.rs | 1 + codex-rs/tui/src/app/agents_overview_view.rs | 62 +++++-------- codex-rs/tui/src/app/app_server_events.rs | 2 + codex-rs/tui/src/app/event_dispatch.rs | 14 +++ codex-rs/tui/src/app/reconnect.rs | 5 + ...__command_center__live_center_columns.snap | 14 +-- ...mmand_center__live_center_custom_help.snap | 13 ++- .../app/tests/navigation_reconnect_tests.rs | 2 + .../app/tests/session_lifecycle_requests.rs | 2 + codex-rs/tui/src/app_event.rs | 13 +++ codex-rs/tui/src/keymap.rs | 5 + codex-rs/tui/src/keymap/bindings.rs | 1 + 25 files changed, 513 insertions(+), 77 deletions(-) diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 146857bbc8..0caf64d5ea 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -503,6 +503,8 @@ pub struct TuiAgentsKeymap { pub delete: Option, /// Hide the selected task until explicitly resumed or the TUI restarts. pub hide: Option, + /// Pin or unpin the selected task in the shared sidebar section. + pub toggle_pin: Option, /// Toggle grouping tasks by status or project. pub toggle_grouping: Option, } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2511f710ed..5864a5df77 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4615,7 +4615,8 @@ "resume": null, "search": null, "stop": null, - "toggle_grouping": null + "toggle_grouping": null, + "toggle_pin": null }, "approval": { "approve": null, @@ -5040,6 +5041,14 @@ } ], "description": "Toggle grouping tasks by status or project." + }, + "toggle_pin": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Pin or unpin the selected task in the shared sidebar section." } }, "type": "object" @@ -5569,7 +5578,8 @@ "resume": null, "search": null, "stop": null, - "toggle_grouping": null + "toggle_grouping": null, + "toggle_pin": null } }, "approval": { diff --git a/codex-rs/tui/src/app/agent_center/hints.rs b/codex-rs/tui/src/app/agent_center/hints.rs index 5a4437dab9..95e06f334c 100644 --- a/codex-rs/tui/src/app/agent_center/hints.rs +++ b/codex-rs/tui/src/app/agent_center/hints.rs @@ -35,6 +35,7 @@ impl AgentsOverviewView { &self.agents_keymap.archive, &self.agents_keymap.delete, &self.agents_keymap.hide, + &self.agents_keymap.toggle_pin, &self.agents_keymap.toggle_grouping, ].into_iter().any(|bindings| bindings.contains(binding))) }) @@ -89,8 +90,11 @@ impl AgentsOverviewView { ("archive", &self.agents_keymap.archive, "Archive"), ("hide", &self.agents_keymap.hide, "Hide"), ("delete", &self.agents_keymap.delete, "Delete"), + ("toggle_pin", &self.agents_keymap.toggle_pin, "Pin/unpin"), ] { - if action != "new_worktree" || self.worktrees_enabled { + if (action != "new_worktree" || self.worktrees_enabled) + && (action != "toggle_pin" || self.can_toggle_selected_pin()) + { tasks.push(self.agents_keymap.primary_hint(action, bindings), label); } } diff --git a/codex-rs/tui/src/app/agent_center/rows.rs b/codex-rs/tui/src/app/agent_center/rows.rs index 40547085d6..bc0edb5512 100644 --- a/codex-rs/tui/src/app/agent_center/rows.rs +++ b/codex-rs/tui/src/app/agent_center/rows.rs @@ -140,15 +140,19 @@ impl AgentsOverviewView { let task = &self.rows[index]; let rect = row(viewport, offset as u16, /*height*/ 1); if matches!(entry, CenterRow::Group(_)) { - let group = match state.grouping { - AgentsOverviewGrouping::Project => { - format_directory_display( - &self.project_groups[index].heading, - /*max_width*/ None, - ) + let group = if self.is_pinned(index) { + "Pinned".to_owned() + } else { + match state.grouping { + AgentsOverviewGrouping::Project => { + format_directory_display( + &self.project_groups[index].heading, + /*max_width*/ None, + ) + } + AgentsOverviewGrouping::Status => task.group.label().to_owned(), + AgentsOverviewGrouping::Model => model_name(&task.thread).to_owned(), } - AgentsOverviewGrouping::Status => task.group.label().to_owned(), - AgentsOverviewGrouping::Model => model_name(&task.thread).to_owned(), }; let count = indices .iter() @@ -164,7 +168,9 @@ impl AgentsOverviewView { } else { format!("{count} of {total}") }; - let group = if state.grouping == AgentsOverviewGrouping::Project { + let group = if state.grouping == AgentsOverviewGrouping::Project + && !self.is_pinned(index) + { crate::text_formatting::center_truncate_path( &group, usize::from(rect.width) diff --git a/codex-rs/tui/src/app/agent_center_tests.rs b/codex-rs/tui/src/app/agent_center_tests.rs index 2db72f03d2..409523e358 100644 --- a/codex-rs/tui/src/app/agent_center_tests.rs +++ b/codex-rs/tui/src/app/agent_center_tests.rs @@ -75,8 +75,10 @@ async fn live_center_columns() { ThreadStatus::Idle, )); crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, child); + app.agents_overview.pinned_thread_ids = Some(vec![current, ThreadId::from_u128(/*value*/ 44)]); let mut view = app.agents_overview_view(threads, Some(current)); insta::assert_snapshot!(screen(&view, /*width*/ 160, /*height*/ 22)); + view.pinned_thread_ranks = None; let mut selected_status_styles = Vec::new(); for _ in 0..fixtures.len() { let area = Rect::new( @@ -466,15 +468,27 @@ page_up = [] [agents] archive = [] rename = 'z r' +toggle_pin = 'z p' "#, ) .unwrap(); app.keymap = RuntimeKeymap::from_config(&config).unwrap(); - let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.agents_overview.pinned_thread_ids = Some(Vec::new()); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); insta::assert_snapshot!( "live_center_custom_footer", screen(&view, /*width*/ 100, /*height*/ 12) ); + let thread_id = ThreadId::from_u128(/*value*/ 42); + let mut view = app.agents_overview_view( + vec![overview_thread( + thread_id, + /*parent_thread_id*/ None, + "Task", + ThreadStatus::Idle, + )], + Some(thread_id), + ); view.handle_key_event(KeyCode::Char('?').into()); insta::assert_snapshot!( "live_center_custom_help", diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index d069a6c129..41fe62b3bd 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -49,11 +49,16 @@ pub(super) struct AgentsOverviewState { pub(super) initialized: bool, pub(super) discovery: super::agents_overview_discovery::AgentsOverviewDiscovery, pub(super) show_more_requested: bool, + pub(super) refresh_show_more: bool, /// Vacancies left by lifecycle removals, filled without expanding the visible window. pub(super) refill_count: usize, pub(super) request_id: Option, pub(super) refresh_pending: bool, + pub(super) pin_refresh_requested: bool, pub(super) refresh_thread_ids: HashSet, + pub(super) active_refresh_thread_ids: HashSet, + pub(super) pinned_thread_ids: Option>, + pub(super) pending_pin_change: Option, pub(super) refresh_task: Option, pub(super) refresh_notifications: HashMap>, pub(super) rendered_full_screen: bool, @@ -170,6 +175,8 @@ impl App { return; } self.agents_overview.request_id = None; + self.agents_overview.refresh_show_more = false; + self.agents_overview.active_refresh_thread_ids.clear(); self.agents_overview.refresh_task = None; let refill_succeeded = result .as_ref() @@ -185,6 +192,9 @@ impl App { } match result { Ok(refresh) => { + if let Some(pinned_thread_ids) = refresh.pinned_thread_ids { + self.agents_overview.pinned_thread_ids = pinned_thread_ids; + } self.agents_overview.initialized = refresh.recent_seed_complete; if let Some(discovery) = refresh.discovery { if !discovery.has_more() { @@ -380,7 +390,7 @@ impl App { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .vim_enabled = self.chat_widget.composer_is_vim_enabled(); - AgentsOverviewView::new( + let mut view = AgentsOverviewView::new( rows, selected_thread_id, self.config.features.enabled(Feature::Worktrees) @@ -392,7 +402,16 @@ impl App { self.app_event_tx.clone(), self.keymap.clone(), Arc::clone(&self.agents_overview.view_state), - ) + ); + view.pinned_thread_ranks = self.agents_overview.pinned_thread_ids.as_ref().map(|ids| { + ids.iter() + .copied() + .enumerate() + .map(|(rank, id)| (id, rank)) + .collect() + }); + view.pin_action_pending = self.agents_overview.pending_pin_change.is_some(); + view } pub(super) async fn select_agents_overview_thread( diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index 0a7472c756..9a03223034 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -12,10 +12,15 @@ use crate::chatwidget::ChatWidget; use crate::render::renderable::Renderable; use crate::tui; use crate::wrapping::word_wrap_lines; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadSectionMoveParams; +use codex_app_server_protocol::ThreadSectionMoveResponse; use codex_protocol::ThreadId; use codex_protocol::protocol::SubAgentSource; +use codex_state::PINNED_THREAD_SECTION_ID; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; @@ -25,6 +30,7 @@ use std::collections::HashSet; use std::future::Future; use tokio_stream::Stream; use tokio_stream::StreamExt; +use uuid::Uuid; struct LifecycleHeader(Vec>); @@ -104,6 +110,90 @@ async fn run_lifecycle_modal( } impl App { + pub(super) fn toggle_agents_overview_pin( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + pinned: bool, + ) { + if self.agents_overview.pending_pin_change.is_some() { + return; + } + let request_id = Uuid::new_v4(); + self.agents_overview.pending_pin_change = Some(request_id); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = request_handle + .request_typed::(ClientRequest::ThreadSectionMove { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadSectionMoveParams { + thread_id: thread_id.to_string(), + section_id: pinned.then(|| PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, + }, + }) + .await + .map(|_| ()) + .map_err(|error| error.to_string()); + app_event_tx.send(AppEvent::AgentsOverviewPinToggled { + request_id, + thread_id, + pinned, + result, + }); + }); + } + + pub(super) fn complete_agents_overview_pin( + &mut self, + app_server: &AppServerSession, + request_id: Uuid, + thread_id: ThreadId, + pinned: bool, + result: Result<(), String>, + ) { + if self.agents_overview.pending_pin_change != Some(request_id) { + return; + } + self.agents_overview.pending_pin_change = None; + match result { + Ok(()) => { + if let Some(pinned_thread_ids) = self.agents_overview.pinned_thread_ids.as_mut() { + if pinned { + if !pinned_thread_ids.contains(&thread_id) { + pinned_thread_ids.push(thread_id); + } + } else { + pinned_thread_ids.retain(|id| *id != thread_id); + } + } + } + Err(error) => { + self.add_agents_overview_error(format!( + "Failed to {} task: {error}", + if pinned { "pin" } else { "unpin" } + )); + } + } + let restore_show_more = self.agents_overview.refresh_show_more; + if let Some(refresh) = self.agents_overview.refresh_task.take() { + refresh.abort(); + } + self.agents_overview.request_id = None; + self.agents_overview.refresh_show_more = false; + self.agents_overview.refresh_notifications.clear(); + let refresh_thread_ids = + std::mem::take(&mut self.agents_overview.active_refresh_thread_ids); + self.agents_overview + .refresh_thread_ids + .extend(refresh_thread_ids); + self.agents_overview.refresh_pending = false; + self.agents_overview.show_more_requested |= restore_show_more; + self.refresh_agents_overview_threads(app_server); + self.repaint_agents_overview(); + } + pub(super) fn confirm_agents_overview_action( &mut self, thread_id: ThreadId, @@ -227,6 +317,8 @@ impl App { refresh.abort(); } self.agents_overview.request_id = None; + self.agents_overview.refresh_show_more = false; + self.agents_overview.active_refresh_thread_ids.clear(); self.agents_overview .view_state .lock() diff --git a/codex-rs/tui/src/app/agents_overview_actions_tests.rs b/codex-rs/tui/src/app/agents_overview_actions_tests.rs index fa9cc71619..5098d0c28b 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app::agents_overview_discovery::list_pinned_threads; use crate::app_event::AgentsOverviewAction; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::RequestId; @@ -11,6 +12,51 @@ use core_test_support::streaming_sse::StreamingSseChunk; use core_test_support::streaming_sse::start_streaming_sse_server; use pretty_assertions::assert_eq; +#[tokio::test] +async fn pin_action_updates_shared_section_state() -> Result<()> { + let mut app = make_test_app().await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + let mut tui = crate::tui::test_support::make_test_tui()?; + + for pinned in [true, false] { + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ToggleAgentsOverviewPin { thread_id, pinned }, + )) + .await?; + assert!(app.agents_overview.pending_pin_change.is_some()); + let completed = rx.recv().await.expect("pin request completes"); + assert!(matches!( + &completed, + AppEvent::AgentsOverviewPinToggled { .. } + )); + app.agents_overview + .refresh_notifications + .insert(thread_id, Vec::new()); + Box::pin(app.handle_event(&mut tui, &mut app_server, completed)).await?; + assert_eq!(app.agents_overview.pending_pin_change, None); + assert!(app.agents_overview.refresh_notifications.is_empty()); + let threads = list_pinned_threads(&app_server.request_handle()) + .await + .map_err(color_eyre::eyre::Report::msg)? + .expect("embedded app server supports shared thread sections"); + assert_eq!( + threads + .iter() + .any(|thread| thread.id == started.session.thread_id.to_string()), + pinned + ); + } + + app_server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn archive_confirmation_number_keys_act_immediately() { for key in ['1', '2'] { @@ -586,6 +632,7 @@ async fn hidden_task_stays_hidden_through_activity_and_seed_until_explicit_resum last_messages: HashMap::new(), recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), ); assert_eq!( @@ -865,6 +912,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb last_messages: HashMap::new(), recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), ); assert_eq!( @@ -967,6 +1015,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb last_messages: HashMap::new(), recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), }); Box::pin(app.handle_event(&mut tui, &mut app_server, confirmed)).await?; diff --git a/codex-rs/tui/src/app/agents_overview_discovery.rs b/codex-rs/tui/src/app/agents_overview_discovery.rs index 13eb20d487..d14c4c01ce 100644 --- a/codex-rs/tui/src/app/agents_overview_discovery.rs +++ b/codex-rs/tui/src/app/agents_overview_discovery.rs @@ -12,11 +12,82 @@ use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; use codex_protocol::protocol::SubAgentSource; +use codex_state::PINNED_THREAD_SECTION_ID; use std::collections::VecDeque; use uuid::Uuid; const PAGE_SIZE: u32 = 10; +pub(crate) async fn list_pinned_threads( + handle: &AppServerRequestHandle, +) -> Result>, String> { + let mut threads = Vec::new(); + for source_kinds in [ + Vec::new(), + vec![ThreadSourceKind::Exec, ThreadSourceKind::AppServer], + ] { + let mut cursor = None; + loop { + let result = handle + .request_typed::(ClientRequest::ThreadList { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ThreadListParams { + originators: None, + cursor: cursor.clone(), + limit: Some(100), + sort_key: Some(ThreadSortKey::SectionPosition), + sort_direction: None, + model_providers: Some(Vec::new()), + source_kinds: Some(source_kinds.clone()), + archived: Some(false), + section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())), + project_id: None, + parent_thread_id: None, + ancestor_thread_id: None, + cwd: None, + use_state_db_only: true, + search_term: None, + }, + }) + .await; + match result { + Ok(page) => { + threads.extend(page.data.into_iter().filter(|thread| { + is_overview_thread(thread) && supports_shared_pinning(&thread.source) + })); + let Some(next_cursor) = page.next_cursor else { + break; + }; + cursor = Some(next_cursor); + } + Err(TypedRequestError::Server { source, .. }) + if matches!(source.code, -32602..=-32600) => + { + return Ok(None); + } + Err(error) => return Err(error.to_string()), + } + } + } + Ok(Some(threads)) +} + +fn is_overview_thread(thread: &Thread) -> bool { + !thread.ephemeral + && thread.parent_thread_id.is_none() + && !matches!( + thread.source, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) + ) +} + +pub(super) fn supports_shared_pinning(source: &SessionSource) -> bool { + matches!( + source, + SessionSource::Cli | SessionSource::VsCode | SessionSource::Exec | SessionSource::AppServer + ) || matches!(source, SessionSource::Custom(source) if matches!(source.as_str(), "atlas" | "chatgpt")) +} + #[derive(Clone, Debug)] struct SourcePage { cursor: Option, @@ -128,14 +199,8 @@ impl SourcePage { Ok(page) => { self.exhausted = page.next_cursor.is_none(); self.cursor = page.next_cursor; - self.rows.extend(page.data.into_iter().filter(|thread| { - !thread.ephemeral - && thread.parent_thread_id.is_none() - && !matches!( - thread.source, - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) - ) - })); + self.rows + .extend(page.data.into_iter().filter(is_overview_thread)); return true; } Err(error) => { diff --git a/codex-rs/tui/src/app/agents_overview_discovery_tests.rs b/codex-rs/tui/src/app/agents_overview_discovery_tests.rs index f8912101ec..f7e8152b21 100644 --- a/codex-rs/tui/src/app/agents_overview_discovery_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_discovery_tests.rs @@ -7,6 +7,9 @@ use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use tokio::net::TcpListener; use tokio_tungstenite::tungstenite::Message; @@ -47,12 +50,21 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { ) }) .collect(); + let reopened_pin = overview_thread( + ThreadId::new(), + /*parent_thread_id*/ None, + "Pinned after reopen", + ThreadStatus::NotLoaded, + ); + let reopened_pin_id = ThreadId::from_string(&reopened_pin.id).unwrap(); + let show_reopened_pin = Arc::new(AtomicBool::new(false)); let expected_first_twenty: HashSet<_> = rows[1..21] .iter() .map(|row| ThreadId::from_string(&row.id).unwrap()) .collect(); let listener = TcpListener::bind("127.0.0.1:0").await?; let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + let server_show_reopened_pin = Arc::clone(&show_reopened_pin); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await?; let mut socket = tokio_tungstenite::accept_async(stream).await?; @@ -66,6 +78,34 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { "initialize" => json!({"userAgent": "overview-test/1.0"}), "thread/loaded/list" => json!({"data": [], "nextCursor": null}), "thread/list" => { + if params["sortKey"] == "section_position" { + assert_eq!(params["sectionId"], codex_state::PINNED_THREAD_SECTION_ID); + let interactive = params["sourceKinds"] == json!([]); + let cursor = params["cursor"].as_str(); + let reopened = server_show_reopened_pin.load(Ordering::SeqCst); + let data = if reopened && interactive && cursor.is_none() { + vec![reopened_pin.clone()] + } else if interactive && cursor == Some("pinned-page-2") { + vec![rows[1].clone()] + } else { + Vec::new() + }; + socket + .send(Message::Text( + json!({ + "id": request.id, + "result": { + "data": data, + "nextCursor": (!reopened && interactive && cursor.is_none()) + .then_some("pinned-page-2"), + } + }) + .to_string() + .into(), + )) + .await?; + continue; + } assert_eq!(params["sortKey"], "recency_at"); assert_eq!(params["limit"], 10); { @@ -113,6 +153,7 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { app.refresh_agents_overview_threads(&session); finish_overview_refresh(&mut app, &session, &mut rx).await; assert_eq!(app.agents_overview.threads.len(), 10); + assert_eq!(app.agents_overview.pinned_thread_ids, Some(vec![ids[1]])); assert!(app.agents_overview.discovery.has_more()); app.track_agents_overview_notification(&ServerNotification::ThreadArchived( ThreadArchivedNotification { @@ -162,6 +203,17 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { finish_overview_refresh(&mut app, &session, &mut rx).await; assert_eq!(app.agents_overview.threads, expected); assert!(!app.agents_overview.discovery.has_more()); + app.agents_overview.threads.clear(); + app.agents_overview.pinned_thread_ids = Some(Vec::new()); + app.agents_overview.refill_count = 0; + show_reopened_pin.store(true, Ordering::SeqCst); + app.refresh_agents_overview_threads(&session); + finish_overview_refresh(&mut app, &session, &mut rx).await; + assert_eq!( + app.agents_overview.pinned_thread_ids, + Some(vec![reopened_pin_id]) + ); + assert!(app.agents_overview.threads.contains_key(&reopened_pin_id)); session.shutdown().await?; assert_eq!( server.await??, diff --git a/codex-rs/tui/src/app/agents_overview_grouping.rs b/codex-rs/tui/src/app/agents_overview_grouping.rs index f6465e6fd5..767cd3c1f9 100644 --- a/codex-rs/tui/src/app/agents_overview_grouping.rs +++ b/codex-rs/tui/src/app/agents_overview_grouping.rs @@ -18,12 +18,65 @@ pub(super) fn model_name(thread: &Thread) -> &str { } impl AgentsOverviewView { + pub(super) fn is_pinned(&self, index: usize) -> bool { + self.pinned_thread_ranks + .as_ref() + .is_some_and(|ranks| ranks.contains_key(&self.rows[index].thread_id)) + } + + pub(super) fn visible_indices(&self) -> Vec { + let state = self.state(); + let search = state.search.to_lowercase(); + let (_, status_group) = super::command_center::TASK_FILTERS[state.status_filter]; + let (mut pinned, mut visible): (Vec<_>, Vec<_>) = self + .rows + .iter() + .enumerate() + .filter_map(|(index, row)| { + let searchable = format!( + "{} {} {}", + row.thread.name.as_deref().unwrap_or_default(), + row.thread.preview, + row.thread.cwd.display(), + ) + .to_lowercase(); + ((search.is_empty() || searchable.contains(&search)) + && (state.rename_target == Some(row.thread_id) + || status_group.is_none_or(|group| group == row.group))) + .then_some(index) + }) + .partition(|index| self.is_pinned(*index)); + if let Some(ranks) = &self.pinned_thread_ranks { + pinned.sort_by_key(|index| ranks.get(&self.rows[*index].thread_id).copied()); + } + match state.grouping { + AgentsOverviewGrouping::Project => visible.sort_by_key(|index| { + ( + &self.project_groups[*index].key, + std::cmp::Reverse(self.rows[*index].thread.updated_at), + ) + }), + AgentsOverviewGrouping::Status => {} + AgentsOverviewGrouping::Model => visible.sort_by_key(|index| { + ( + model_name(&self.rows[*index].thread), + std::cmp::Reverse(self.rows[*index].thread.updated_at), + ) + }), + } + pinned.extend(visible); + pinned + } + pub(super) fn same_group( &self, grouping: AgentsOverviewGrouping, left: usize, right: usize, ) -> bool { + if self.is_pinned(left) || self.is_pinned(right) { + return self.is_pinned(left) && self.is_pinned(right); + } match grouping { AgentsOverviewGrouping::Project => { self.project_groups[left].key == self.project_groups[right].key diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 787b4b81eb..f5313a25c3 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -376,6 +376,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re ]), recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), ); retained_thread.name = Some("New name".to_string()); @@ -396,6 +397,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re threads: HashMap::from([(retained, None)]), recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), ); assert_eq!(app.agents_overview.threads, expected); @@ -445,6 +447,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re last_messages, recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), ); assert!(app.agents_overview.last_messages.is_empty()); @@ -2697,6 +2700,7 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res last_messages: HashMap::new(), recent_seed_complete: false, discovery: None, + pinned_thread_ids: None, }), ] { let request_id = Uuid::new_v4(); @@ -2728,6 +2732,7 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res last_messages: HashMap::new(), recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), ); assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 48), before); @@ -3023,7 +3028,18 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone( ThreadStatus::Idle, ); target.cwd = test_path_buf("/tmp/checkout/subdir").abs(); + app.agents_overview.pinned_thread_ids = Some(Vec::new()); let mut view = app.agents_overview_view(vec![target.clone()], Some(id)); + view.handle_key_event(KeyCode::Char('p').into()); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::ToggleAgentsOverviewPin { + thread_id, + pinned: true, + }) if thread_id == id + )); + view.handle_key_event(KeyCode::Char('p').into()); + assert!(rx.try_recv().is_err()); for _ in 0..3 { view.handle_key_event(KeyCode::Char('n').into()); assert!( @@ -3040,13 +3056,13 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone( )); } view.handle_key_event(KeyCode::Char('r').into()); - for character in "nwogrxfha".chars() { + for character in "nwogrxfhap".chars() { view.handle_key_event(KeyCode::Char(character).into()); } assert!(rx.try_recv().is_err()); view.handle_key_event(KeyCode::Enter.into()); assert!( - matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name.ends_with("nwogrxfha")) + matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name.ends_with("nwogrxfhap")) ); let mut empty = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); empty.handle_key_event(KeyCode::Char('n').into()); diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index 8a83aba490..ab3e56f242 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -5,6 +5,7 @@ use super::App; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use super::agents_overview_details::preview_agent_message; +use super::agents_overview_discovery::list_pinned_threads; use super::app_server_event_targets::ServerNotificationThreadTarget; use super::app_server_event_targets::server_notification_thread_target; use crate::AppServerTarget; @@ -218,6 +219,7 @@ impl App { } pub(super) fn refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) { + self.agents_overview.pin_refresh_requested = true; self.agents_overview.refresh_thread_ids.extend( self.agents_overview .threads @@ -266,16 +268,19 @@ impl App { && self.agents_overview.refresh_thread_ids.is_empty() && !self.agents_overview.show_more_requested && !(self.agents_overview.refill_count > 0 && self.agents_overview.discovery.has_more()) + && !self.agents_overview.pin_refresh_requested { return; } + self.agents_overview.pin_refresh_requested = false; let request_id = Uuid::new_v4(); self.agents_overview.request_id = Some(request_id); let initialized = self.agents_overview.initialized; let refill = self.agents_overview.refill_count; let show_more = refill == 0 && std::mem::take(&mut self.agents_overview.show_more_requested); + self.agents_overview.refresh_show_more = show_more; let discover = !initialized || show_more || (refill > 0 && self.agents_overview.discovery.has_more()); let limit = if initialized && refill > 0 { @@ -292,6 +297,7 @@ impl App { } }); let mut thread_ids = std::mem::take(&mut self.agents_overview.refresh_thread_ids); + self.agents_overview.active_refresh_thread_ids = thread_ids.clone(); let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); self.agents_overview @@ -304,6 +310,23 @@ impl App { let mut threads = HashMap::new(); let mut last_messages = HashMap::new(); let mut recent_seed_complete = true; + let pinned_thread_ids = match list_pinned_threads(&request_handle).await { + Ok(Some(pinned_threads)) => { + let mut ids = Vec::with_capacity(pinned_threads.len()); + for thread in pinned_threads { + if let Ok(thread_id) = ThreadId::from_string(&thread.id) { + ids.push(thread_id); + threads.insert(thread_id, Some(thread)); + } + } + Some(Some(ids)) + } + Ok(None) => Some(None), + Err(error) => { + tracing::warn!(%error, "failed to list pinned agent threads"); + None + } + }; if let Some(discovery) = &mut discovery { let loaded = async { if initialized { @@ -423,6 +446,7 @@ impl App { last_messages, recent_seed_complete, discovery, + pinned_thread_ids, }) } .await; diff --git a/codex-rs/tui/src/app/agents_overview_usage_tests.rs b/codex-rs/tui/src/app/agents_overview_usage_tests.rs index 15ade1480a..b8c321a304 100644 --- a/codex-rs/tui/src/app/agents_overview_usage_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_usage_tests.rs @@ -103,6 +103,7 @@ async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> R last_messages: HashMap::new(), recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), ); assert_eq!( diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 275fc92c6a..094bd63c11 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -12,6 +12,7 @@ use grouping::model_name; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use super::agents_overview_details::AgentsOverviewDetails; +use super::agents_overview_discovery::supports_shared_pinning; use crate::app_event::AgentsOverviewAction; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -54,6 +55,7 @@ use ratatui::text::Span; use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -181,6 +183,8 @@ pub(super) struct AgentsOverviewView { use_theme_colors: bool, pub(super) rows: Vec, project_groups: Vec, + pub(super) pinned_thread_ranks: Option>, + pub(super) pin_action_pending: bool, selected: usize, state: Arc>, app_event_tx: AppEventSender, @@ -251,6 +255,8 @@ impl AgentsOverviewView { use_theme_colors, rows, project_groups, + pinned_thread_ranks: None, + pin_action_pending: false, selected, state, app_event_tx, @@ -302,44 +308,12 @@ impl AgentsOverviewView { .filter(|_| self.visible_indices().contains(&self.selected)) } - fn visible_indices(&self) -> Vec { - let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); - let search = state.search.to_lowercase(); - let (_, status_group) = command_center::TASK_FILTERS[state.status_filter]; - let mut visible = self - .rows - .iter() - .enumerate() - .filter_map(|(index, row)| { - let searchable = format!( - "{} {} {}", - row.thread.name.as_deref().unwrap_or_default(), - row.thread.preview, - row.thread.cwd.display(), - ) - .to_lowercase(); - ((search.is_empty() || searchable.contains(&search)) - && (state.rename_target == Some(row.thread_id) - || status_group.is_none_or(|group| group == row.group))) - .then_some(index) - }) - .collect::>(); - match state.grouping { - AgentsOverviewGrouping::Project => visible.sort_by_key(|index| { - ( - &self.project_groups[*index].key, - std::cmp::Reverse(self.rows[*index].thread.updated_at), - ) - }), - AgentsOverviewGrouping::Status => {} - AgentsOverviewGrouping::Model => visible.sort_by_key(|index| { - ( - model_name(&self.rows[*index].thread), - std::cmp::Reverse(self.rows[*index].thread.updated_at), - ) - }), - } - visible + fn can_toggle_selected_pin(&self) -> bool { + !self.pin_action_pending + && self.pinned_thread_ranks.is_some() + && self + .selected_row() + .is_some_and(|row| supports_shared_pinning(&row.thread.source)) } fn move_selection(&mut self, forward: bool) { @@ -682,6 +656,18 @@ impl BottomPaneView for AgentsOverviewView { .send(AppEvent::PersistAgentsOverviewGrouping(state.grouping)); return; } + if self.agents_keymap.toggle_pin.is_pressed(key) { + if self.can_toggle_selected_pin() + && let (Some(ranks), Some(row)) = (&self.pinned_thread_ranks, self.selected_row()) + { + let thread_id = row.thread_id; + let pinned = !ranks.contains_key(&thread_id); + self.pin_action_pending = true; + self.app_event_tx + .send(AppEvent::ToggleAgentsOverviewPin { thread_id, pinned }); + } + return; + } if self.agents_keymap.new_task.is_pressed(key) { self.app_event_tx.send(AppEvent::NewAgentsOverviewSession { cwd: self.selected_row().map(|row| row.thread.cwd.clone()), diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index e775a70e1c..560f5d564b 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -78,6 +78,8 @@ impl App { task.abort(); } self.agents_overview.request_id = None; + self.agents_overview.refresh_show_more = false; + self.agents_overview.active_refresh_thread_ids.clear(); self.agents_overview.refresh_pending = false; self.agents_overview.initialized = false; self.agents_overview.refresh_notifications.clear(); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b5466216a5..2979e0a9ab 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -67,6 +67,7 @@ impl App { | AppEvent::FinishPromptRevert { .. } | AppEvent::ManagedWorktreeCreated(_) | AppEvent::AgentsOverviewWorktreeCreated(_) + | AppEvent::AgentsOverviewPinToggled { .. } | AppEvent::AppendMessageHistoryEntry { .. } | AppEvent::BeginInitialHistoryReplayBuffer | AppEvent::BeginThreadSwitchHistoryReplayBuffer @@ -2830,6 +2831,19 @@ impl App { } } } + AppEvent::ToggleAgentsOverviewPin { thread_id, pinned } => { + self.toggle_agents_overview_pin(app_server, thread_id, pinned); + } + AppEvent::AgentsOverviewPinToggled { + request_id, + thread_id, + pinned, + result, + } => { + self.complete_agents_overview_pin( + app_server, request_id, thread_id, pinned, result, + ); + } AppEvent::SuggestThreadName { thread_id, request_id, diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 25872123b0..b7d6e2637e 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -214,8 +214,11 @@ impl App { task.abort(); } self.agents_overview.request_id = None; + self.agents_overview.refresh_show_more = false; + self.agents_overview.active_refresh_thread_ids.clear(); self.agents_overview.refresh_pending = false; self.agents_overview.refresh_notifications.clear(); + self.agents_overview.pending_pin_change = None; self.agents_overview.pending_usage = None; self.agents_overview.usage_disabled = false; self.agents_overview.usage.clear(); @@ -357,6 +360,8 @@ impl App { self.sync_thread_title_progress(); self.agents_overview.dispatched_requests.clear(); self.agents_overview.request_id = None; + self.agents_overview.refresh_show_more = false; + self.agents_overview.active_refresh_thread_ids.clear(); self.agents_overview.refresh_pending = false; for input in self.agents_overview.input_states.values_mut() { input.reconnect_pending = true; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_columns.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_columns.snap index 104ad10795..8f331bb8dc 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_columns.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_columns.snap @@ -6,15 +6,15 @@ expression: "screen(&view, 160, 22)" All 6 Needs you 2 Working 1 Ready 2 Inactive 1 tab/⇧tab filter ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Tasks Status Updated │ Task details - /tmp/project 6 │ + Pinned 2 │ › ○ Current task Ready now │ Current task - ○ A longer task title that uses the available column voice Ready 5m ago │ ○ Ready + ● Needs an answer Needs input 2h ago │ ○ Ready + │ + /tmp/project 4 │ Model: Unknown + ○ A longer task title that uses the available column voice Ready 5m ago │ Reasoning: Unknown ! Failed task Error 1h ago │ - ● Needs an answer Needs input 2h ago │ Model: Unknown - ● Working task Working 1d ago │ Reasoning: Unknown - ○ Unloaded task Inactive 2d ago │ - │ Project - │ /tmp/project + ● Working task Working 1d ago │ Project + ○ Unloaded task Inactive 2d ago │ /tmp/project │ │ Prompt │ Current task diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_custom_help.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_custom_help.snap index d9a12f8533..ff08930897 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_custom_help.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_custom_help.snap @@ -1,22 +1,21 @@ --- source: tui/src/app/agent_center_tests.rs -assertion_line: 450 expression: "screen(&view, 100, 24)" --- Agent command center Group: Project g - All 0 Needs you 0 Working 0 Ready 0 Inactive 0 tab/⇧tab filter + All 1 Needs you 0 Working 0 Ready 1 Inactive 0 tab/⇧tab filter ──────────────────────────────────────────────────────────────────────────────────────────────── Task shortcuts - Navigate Tasks View - k Up n New tab/⇧tab Filter - j Down o Resume f3 Search - enter Open f Fork g Group + Navigate Tasks View + k Up n New tab/⇧tab Filter + j Down o Resume f3 Search + enter Open f Fork g Group pgdn Page down z r Rename ⌃c Quit x Stop h Hide backspace Delete - + z p Pin/unpin diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index b2f5b3eb29..398a9c1dfa 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -284,6 +284,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv threads: HashMap::new(), recent_seed_complete: false, discovery: None, + pinned_thread_ids: None, }), ); assert_eq!(app.agents_overview.visible_thread_ids.len(), 2); @@ -436,6 +437,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv threads: stale_threads, recent_seed_complete: true, discovery: None, + pinned_thread_ids: None, }), ); assert!(!app.agents_overview.visible_thread_ids.contains(&vanished)); diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 2e518843b7..b40809456f 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -634,6 +634,7 @@ pub(super) async fn start_recording_app_server_with_realtime_speech( }, }) } else if request.method == "thread/list" + && params.is_some_and(|params| params["sortKey"] == "recency_at") && std::mem::take(&mut reject_thread_list) { JSONRPCMessage::Error(JSONRPCError { @@ -3297,6 +3298,7 @@ async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable let mut sort_keys = list_requests .iter() .map(|params| params["sortKey"].as_str().unwrap()) + .filter(|sort_key| *sort_key != "section_position") .collect::>(); sort_keys.sort_unstable(); assert_eq!(sort_keys, expected_sort_keys); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 694c0b20aa..4c6ede335f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -275,6 +275,7 @@ pub(crate) struct AgentsOverviewThreadRefresh { pub(crate) last_messages: std::collections::HashMap, pub(crate) recent_seed_complete: bool, pub(crate) discovery: Option, + pub(crate) pinned_thread_ids: Option>>, } #[derive(Debug, Default)] @@ -335,6 +336,18 @@ pub(crate) enum AppEvent { thread_id: ThreadId, name: String, }, + /// Move a task into or out of the shared pinned section. + ToggleAgentsOverviewPin { + thread_id: ThreadId, + pinned: bool, + }, + /// Finish moving a task into or out of the shared pinned section. + AgentsOverviewPinToggled { + request_id: Uuid, + thread_id: ThreadId, + pinned: bool, + result: Result<(), String>, + }, /// Generate an editable title suggestion for the active rename prompt. SuggestThreadName { thread_id: ThreadId, diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 62d60a271a..047473ad51 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -432,6 +432,7 @@ pub(crate) struct AgentsKeymap { pub(crate) archive: Vec, pub(crate) delete: Vec, pub(crate) hide: Vec, + pub(crate) toggle_pin: Vec, pub(crate) toggle_grouping: Vec, chord_hints: Arc, } @@ -1361,6 +1362,7 @@ impl RuntimeKeymap { archive: resolve_local!(keymap, defaults, agents, archive), delete: resolve_local!(keymap, defaults, agents, delete), hide: resolve_local!(keymap, defaults, agents, hide), + toggle_pin: resolve_local!(keymap, defaults, agents, toggle_pin), toggle_grouping: resolve_local!(keymap, defaults, agents, toggle_grouping), chord_hints: Arc::clone(&chords), }; @@ -1380,6 +1382,7 @@ impl RuntimeKeymap { (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_pin.as_ref(), &mut agents.toggle_pin), ( keymap.agents.toggle_grouping.as_ref(), &mut agents.toggle_grouping, @@ -1546,6 +1549,7 @@ impl RuntimeKeymap { (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_pin.as_ref(), &mut agents.toggle_pin), ( keymap.agents.toggle_grouping.as_ref(), &mut agents.toggle_grouping, @@ -1920,6 +1924,7 @@ impl RuntimeKeymap { archive: default_bindings![plain(KeyCode::Char('a'))], delete: default_bindings![plain(KeyCode::Backspace)], hide: default_bindings![plain(KeyCode::Char('h'))], + toggle_pin: default_bindings![plain(KeyCode::Char('p'))], toggle_grouping: default_bindings![plain(KeyCode::Char('g'))], chord_hints: Arc::default(), }, diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 52a8703dd2..3e044f2437 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -428,6 +428,7 @@ define_runtime_action_bindings! { archive, delete, hide, + toggle_pin, toggle_grouping, ], "approval" => Approval, approval, approval [