Open tasks with Right from the agents overview (#44344)

## What changed

Allow `Right` to open the selected task from the task list or an empty, focused composer. Preserve editor keybindings and guard against pending input, active popups, and offline state. Keep metadata editing unaffected and show the shortcut in navigation hints.

## Testing

Add regression tests for opening the current or highlighted task, preserving cursor movement and renaming, and preventing navigation while offline. Update snapshots to cover the new hints.

GitOrigin-RevId: 9fc01b62ad0829b964509ae6286a041fc4936b9d
This commit is contained in:
Eric Traut
2026-09-10 00:07:20 +00:00
committed by copyberry
parent 1bff94edb6
commit 3ef3cecd20
13 changed files with 151 additions and 9 deletions
+22 -1
View File
@@ -1,5 +1,5 @@
//! Focus and input routing for the agent dashboard. Refreshes retain the shared
//! composer; dashboard actions never consume keys while its editor owns focus.
//! composer; Right opens the selected task from an empty editor with no pending input.
use super::*;
use crate::bottom_pane::InputResult;
@@ -13,6 +13,17 @@ impl AgentsOverviewView {
let mut state = self.state();
let offline = state.connection_notice.is_some();
let status_grouping = state.status_grouping;
if !offline
&& crate::key_hint::plain(KeyCode::Right).is_press(key)
&& state
.composer
.as_ref()
.is_some_and(ChatComposer::can_leave_empty_prompt_with_right)
{
drop(state);
self.activate();
return;
}
if key.code == KeyCode::Esc && !state.composer_owns_escape() {
state.focus = AgentsOverviewFocus::List;
return;
@@ -102,6 +113,16 @@ impl AgentsOverviewView {
{
*key = "esc esc".to_string();
}
if composing
&& state.connection_notice.is_none()
&& state
.composer
.as_ref()
.is_some_and(ChatComposer::can_leave_empty_prompt_with_right)
&& self.rows.get(self.selected).is_some()
{
hints.push(("→".to_string(), "open task".to_string()));
}
if area.width < 60 && hints.len() > 2 {
hints.remove(/*index*/ 1);
}
@@ -63,7 +63,11 @@ impl AgentsOverviewView {
"resume",
true,
);
add_hint(list_hint(ListAction::Accept), "open", true);
let open_hint = (!self.state().editing_metadata())
.then(|| list_hint(ListAction::MoveRight))
.flatten()
.or_else(|| list_hint(ListAction::Accept));
add_hint(open_hint, "open", true);
add_hint(
self.agents_keymap
.primary_hint("new_task", &self.agents_keymap.new_task),
@@ -106,6 +106,96 @@ fn overview_draft(app: &App) -> (String, usize) {
(composer.current_text_with_pending(), composer.cursor())
}
#[tokio::test]
async fn overview_right_opens_current_or_highlighted_task() {
let mut app = make_test_app().await;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let current = ThreadId::new();
let other = ThreadId::new();
app.primary_thread_id = Some(current);
let threads = vec![
overview_thread(
current,
/*parent_thread_id*/ None,
"Current",
ThreadStatus::Idle,
),
overview_thread(
other,
/*parent_thread_id*/ None,
"Other",
ThreadStatus::Idle,
),
];
let view = app.agents_overview_view(threads.clone(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
insta::assert_snapshot!(
"overview_empty_prompt_right_hint",
rendered.lines().last().unwrap()
);
app.chat_widget.handle_key_event(KeyCode::Right.into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == current)
);
assert!(app.chat_widget.no_modal_or_popup_active());
let mut view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Esc.into());
view.handle_key_event(KeyCode::Down.into());
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
insta::assert_snapshot!(
"overview_right_open_hint",
rendered.lines().find(|line| line.contains("open")).unwrap()
);
app.chat_widget.handle_key_event(KeyCode::Right.into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == other)
);
assert!(app.chat_widget.no_modal_or_popup_active());
}
#[tokio::test]
async fn overview_right_preserves_editors_and_offline_state() {
let mut app = make_test_app().await;
app.config.disable_paste_burst = true;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let thread_id = ThreadId::new();
let mut view = app.agents_overview_view(
vec![overview_thread(
thread_id,
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
)],
Some(thread_id),
);
view.handle_paste("ab".into());
view.handle_key_event(KeyCode::Home.into());
view.handle_key_event(KeyCode::Right.into());
assert_eq!(overview_draft(&app), ("ab".into(), 1));
view.handle_key_event(KeyCode::Esc.into());
view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
view.handle_key_event(KeyCode::Right.into());
assert!(app.agents_overview.view_state.lock().unwrap().renaming);
view.handle_key_event(KeyCode::Esc.into());
app.agents_overview
.view_state
.lock()
.unwrap()
.connection_notice = Some("Offline");
view.handle_key_event(KeyCode::Right.into());
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
view.on_ctrl_c();
view.handle_key_event(KeyCode::Right.into());
assert!(!view.is_complete());
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn overview_composer_preserves_editing_and_routes_focus() {
let mut app = make_test_app().await;
@@ -739,6 +739,7 @@ impl BottomPaneView for AgentsOverviewView {
self.move_selection(action == ListAction::PageDown);
}
}
ListAction::MoveRight if !self.state().editing_metadata() => self.activate(),
ListAction::MoveLeft | ListAction::MoveRight => {}
}
} else if key.code == KeyCode::Backspace {
@@ -0,0 +1,5 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: rendered.lines().last().unwrap()
---
enter create task ctrl+j newline esc tasks → open task
@@ -0,0 +1,5 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "rendered.lines().find(|line| line.contains(\"open\")).unwrap()"
---
↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group ctrl+r rename
@@ -25,4 +25,4 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
› Describe a new task
enter create task ctrl+j newline esc tasks
enter create task ctrl+j newline esc tasks → open task
@@ -18,6 +18,7 @@
//! # Key Event Routing
//!
//! Plain Left opens agents when the local-daemon composer is empty and available for input.
//! The agents dashboard uses the matching empty-editor guards for Right to open a task.
//! Explicit editor remaps take precedence.
//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a
//! popup-specific handler if a popup is visible and otherwise to
@@ -2,10 +2,26 @@
//!
//! Only local-daemon sessions enable this shortcut. Draft contents, transient input surfaces,
//! and explicit editor remaps take precedence over navigation.
//! The dashboard can also leave an empty editor with Right under the same input guards.
use super::*;
impl ChatComposer {
pub(crate) fn can_leave_empty_prompt_with_right(&self) -> bool {
let move_right = if self.draft.textarea.is_vim_normal_mode() {
&self.vim_normal_keymap.move_right
} else {
&self.editor_keymap.move_right
};
move_right.is_pressed(KeyCode::Right.into())
&& self.has_focus
&& self.draft.input_enabled
&& self.is_empty()
&& !self.is_in_paste_burst()
&& !self.popup_active()
&& !self.draft.textarea.is_vim_operator_pending()
}
pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) {
self.agents_navigation_enabled = enabled;
}
@@ -25,4 +25,4 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
› Describe a new task
enter create task ctrl+j newline esc tasks
enter create task ctrl+j newline esc tasks → open task
@@ -23,6 +23,6 @@ expression: "render_bottom_popup(&app.chat_widget, 48)"
› Describe a new task
↑↓ navigate ctrl+o resume enter open ctrl+n
new task ctrl+f search ctrl+s group ctrl+r
↑↓ navigate ctrl+o resume → open ctrl+n new
task ctrl+f search ctrl+s group ctrl+r
rename ctrl+x stop esc back
@@ -25,4 +25,4 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
› Describe a new task
enter create task ctrl+j newline esc tasks
enter create task ctrl+j newline esc tasks → open task
@@ -1,6 +1,5 @@
---
source: tui/src/app/agents_overview_tests.rs
assertion_line: 943
expression: grouping
---
Agent command center
@@ -26,4 +25,4 @@ expression: grouping
› Describe a new task
enter create task ctrl+j newline esc tasks
enter create task ctrl+j newline esc tasks → open task