Add a hide shortcut to the agents overview (#44424)

## What changed

- Add `Ctrl+W` to hide the selected task without stopping it. Hidden tasks stay hidden through activity and metadata refreshes until explicitly resumed or the TUI restarts.
- Expose `agents.hide` in keymap configuration and the shortcut picker, and let the default binding yield to existing custom shortcuts.
- Wrap overview footer hints while keeping shortcuts and their labels together when they fit.

## Testing

Add coverage for hiding filtered tasks across statuses, visibility through activity and refreshes, explicit resume, existing keybinding compatibility, and custom chord footer rendering at narrow widths.

GitOrigin-RevId: 980726e1095b8d5bad8901be919decdde8c0f925
This commit is contained in:
Eric Traut
2026-09-10 05:41:54 +00:00
committed by copyberry
parent ddea03ad04
commit bf5ebd98c5
24 changed files with 339 additions and 24 deletions
+2
View File
@@ -443,6 +443,8 @@ pub struct TuiAgentsKeymap {
pub rename: Option<KeybindingsSpec>,
/// Stop the selected running task.
pub stop: Option<KeybindingsSpec>,
/// Hide the selected task until explicitly resumed or the TUI restarts.
pub hide: Option<KeybindingsSpec>,
/// Toggle grouping tasks by status or project.
pub toggle_grouping: Option<KeybindingsSpec>,
}
+10
View File
@@ -3993,6 +3993,7 @@
],
"default": {
"agents": {
"hide": null,
"new_task": null,
"rename": null,
"resume": null,
@@ -4303,6 +4304,14 @@
"additionalProperties": false,
"description": "Shortcuts specific to the shared agents overview.",
"properties": {
"hide": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Hide the selected task until explicitly resumed or the TUI restarts."
},
"new_task": {
"allOf": [
{
@@ -4808,6 +4817,7 @@
}
],
"default": {
"hide": null,
"new_task": null,
"rename": null,
"resume": null,
+5
View File
@@ -32,6 +32,8 @@ pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";
pub(super) struct AgentsOverviewState {
/// Missing metadata records a local resume until the next metadata refresh.
pub(super) threads: HashMap<ThreadId, Option<Thread>>,
/// Local visibility only; activity and metadata refreshes never reveal hidden roots.
pub(super) hidden_threads: HashSet<ThreadId>,
pub(super) last_messages: HashMap<ThreadId, String>,
pub(super) activity: HashMap<ThreadId, super::agents_overview_details::AgentsOverviewActivity>,
pub(super) initialized: bool,
@@ -270,6 +272,9 @@ impl App {
let Ok(thread_id) = ThreadId::from_string(&root.id) else {
continue;
};
if self.agents_overview.hidden_threads.contains(&thread_id) {
continue;
}
rows.push(AgentsOverviewRow {
details: self.agents_overview_details(root, &children),
thread: root.clone(),
@@ -0,0 +1,185 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
let mut app = make_test_app().await;
let mut keymap = TuiKeymap::default();
keymap.agents.hide = Some(KeybindingsSpec::One(KeybindingSpec("f7".into())));
app.keymap = RuntimeKeymap::from_config(&keymap).unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
for status in [
ThreadStatus::Idle,
ThreadStatus::NotLoaded,
ThreadStatus::Active {
active_flags: Vec::new(),
},
] {
app.agents_overview
.view_state
.lock()
.unwrap()
.focus_composer();
let target = ThreadId::new();
let mut view = app.agents_overview_view(
vec![
overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Other",
ThreadStatus::Idle,
),
overview_thread(target, /*parent_thread_id*/ None, "Target", status),
],
Some(target),
);
view.handle_key_event(KeyCode::Esc.into());
view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
for character in "Target".chars() {
view.handle_key_event(KeyCode::Char(character).into());
}
view.handle_key_event(KeyCode::F(7).into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::HideAgentsOverviewThread { thread_id }) if thread_id == target)
);
assert!(rx.try_recv().is_err());
view.handle_key_event(KeyCode::Esc.into());
}
}
#[tokio::test]
async fn hidden_task_stays_hidden_through_activity_and_seed_until_explicit_resume() -> Result<()> {
let (mut app, mut rx, _op_rx) = crate::app::tests::make_test_app_with_channels().await;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let started = app_server.start_thread(&app.config).await?;
let id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
let thread = overview_thread(
id,
/*parent_thread_id*/ None,
"Hidden task",
ThreadStatus::Idle,
);
app.agents_overview.threads.insert(id, Some(thread.clone()));
let mut tui = crate::tui::test_support::make_test_tui()?;
let view = app.agents_overview_view(vec![thread.clone()], Some(id));
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
let hide = std::iter::from_fn(|| rx.try_recv().ok())
.find(|event| matches!(event, AppEvent::HideAgentsOverviewThread { .. }))
.expect("shortcut requests hiding the task");
Box::pin(app.handle_event(&mut tui, &mut app_server, hide)).await?;
app.track_agents_overview_notification(&ServerNotification::ThreadStarted(
ThreadStartedNotification {
thread: thread.clone(),
},
));
app.track_agents_overview_notification(&ServerNotification::ThreadStatusChanged(
codex_app_server_protocol::ThreadStatusChangedNotification {
thread_id: id.to_string(),
status: ThreadStatus::Active {
active_flags: Vec::new(),
},
},
));
app.track_agents_overview_notification(&ServerNotification::ThreadClosed(
ThreadClosedNotification {
thread_id: id.to_string(),
},
));
let request_id = Uuid::new_v4();
app.agents_overview.initialized = false;
app.agents_overview.request_id = Some(request_id);
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
threads: HashMap::from([(id, Some(thread.clone()))]),
last_messages: HashMap::new(),
recent_seed_complete: true,
}),
);
assert_eq!(
app.agents_overview_view(vec![thread.clone()], /*selected_thread_id*/ None)
.thread_ids(),
Vec::<ThreadId>::new()
);
assert_eq!(app.primary_thread_id, Some(id));
app.resume_target_session(
&mut tui,
&mut app_server,
SessionTarget {
thread_id: id,
path: None,
cwd: None,
history_mode: None,
},
)
.await?;
assert_eq!(
app.agents_overview_view(vec![thread], /*selected_thread_id*/ None)
.thread_ids(),
vec![id]
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn lifecycle_footer_keeps_custom_chords_with_labels() {
let mut app = make_test_app().await;
app.keymap = RuntimeKeymap::from_config(
&serde_json::from_value(serde_json::json!({
"agents": { "hide": "f5 f8" }
}))
.unwrap(),
)
.unwrap();
let mut view = app.agents_overview_view(
vec![overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
)],
/*selected_thread_id*/ None,
);
view.handle_key_event(KeyCode::Esc.into());
for width in [36, 48, 80] {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width + 4, /*height*/ 24);
let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer);
let lines = buffer
.content()
.chunks(usize::from(area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim()
.to_string()
})
.collect::<Vec<_>>();
assert!(
lines
.iter()
.any(|line| line.contains("hide") && line.contains("f5"))
);
assert!(
lines
.iter()
.all(|line| unicode_width::UnicodeWidthStr::width(line.as_str())
<= usize::from(width))
);
}
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::assert_snapshot!(
"agents_custom_lifecycle_chords",
render_bottom_popup(&app.chat_widget, /*width*/ 48)
.replace(&test_path_display("/tmp/project"), "/tmp/project")
);
}
+37 -12
View File
@@ -25,6 +25,7 @@ impl AgentsOverviewView {
&self.agents_keymap.new_task,
&self.agents_keymap.rename,
&self.agents_keymap.stop,
&self.agents_keymap.hide,
&self.agents_keymap.toggle_grouping,
]
.into_iter()
@@ -46,15 +47,20 @@ impl AgentsOverviewView {
" "
},
);
let mut footer_spans = Vec::new();
let mut hints: Vec<Line<'static>> = Vec::new();
if !navigation_hint.is_empty() {
footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]);
hints.push(vec![navigation_hint.bold(), " navigate".dim()].into());
}
let mut add_hint = |hint: Option<ShortcutHint>, label: &'static str, enabled: bool| {
if let Some(hint) = hint {
let key = hint.display_label().replace(" + ", "+");
footer_spans.push(if enabled { key.bold() } else { key.dim() });
footer_spans.push(format!(" {label} ").dim());
hints.push(
vec![
if enabled { key.bold() } else { key.dim() },
format!(" {label}").dim(),
]
.into(),
);
}
};
add_hint(
@@ -99,16 +105,35 @@ impl AgentsOverviewView {
self.selected_row()
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })),
);
add_hint(
self.agents_keymap
.primary_hint("hide", &self.agents_keymap.hide),
"hide",
self.selected_row().is_some(),
);
add_hint(list_hint(ListAction::Cancel), "back", true);
let mut footer_line: Line = footer_spans.into();
if footer_line.width() > usize::from(width) {
for span in &mut footer_line.spans {
if span.content.ends_with(" ") {
span.content.to_mut().pop();
let separator = if hints.iter().map(Line::width).sum::<usize>()
+ hints.len().saturating_sub(1) * 2
<= usize::from(width)
{
" "
} else {
" "
};
crate::footer_hint::wrap_hint_rows(hints, width, separator.len(), Line::width)
.into_iter()
.flat_map(|row| {
let mut line = Line::default();
for hint in row {
if !line.spans.is_empty() {
line.spans.push(separator.dim());
}
line.spans.extend(hint.spans);
}
}
}
crate::wrapping::word_wrap_lines([footer_line], usize::from(width))
// An individual custom chord may be wider than the entire terminal.
crate::wrapping::word_wrap_lines([line], usize::from(width.max(1)))
})
.collect()
}
}
@@ -2258,3 +2258,6 @@ async fn command_center_handles_resume_failure_and_success() -> Result<()> {
server.shutdown().await?;
Ok(())
}
#[path = "agents_overview_actions_tests.rs"]
mod actions;
@@ -689,6 +689,14 @@ impl BottomPaneView for AgentsOverviewView {
}
return;
}
if self.agents_keymap.hide.is_pressed(key) {
if let Some(row) = self.selected_row() {
self.app_event_tx.send(AppEvent::HideAgentsOverviewThread {
thread_id: row.thread_id,
});
}
return;
}
if self.agents_keymap.stop.is_pressed(key) {
if let Some(row) = self.selected_row()
&& matches!(row.thread.status, ThreadStatus::Active { .. })
+4
View File
@@ -2803,6 +2803,10 @@ impl App {
}
}
}
AppEvent::HideAgentsOverviewThread { thread_id } => {
self.agents_overview.hidden_threads.insert(thread_id);
self.repaint_agents_overview();
}
AppEvent::StopAgentsOverviewThread { thread_id } => {
self.stop_agents_overview_thread(app_server, thread_id)
.await;
@@ -1191,6 +1191,10 @@ impl App {
target_session: SessionTarget,
) -> Result<AppRunControl> {
if self.ignore_same_thread_resume(&target_session) {
self.agents_overview
.hidden_threads
.remove(&target_session.thread_id);
self.repaint_agents_overview();
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
@@ -0,0 +1,28 @@
---
source: tui/src/app/agents_overview_actions_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 48)"
---
Agent command center
0 need input 0 working 1 ready
────────────────────────────────────────────
/tmp/project 1
› ○ Task Ready
New task
› Describe a new task
↑↓ navigate ctrl+o resume → open
ctrl+n new task ctrl+f search ctrl+s group
ctrl+r rename ctrl+x stop f5 f8 hide
esc back
+1
View File
@@ -1511,6 +1511,7 @@ impl App {
self.recap.reset_for_new_thread(Instant::now());
}
self.primary_thread_id = Some(thread_id);
self.agents_overview.hidden_threads.remove(&thread_id);
self.agents_overview.threads.entry(thread_id).or_default();
self.primary_session_configured = Some(session.clone());
self.upsert_agent_picker_thread(
+4
View File
@@ -310,6 +310,10 @@ pub(crate) enum AppEvent {
StopAgentsOverviewThread {
thread_id: ThreadId,
},
/// Hide a dashboard row locally without stopping its task.
HideAgentsOverviewThread {
thread_id: ThreadId,
},
/// Start the shared app-server daemon without moving the current embedded session.
#[cfg(any(unix, windows))]
StartAgentsDaemon,
+17
View File
@@ -411,6 +411,7 @@ pub(crate) struct AgentsKeymap {
pub(crate) new_task: Vec<KeyBinding>,
pub(crate) rename: Vec<KeyBinding>,
pub(crate) stop: Vec<KeyBinding>,
pub(crate) hide: Vec<KeyBinding>,
pub(crate) toggle_grouping: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>,
}
@@ -1289,10 +1290,24 @@ impl RuntimeKeymap {
new_task: resolve_local!(keymap, defaults, agents, new_task),
rename: resolve_local!(keymap, defaults, agents, rename),
stop: resolve_local!(keymap, defaults, agents, stop),
hide: resolve_local!(keymap, defaults, agents, hide),
toggle_grouping: resolve_local!(keymap, defaults, agents, toggle_grouping),
chord_hints: Arc::clone(&chords),
};
// The new default yields to existing user bindings in the dashboard.
if keymap.agents.hide.is_none()
&& (configured_context_alias_is_used(&keymap.agents, "ctrl-w")
|| configured_context_alias_is_used(&keymap.list, "ctrl-w")
|| configured_context_alias_is_used(&keymap.global, "ctrl-w")
|| chords.bindings.iter().any(|chord| {
chord.action.context.overlaps(KeymapContext::Agents)
&& agents.hide.contains(&chord.chord.prefix)
}))
{
agents.hide.clear();
}
let approval = ApprovalKeymap {
open_fullscreen: resolve_local!(keymap, defaults, approval, open_fullscreen),
open_thread: resolve_local!(keymap, defaults, approval, open_thread),
@@ -1425,6 +1440,7 @@ impl RuntimeKeymap {
(keymap.agents.new_task.as_ref(), &mut agents.new_task),
(keymap.agents.rename.as_ref(), &mut agents.rename),
(keymap.agents.stop.as_ref(), &mut agents.stop),
(keymap.agents.hide.as_ref(), &mut agents.hide),
(
keymap.agents.toggle_grouping.as_ref(),
&mut agents.toggle_grouping,
@@ -1789,6 +1805,7 @@ impl RuntimeKeymap {
new_task: default_bindings![ctrl(KeyCode::Char('n'))],
rename: default_bindings![ctrl(KeyCode::Char('r'))],
stop: default_bindings![ctrl(KeyCode::Char('x'))],
hide: default_bindings![ctrl(KeyCode::Char('w'))],
toggle_grouping: default_bindings![ctrl(KeyCode::Char('s'))],
chord_hints: Arc::default(),
},
+1
View File
@@ -398,6 +398,7 @@ define_runtime_action_bindings! {
new_task,
rename,
stop,
hide,
toggle_grouping,
],
"approval" => Approval, approval, approval [
+16
View File
@@ -37,3 +37,19 @@ See the Codex keymap documentation for supported actions and examples."
config.remove(context);
}
}
#[test]
fn new_agents_defaults_preserve_existing_custom_bindings() {
for context in ["agents", "list"] {
let existing = if context == "agents" {
"stop"
} else {
"move_down"
};
let keymap: TuiKeymap =
serde_json::from_value(json!({context: {existing: "ctrl-w"}})).unwrap();
let runtime =
RuntimeKeymap::from_config(&keymap).expect("existing configuration remains valid");
assert!(runtime.agents.hide.is_empty());
}
}
+2
View File
@@ -218,6 +218,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("agents", "Agents", "new_task", "Start composing a new agent task."),
action("agents", "Agents", "rename", "Rename the selected task."),
action("agents", "Agents", "stop", "Stop the selected running task."),
action("agents", "Agents", "hide", "Hide the selected task until explicitly resumed."),
action("agents", "Agents", "toggle_grouping", "Group tasks by status or project."),
action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."),
action("approval", "Approval", "open_thread", "Open the approval source thread when available."),
@@ -395,6 +396,7 @@ pub(super) fn binding_slot<'a>(
("agents", "new_task") => Some(&mut keymap.agents.new_task),
("agents", "rename") => Some(&mut keymap.agents.rename),
("agents", "stop") => Some(&mut keymap.agents.stop),
("agents", "hide") => Some(&mut keymap.agents.hide),
("agents", "toggle_grouping") => Some(&mut keymap.agents.toggle_grouping),
("approval", "open_fullscreen") => Some(&mut keymap.approval.open_fullscreen),
("approval", "open_thread") => Some(&mut keymap.approval.open_thread),
@@ -18,11 +18,11 @@ expression: "render_bottom_popup(&app.chat_widget, 48)"
New task
› Describe a new task
↑↓ navigate ctrl+o resume → open ctrl+n new
task ctrl+f search ctrl+s group ctrl+r
rename ctrl+x stop esc back
↑↓ navigate ctrl+o resume → open
ctrl+n new task ctrl+f search ctrl+s group
ctrl+r rename ctrl+x stop ctrl+w hide
esc back
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
142 actions, 1 customized, 5 unbound.
143 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
143 actions, 0 customized, 6 unbound.
144 actions, 0 customized, 6 unbound.
[All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -2,7 +2,7 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (142 selectable)
tab: All (143 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 (20 selectable)
tab: Agents (6 selectable)
tab: Agents (7 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.
142 actions, 1 customized, 6 unbound.
143 actions, 1 customized, 6 unbound.
[All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
142 actions, 0 customized, 5 unbound.
143 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim
Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
142 actions, 0 customized, 5 unbound.
143 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
142 actions, 0 customized, 5 unbound.
143 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug