Simplify agent command center hints and back navigation (#46840)

## Why

The command center's shortcut footer consumes several rows in narrow terminals, leaving less space for tasks. The overview also ignores attempts to dismiss it with `Esc`.

## What changed

- Keep primary actions in a single-line footer and show task and navigation shortcuts in a `?` help view.
- Respect configured bindings, suppress conflicting hints, and ignore key repeats when toggling help.
- Let the configured cancel key close help or cancel search and rename before dismissing the overview.
- Track overview dismissal while disconnected and clear stale connection notices after reconnecting.
- Use picker defaults for task action and error dialogs, and the theme accent color for agent status titles.

## Testing

Add coverage for help rendering, cancel navigation, custom shortcut conflicts, and overview dismissal during reconnection. Update snapshots for the compact footer and narrow layouts.

GitOrigin-RevId: 457b1e667dd87be2e8216936c5402eb6ab70fde1
This commit is contained in:
Eric Traut
2026-09-20 16:44:47 +00:00
committed by copyberry
parent eef77aa211
commit 724cdfbd32
34 changed files with 384 additions and 506 deletions
+7
View File
@@ -965,6 +965,13 @@ impl App {
{
if self.reconnect.presentation == reconnect::ReconnectPresentation::Overview {
self.chat_widget.handle_disconnected_view_key(*key);
if self
.chat_widget
.selected_index_for_present_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID)
.is_none()
{
self.reconnect.presentation = reconnect::ReconnectPresentation::Conversation;
}
} else {
self.chat_widget
.handle_restricted_key(*key, RestrictedInputMode::Disconnected);
+122 -1
View File
@@ -1,4 +1,4 @@
//! Status-tab hints reflect shortcuts that are not claimed by configured actions.
//! A compact footer advertises primary actions; help lists the configured task shortcuts.
use super::*;
use crate::key_hint;
@@ -20,6 +20,23 @@ pub(super) fn hint_line(items: &[(String, String)]) -> Line<'static> {
}
impl AgentsOverviewView {
fn center_list_hint(&self, action: ListAction) -> Option<ShortcutHint> {
self.keymap.primary_hint(action).filter(|hint| {
!matches!(hint, ShortcutHint::Single(binding) if [
&self.agents_keymap.resume,
&self.agents_keymap.search,
&self.agents_keymap.new_task,
&self.agents_keymap.new_worktree,
&self.agents_keymap.rename,
&self.agents_keymap.stop,
&self.agents_keymap.archive,
&self.agents_keymap.delete,
&self.agents_keymap.hide,
&self.agents_keymap.toggle_grouping,
].into_iter().any(|bindings| bindings.contains(binding)))
})
}
pub(super) fn center_filter_hint(&self) -> String {
[key_hint::plain(KeyCode::Tab), key_hint::shift(KeyCode::Tab)]
.into_iter()
@@ -33,4 +50,108 @@ impl AgentsOverviewView {
.collect::<Vec<_>>()
.join("/")
}
pub(super) fn center_task_hints(&self) -> Vec<(String, String)> {
let mut hints = vec![(self.center_filter_hint(), "filter".into())];
for (action, bindings, label) in [
("new_task", &self.agents_keymap.new_task, "new"),
(
"new_worktree",
&self.agents_keymap.new_worktree,
"new worktree",
),
("resume", &self.agents_keymap.resume, "resume"),
("search", &self.agents_keymap.search, "search"),
(
"toggle_grouping",
&self.agents_keymap.toggle_grouping,
"group",
),
("rename", &self.agents_keymap.rename, "rename"),
("stop", &self.agents_keymap.stop, "stop"),
("archive", &self.agents_keymap.archive, "archive"),
("hide", &self.agents_keymap.hide, "hide"),
("delete", &self.agents_keymap.delete, "delete"),
] {
if (action != "new_worktree" || self.worktrees_enabled)
&& let Some(hint) = self.agents_keymap.primary_hint(action, bindings)
{
hints.push((hint.display_label(), label.into()));
}
}
for (action, label) in [
(ListAction::MoveUp, "up"),
(ListAction::MoveDown, "down"),
(ListAction::Accept, "open"),
(ListAction::PageUp, "page up"),
(ListAction::PageDown, "page down"),
] {
if let Some(hint) = self.center_list_hint(action) {
hints.push((hint.display_label(), label.into()));
}
}
hints.push((
key_hint::ctrl(KeyCode::Char('c')).display_label(),
"quit".into(),
));
hints
}
pub(super) fn center_footer_hints(&self) -> Vec<(String, String)> {
let state = self.state();
if let Some(items) = &state.key_chord_hint {
return items.clone();
}
let mut hints = Vec::new();
if !state.help
&& !state.editing_metadata()
&& ![KeyModifiers::NONE, KeyModifiers::SHIFT]
.into_iter()
.any(|modifiers| {
self.center_shortcut_keys
.is_pressed(KeyEvent::new(KeyCode::Char('?'), modifiers))
})
{
hints.push(("?".into(), "help".into()));
}
let cancel = if state.help {
self.keymap.primary_hint(ListAction::Cancel)
} else {
self.center_list_hint(ListAction::Cancel)
};
if let Some(hint) = cancel {
hints.push((hint.display_label(), "back".into()));
}
if state.help {
return hints;
}
if let Some(hint) = self.center_list_hint(ListAction::Accept) {
hints.push((
hint.display_label(),
if state.rename_target.is_some() {
"rename"
} else {
"open"
}
.into(),
));
}
if !state.editing_metadata() {
for (action, bindings, label) in [
("new_task", &self.agents_keymap.new_task, "new"),
(
"new_worktree",
&self.agents_keymap.new_worktree,
"new worktree",
),
] {
if (action != "new_worktree" || self.worktrees_enabled)
&& let Some(hint) = self.agents_keymap.primary_hint(action, bindings)
{
hints.push((hint.display_label(), label.into()));
}
}
}
hints
}
}
+30 -1
View File
@@ -1,6 +1,7 @@
//! Status-tab shortcuts yield to configured task and list bindings.
//! Tab filters and shortcut help yield to configured task and list bindings.
use super::*;
use crossterm::event::KeyEventKind;
impl AgentsOverviewView {
pub(in crate::app::agents_overview_view) fn command_center_key(
@@ -8,12 +9,40 @@ impl AgentsOverviewView {
key: KeyEvent,
) -> bool {
if self.state().editing_metadata() {
if self.keymap.action_for(key) == Some(ListAction::Cancel) {
self.on_ctrl_c();
return true;
}
return false;
}
let mut state = self.state();
if state.help && self.keymap.action_for(key) == Some(ListAction::Cancel) {
state.help = false;
return true;
}
if state.help {
if key.code == KeyCode::Char('?')
&& key.kind == KeyEventKind::Press
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
state.help = false;
}
return true;
}
if self.center_shortcut_keys.is_pressed(key) {
return false;
}
if key.code == KeyCode::Char('?')
&& key.kind == KeyEventKind::Press
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
state.help = !state.help;
return true;
}
if key.code == KeyCode::Tab
&& !key
.modifiers
+24 -6
View File
@@ -1,4 +1,4 @@
//! Task rows, metadata editing and read-only details share a wide/narrow layout.
//! Status tabs, task rows and read-only details share a simple wide/narrow layout.
use super::hints::hint_line;
use super::*;
@@ -48,9 +48,8 @@ struct CenterLayout {
impl AgentsOverviewView {
fn center_layout(&self, area: Rect) -> CenterLayout {
let footer_height = (self.footer_lines(area.width.saturating_sub(4)).len() as u16)
.min(area.height.saturating_sub(4));
let state = self.state();
let footer_height = u16::from(area.height >= 2);
let header_height = if area.height >= 6 { 3 } else { 0 };
let footer = row(area, area.height - footer_height, footer_height);
let header = row(area, /*offset*/ 0, header_height);
@@ -123,7 +122,7 @@ impl Renderable for AgentsOverviewView {
let layout = self.center_layout(area);
let inset =
|area: Rect| area.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
let footer_lines = self.footer_lines(area.width.saturating_sub(4));
let footer_hints = self.center_footer_hints();
let search_key = self
.agents_keymap
.primary_hint("search", &self.agents_keymap.search)
@@ -223,12 +222,31 @@ impl Renderable for AgentsOverviewView {
(state.refresh_failed && !state.loading).then(|| "Error loading tasks".to_owned())
})
.or_else(|| state.server_version_notice.clone());
if let Some(notice) = notice.filter(|_| state.key_chord_hint.is_none()) {
if let Some(notice) = notice.filter(|_| !state.help && state.key_chord_hint.is_none()) {
line(notice.dim(), inset(layout.footer), buf);
} else {
Paragraph::new(footer_lines).render(inset(layout.footer), buf);
line(hint_line(&footer_hints), inset(layout.footer), buf);
}
let help = state.help;
drop(state);
if help {
let body = row(
area,
layout.header.height,
layout.footer.y - layout.header.bottom(),
);
Clear.render(inset(body), buf);
let lines = crate::wrapping::word_wrap_lines(
vec![
"Task shortcuts".bold().into(),
Line::default(),
hint_line(&self.center_task_hints()),
],
usize::from(inset(body).width),
);
Paragraph::new(lines).render(inset(body), buf);
return;
}
for y in layout.gap.y..layout.gap.bottom() {
if layout.gap.width > 1 {
buf[(layout.gap.x + 1, y)]
+97 -8
View File
@@ -235,7 +235,8 @@ async fn live_center_rename_retains_target_when_status_leaves_filter() -> Result
#[tokio::test]
async fn live_center_navigation_and_complete_hints() {
let app = make_test_app().await;
let mut app = make_test_app().await;
app.config.features.enable(Feature::Worktrees).unwrap();
let ready = ThreadId::from_u128(/*value*/ 42);
let needs_you = ThreadId::from_u128(/*value*/ 43);
let unloaded = ThreadId::from_u128(/*value*/ 44);
@@ -273,17 +274,105 @@ async fn live_center_navigation_and_complete_hints() {
screen(&view, /*width*/ 40, /*height*/ 12)
);
view.handle_key_event(KeyCode::Tab.into());
let repeat_help =
KeyEvent::new_with_kind(KeyCode::Char('?'), KeyModifiers::NONE, KeyEventKind::Repeat);
view.handle_key_event(KeyCode::Char('?').into());
view.handle_key_event(repeat_help);
insta::assert_snapshot!(screen(&view, /*width*/ 40, /*height*/ 24));
view.handle_key_event(KeyCode::Esc.into());
view.handle_key_event(repeat_help);
assert!(screen(&view, /*width*/ 40, /*height*/ 24).contains("Search tasks"));
assert!(!view.is_complete());
view.handle_key_event(KeyCode::Esc.into());
assert!(view.is_complete());
}
#[tokio::test]
async fn live_center_fixed_shortcuts_yield_to_configured_actions() {
let mut app = make_test_app().await;
let config: TuiKeymap = toml::from_str("[agents]\nresume = 'tab'").unwrap();
let config: TuiKeymap = toml::from_str("[list]\ncancel = 'q'").unwrap();
app.keymap = RuntimeKeymap::from_config(&config).unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Tab.into());
assert!(matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)));
assert!(rx.try_recv().is_err());
for editor_key in ['f', 'r'] {
let mut view = app.agents_overview_view(
vec![overview_thread(
ThreadId::from_u128(/*value*/ 42),
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
)],
/*selected_thread_id*/ None,
);
view.handle_key_event(KeyCode::Char(editor_key).into());
assert!(
app.agents_overview
.view_state
.lock()
.unwrap()
.editing_metadata()
);
view.handle_key_event(KeyCode::Char('q').into());
assert!(
!app.agents_overview
.view_state
.lock()
.unwrap()
.editing_metadata()
);
assert!(!view.is_complete());
}
for (binding, key) in [
("tab", KeyEvent::from(KeyCode::Tab)),
("?", KeyEvent::from(KeyCode::Char('?'))),
(
"shift-?",
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT),
),
("f9", KeyEvent::from(KeyCode::F(9))),
("enter", KeyEvent::from(KeyCode::Enter)),
] {
let mut app = make_test_app().await;
let config: TuiKeymap = toml::from_str(&format!(
"[list]\ncancel = 'f9'\n[agents]\nresume = '{binding}'"
))
.unwrap();
app.keymap = RuntimeKeymap::from_config(&config).unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Esc.into());
assert!(!view.is_complete());
if key.code == KeyCode::Char('?') {
assert!(!screen(&view, /*width*/ 100, /*height*/ 24).contains("? help"));
}
if key.code == KeyCode::Enter {
assert!(!screen(&view, /*width*/ 100, /*height*/ 24).contains("enter open"));
view.handle_key_event(KeyCode::Char('?').into());
assert!(!screen(&view, /*width*/ 100, /*height*/ 24).contains("enter open"));
app.agents_overview
.view_state
.lock()
.unwrap()
.server_version_notice = Some("Version notice".into());
assert!(screen(&view, /*width*/ 100, /*height*/ 24).contains("f9 back"));
view.handle_key_event(KeyCode::Esc.into());
assert!(screen(&view, /*width*/ 100, /*height*/ 24).contains("Task shortcuts"));
view.handle_key_event(KeyCode::F(9).into());
assert!(!screen(&view, /*width*/ 100, /*height*/ 24).contains("Task shortcuts"));
app.agents_overview
.view_state
.lock()
.unwrap()
.server_version_notice = None;
view.handle_key_event(KeyCode::Char('f').into());
assert!(!screen(&view, /*width*/ 100, /*height*/ 24).contains("enter open"));
}
view.handle_key_event(key);
assert!(!view.is_complete());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)),
"{binding}"
);
assert!(rx.try_recv().is_err());
}
}
+6 -1
View File
@@ -4,6 +4,7 @@ use super::ThreadBufferedEvent;
use super::ThreadEventStore;
use crate::history_cell::HistoryCell;
use crate::history_cell::plain_lines;
use crate::style::accent_color;
use crate::text_formatting::truncate_text;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::ServerNotification;
@@ -112,7 +113,11 @@ impl AgentStatusThreadPreview {
}
fn title_line(&self) -> Line<'static> {
vec![" • ".dim(), format!("`{}`", self.agent_path).cyan()].into()
vec![
" • ".dim(),
format!("`{}`", self.agent_path).fg(accent_color()),
]
.into()
}
fn preview_lines(&self, width: u16) -> Vec<Line<'static>> {
@@ -157,7 +157,7 @@ impl App {
..Default::default()
},
],
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -313,7 +313,7 @@ impl App {
dismiss_on_select: true,
..Default::default()
}],
..Default::default()
..SelectionViewParams::picker()
});
}
@@ -742,98 +742,3 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb
}
Ok(())
}
#[tokio::test]
async fn disabled_footer_shortcuts_stay_bold_when_wrapped() {
let app = make_test_app().await;
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Esc.into());
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 84, /*height*/ 24,
);
let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer);
let delete_key = crate::key_hint::plain(KeyCode::Delete).display_label();
for (key, label) in [
("x", "x stop"),
("h", "h hide"),
("a", "a archive"),
(delete_key.as_str(), delete_key.as_str()),
] {
let cells = buffer
.content()
.windows(label.len())
.find(|cells| {
cells
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
== label
})
.expect("footer shortcut");
assert_eq!(
cells[..key.len()]
.iter()
.map(|cell| cell.modifier)
.collect::<Vec<_>>(),
vec![ratatui::style::Modifier::BOLD | ratatui::style::Modifier::DIM; key.len()]
);
}
}
#[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": { "archive": "f5 f6", "delete": "f5 f7", "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<_>>();
for label in ["archive", "delete", "hide"] {
assert!(
lines
.iter()
.any(|line| line.contains(label) && 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")
);
}
@@ -38,7 +38,7 @@ impl App {
Line::from(message.clone().dim()),
])),
items,
..Default::default()
..SelectionViewParams::picker()
});
}
self.chat_widget.add_error_message(message);
@@ -1,214 +0,0 @@
//! Layout rendering and cursor placement for the agent dashboard.
//! Only search and rename reserve an editor row; list actions share a wrapped footer.
use super::*;
impl AgentsOverviewView {
pub(super) fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
let state = self.state();
let message = if let Some(items) = &state.key_chord_hint {
Some(
items
.iter()
.map(|(key, label)| format!("{key} {label}"))
.collect::<Vec<_>>()
.join(" "),
)
} else if state.editing_metadata() && state.connection_notice.is_some() {
Some("esc cancel · actions paused until reconnected".into())
} else if state.editing_metadata() {
Some(format!(
"{} {} esc cancel",
self.keymap
.primary_hint(ListAction::Accept)
.map(crate::key_hint::ShortcutHint::display_label)
.unwrap_or_default(),
if state.rename_target.is_some() {
"rename"
} else {
"open"
}
))
} else if state.connection_notice.is_some() {
Some("ctrl+c quit · actions paused until the list is refreshed".into())
} else if state.creating_worktree {
Some("Creating worktree… ctrl+c quit".into())
} else {
None
};
drop(state);
if let Some(message) = message {
return textwrap::wrap(&message, usize::from(width.max(1)))
.into_iter()
.map(|line| line.into_owned().dim().into())
.collect();
}
if width < 24 {
let hints = [
("new_task", &self.agents_keymap.new_task, "new"),
(
"new_worktree",
&self.agents_keymap.new_worktree,
"new worktree",
),
("search", &self.agents_keymap.search, "search"),
]
.into_iter()
.filter(|(action, _, _)| *action != "new_worktree" || self.worktrees_enabled)
.filter_map(|(action, bindings, label)| {
self.agents_keymap
.primary_hint(action, bindings)
.map(|hint| format!("{} {label}", hint.display_label()))
});
return hints
.chain(["ctrl+c quit".into()])
.flat_map(|hint| {
textwrap::wrap(&hint, usize::from(width.max(1)))
.into_iter()
.map(|line| line.into_owned().dim().into())
.collect::<Vec<_>>()
})
.collect();
}
let list_hint = |action| {
self.keymap.primary_hint(action).filter(|hint| {
!matches!(hint, ShortcutHint::Single(binding)
if [
&self.agents_keymap.resume,
&self.agents_keymap.search,
&self.agents_keymap.new_task,
&self.agents_keymap.new_worktree,
&self.agents_keymap.rename,
&self.agents_keymap.stop,
&self.agents_keymap.archive,
&self.agents_keymap.delete,
&self.agents_keymap.hide,
&self.agents_keymap.toggle_grouping,
]
.into_iter()
.any(|bindings| bindings.contains(binding)))
})
};
let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown]
.into_iter()
.filter_map(list_hint)
.map(|hint| hint.display_label().replace(" + ", "+"))
.collect::<Vec<_>>();
let navigation_hint = navigation_hints.join(
if navigation_hints
.iter()
.all(|hint| hint.chars().count() == 1)
{
""
} else {
" "
},
);
let mut hints: Vec<Line<'static>> = Vec::new();
if !navigation_hint.is_empty() {
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(" + ", "+");
hints.push(
vec![
if enabled {
key.bold()
} else {
key.bold().dim()
},
format!(" {label}").dim(),
]
.into(),
);
}
};
add_hint(
self.agents_keymap
.primary_hint("resume", &self.agents_keymap.resume),
"resume",
true,
);
let open_hint = list_hint(ListAction::Accept);
add_hint(open_hint, "open", true);
add_hint(
self.agents_keymap
.primary_hint("new_task", &self.agents_keymap.new_task),
"new",
true,
);
add_hint(
self.agents_keymap
.primary_hint("new_worktree", &self.agents_keymap.new_worktree),
"new worktree",
self.worktrees_enabled,
);
add_hint(
self.agents_keymap
.primary_hint("search", &self.agents_keymap.search),
"search",
true,
);
add_hint(
self.agents_keymap
.primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping),
match self.state().grouping {
AgentsOverviewGrouping::Project => "group: project",
AgentsOverviewGrouping::Status => "group: status",
AgentsOverviewGrouping::Model => "group: model",
},
true,
);
add_hint(
self.agents_keymap
.primary_hint("rename", &self.agents_keymap.rename),
"rename",
true,
);
add_hint(
self.agents_keymap
.primary_hint("stop", &self.agents_keymap.stop),
"stop",
self.selected_row()
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })),
);
for (action, bindings) in [
("hide", &self.agents_keymap.hide),
("archive", &self.agents_keymap.archive),
("delete", &self.agents_keymap.delete),
] {
add_hint(
self.agents_keymap.primary_hint(action, bindings),
action,
self.selected_row().is_some(),
);
}
if self.state().editing_metadata() {
add_hint(list_hint(ListAction::Cancel), "cancel", true);
}
hints.push(vec!["ctrl+c".bold(), " quit".dim()].into());
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);
}
// An individual custom chord may be wider than the entire terminal.
crate::wrapping::word_wrap_lines([line], usize::from(width.max(1)))
})
.collect()
}
}
+2 -91
View File
@@ -79,12 +79,7 @@ async fn older_server_notice_is_visible_in_agents_overview() {
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(
rendered
.lines()
.find(|line| line.contains("Service v0.153.0") || line.contains("Service v0.152.1"))
.unwrap()
);
insta::assert_snapshot!(rendered.lines().last().unwrap());
}
#[tokio::test]
@@ -95,7 +90,7 @@ async fn server_version_overview_notice_updates_and_clears() {
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().find(|line| line.contains("Service v0.151.0")).unwrap(), @" Service v0.151.0 < Codex CLI v0.153.0");
insta::assert_snapshot!(rendered.lines().last().unwrap(), @" Service v0.151.0 < Codex CLI v0.153.0");
app.update_server_version_overview_notice("0.153.0", /*server_version*/ None);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
@@ -2606,90 +2601,6 @@ fn trust_fixture_folders(app: &mut App) {
#[path = "agents_overview_usage_tests.rs"]
mod usage;
#[tokio::test]
async fn command_center_escape_cancels_editors_and_never_closes_list() {
for (vim, offline, empty, running) in [
(false, false, false, false),
(true, false, false, false),
(false, true, false, false),
(false, false, true, false),
(false, false, false, true),
] {
let (mut app, mut rx, mut op_rx) = crate::app::tests::make_test_app_with_channels().await;
if vim {
app.chat_widget.toggle_vim_mode_and_notify();
}
if running {
app.chat_widget.handle_server_notification(
ServerNotification::TurnStarted(
codex_app_server_protocol::TurnStartedNotification {
thread_id: ThreadId::new().to_string(),
turn: codex_app_server_protocol::Turn {
id: "running".into(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::InProgress,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
},
),
/*replay_kind*/ None,
);
assert!(app.chat_widget.is_task_running_for_test());
}
while rx.try_recv().is_ok() {}
let rows = if empty {
Vec::new()
} else {
vec![overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
)]
};
let mut view = app.agents_overview_view(rows, /*selected_thread_id*/ None);
if offline {
app.agents_overview
.view_state
.lock()
.unwrap()
.connection_notice = Some("Offline");
}
view.handle_key_event(KeyCode::Char('f').into());
view.handle_paste("nwogrxfha".into());
assert!(rx.try_recv().is_err());
view.handle_key_event(KeyCode::Esc.into());
if !empty {
assert_eq!(view.selected_index(), Some(0));
}
assert_eq!(
view.on_ctrl_c(),
crate::bottom_pane::CancellationEvent::NotHandled
);
app.chat_widget.show_bottom_pane_view(Box::new(view));
for _ in 0..3 {
app.chat_widget.handle_key_event(KeyCode::Esc.into());
}
assert!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some()
);
assert!(rx.try_recv().is_err());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(op_rx.try_recv().is_err());
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::Exit(crate::app_event::ExitMode::ShutdownFirst))
));
}
}
#[tokio::test]
async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone() {
let mut app = make_test_app().await;
+4 -8
View File
@@ -4,9 +4,6 @@
#[path = "agent_center/mod.rs"]
pub(super) mod command_center;
#[path = "agents_overview_render.rs"]
mod render;
#[path = "agents_overview_grouping.rs"]
mod grouping;
@@ -137,6 +134,7 @@ pub(super) struct AgentsOverviewViewState {
scroll: usize,
page_height: usize,
status_filter: usize,
help: bool,
pub(super) input: String,
pub(super) key_chord_hint: Option<Vec<(String, String)>>,
pub(super) creating_worktree: bool,
@@ -518,10 +516,6 @@ impl BottomPaneView for AgentsOverviewView {
if self.command_center_key(key) {
return;
}
if key.code == KeyCode::Esc {
self.on_ctrl_c();
return;
}
if key.code == KeyCode::Backspace
&& self.state().editing_metadata()
&& key.modifiers.is_empty()
@@ -658,7 +652,9 @@ impl BottomPaneView for AgentsOverviewView {
}
ListAction::Accept => self.activate(),
ListAction::Cancel => {
self.on_ctrl_c();
if matches!(self.on_ctrl_c(), CancellationEvent::NotHandled) {
self.state().completion = Some(ViewCompletion::Cancelled);
}
}
ListAction::PageUp | ListAction::PageDown => {
self.page_selection(action);
+1 -3
View File
@@ -271,6 +271,7 @@ impl App {
.view_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.connection_notice = None;
if state.creating_worktree {
state.creating_worktree = false;
self.pending_managed_worktree_creation = false;
@@ -443,9 +444,6 @@ impl App {
// A hidden overview performs this discovery when it is next opened.
self.agents_overview.initialized = false;
if self.reconnect.presentation == ReconnectPresentation::Overview {
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.connection_notice = None;
}
let threads = self
.agents_overview
.threads
@@ -1,28 +0,0 @@
---
source: tui/src/app/agents_overview_actions_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\n48).replace(&test_path_display(\"/tmp/project\"), \"/tmp/project\")"
---
Agent command center Group: Project g
All 1 Needs you 0 Working 0 ›
────────────────────────────────────────────
Search tasks · f
Tasks
/tmp/project 1
› ○ Task
↑↓ navigate o resume enter open n new
w new worktree f search g group: project
r rename x stop f5 f8 hide f5 f6 archive
f5 f7 delete ctrl+c quit
@@ -40,5 +40,5 @@ expression: normalize_agent_center_snapshot(&cached)
" │ "
" │ "
" │ "
" ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename "
" x stop h hide a archive del delete ctrl+c quit "
" │ "
" ? help esc back enter open n new w new worktree "
@@ -24,5 +24,5 @@ expression: "normalize_agent_center_snapshot(render_bottom_popup(&app.chat_widge
│ …
│
│
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
x stop h hide a archive del delete ctrl+c quit
│
? help esc back enter open n new w new worktree
@@ -24,5 +24,5 @@ expression: "normalize_agent_center_snapshot(render_bottom_popup(&app.chat_widge
│ …
│
│
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
x stop h hide a archive del delete ctrl+c quit
│
? help esc back enter open n new w new worktree
@@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n100).replace(&test_path_disp
│
│
│
↑↓ navigate o resume enter open n new w new worktree f search g group: model r rename x stop
h hide a archive del delete ctrl+c quit
│
? help esc back enter open n new w new worktree
@@ -24,5 +24,5 @@ expression: "age.replace_all(&rendered, \" [age]\")"
↑↓ navigate o resume enter open n new w new worktree f search
g group: project r rename x stop h hide a archive del delete ctrl+c quit
? help esc back enter open n new w new worktree
@@ -23,4 +23,4 @@ expression: "screen(&view, 160, 22)"
│
│
│
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop h hide a archive del delete ctrl+c quit
? help esc back enter open n new w new worktree
@@ -19,4 +19,4 @@ expression: rendered
enter rename esc cancel
esc back enter rename
@@ -0,0 +1,28 @@
---
source: tui/src/app/agent_center_tests.rs
expression: "screen(&view, 40, 24)"
---
Agent command center Group: Projec…
All 3 Needs you 1 Working 0 ›
────────────────────────────────────
Task shortcuts
tab/shift+tab filter n new w new
worktree o resume f search g
group r rename x stop a archive
h hide del delete ↑ up ↓ down
enter open pgup page up pgdn page
down ctrl+c quit
esc back
@@ -6,11 +6,11 @@ expression: "screen(&view, 40, 12)"
‹ Working 0 Ready 1 Inactive 1
────────────────────────────────────
Search tasks · f
↑ Tasks
Tasks
/tmp/project 1 of 3
› ○ Unloaded task
↑↓ navigate o resume enter open
n new w new worktree f search
g group: project r rename x stop
h hide a archive del delete
ctrl+c quit
? help esc back enter open n new…
@@ -20,4 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget, 12)"
Service…
@@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Review parser
│
│
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
x stop h hide a archive del delete ctrl+c quit
│
? help esc back enter open n new w new worktree
@@ -215,6 +215,10 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
},
);
}
if edit_offline {
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
}
app.begin_reconnect();
if deferred_notice {
assert_eq!(
@@ -227,6 +231,12 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
);
}
if edit_offline {
for key in [KeyCode::Esc, KeyCode::Enter] {
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key.into()))
.await?;
}
assert!(!app.chat_widget.has_active_view());
assert_eq!(app.chat_widget.composer_text_with_pending(), "kept draft");
app.handle_tui_event(
&mut tui,
&mut session,
@@ -280,6 +290,17 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
);
app.finish_reconnect(&mut tui, &mut session, &mut events, connected, "2.1.0")
.await?;
if edit_offline {
assert!(!app.chat_widget.has_active_view());
assert!(
app.agents_overview
.view_state
.lock()
.unwrap()
.connection_notice
.is_none()
);
}
assert!(app.pending_server_profiles.is_empty());
assert!(!app.pending_managed_worktree_creation);
assert!(
@@ -438,20 +438,6 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
)
.await?;
assert!(app.chat_widget.has_active_view());
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
)
.await?;
assert!(app.chat_widget.has_active_view());
// Esc stays in the overview. Dismiss the retained view explicitly
// to inspect the unavailable conversation and its cached draft below.
app.agents_overview.view_state.lock().unwrap().completion =
Some(crate::bottom_pane::ViewCompletion::Accepted);
app.chat_widget.handle_key_event(KeyCode::Null.into());
assert!(!app.chat_widget.has_active_view());
assert_eq!(app.current_displayed_thread_id(), Some(id));
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
@@ -24,4 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│
│
│
│
Service v2.0.0 ≠ Codex CLI v0.0.0 · /daemon
@@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│
│
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
x stop h hide a archive del delete ctrl+c quit
│
? help esc back enter open n new w new worktree
@@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Repair authentication
│
│
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
x stop h hide a archive del delete ctrl+c quit
│
? help esc back enter open n new w new worktree
@@ -22,7 +22,7 @@ expression: rendered
↑↓ navigate o resume enter open n new
w new worktree f search g group: project
r rename x stop h hide a archive del delete
ctrl+c quit
? help esc back enter open n new w new …
@@ -15,6 +15,7 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Found the regression in the parser.
│ Found the regression in the parser.
│ Found the regression in the parser.
│ Found the regression in the parser.
│ …
│
│ Project
@@ -24,5 +25,4 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Prompt
│ Investigate parser
│ …
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
x stop h hide a archive del delete ctrl+c quit
? help esc back enter open n new w new worktree
@@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│
│
│
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
x stop h hide a archive del delete ctrl+c quit
│
? help esc back enter open n new w new worktree
@@ -25,4 +25,4 @@ expression: normalize_agent_center_snapshot(grouping)
↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop h hide a archive del delete ctrl+c quit
? help esc back enter open n new w new worktree