mirror of
https://github.com/openai/codex.git
synced 2026-10-22 01:34:54 +00:00
Use the shared composer in the agent command center (#42428)
## What changed - Replace the command center's single-line new-task input with the shared chat composer, including multiline editing, paste handling, Vim mode, configured keybindings, cursor behavior, and input validation. - Keep task drafts and editor state across dashboard refreshes and reconnects while separating composer focus from list, search, and rename actions. - Preserve whitespace in dispatched task prompts, block submission while offline, and restore unsent prompts after dispatch failures when the composer is still available. ## Testing - Add coverage for focus routing, draft retention, multiline pastes, custom keybindings, Vim mode, offline editing, failed-dispatch recovery, and responsive rendering. GitOrigin-RevId: a609ae95d497cbae0ebcac4cdcc50fe49d74832a
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
//! Daemon-wide overview of recent and locally retained sessions and their subagents.
|
||||
|
||||
#[path = "agents_overview_composer.rs"]
|
||||
mod composer;
|
||||
|
||||
use super::agents_overview_view::AgentsOverviewGroup;
|
||||
use super::agents_overview_view::AgentsOverviewRow;
|
||||
use super::agents_overview_view::AgentsOverviewView;
|
||||
@@ -98,6 +101,11 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
self.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.focus_composer();
|
||||
let threads = self
|
||||
.agents_overview
|
||||
.threads
|
||||
@@ -252,6 +260,8 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
self.sync_agents_overview_composer();
|
||||
|
||||
AgentsOverviewView::new(
|
||||
rows,
|
||||
selected_thread_id,
|
||||
@@ -599,9 +609,7 @@ impl App {
|
||||
Some(cwd) => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.input = prompt;
|
||||
}
|
||||
self.restore_agents_overview_prompt(prompt);
|
||||
return self
|
||||
.chat_widget
|
||||
.add_error_message(format!("Failed to load project settings: {error}"));
|
||||
@@ -615,9 +623,7 @@ impl App {
|
||||
|| config.permissions.profile_workspace_roots()
|
||||
!= self.config.permissions.profile_workspace_roots())
|
||||
{
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.input = prompt;
|
||||
}
|
||||
self.restore_agents_overview_prompt(prompt);
|
||||
return self
|
||||
.chat_widget
|
||||
.add_error_message("Permission profile has different settings.".to_string());
|
||||
@@ -646,9 +652,7 @@ impl App {
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.input = prompt;
|
||||
}
|
||||
self.restore_agents_overview_prompt(prompt);
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to start background task: {error}"));
|
||||
}
|
||||
@@ -676,9 +680,7 @@ impl App {
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.input = prompt;
|
||||
}
|
||||
self.restore_agents_overview_prompt(prompt);
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to send task message: {error}"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Retained composer setup and failed-dispatch recovery for the agent dashboard.
|
||||
//! Refreshes rebind runtime settings without replacing drafts or editor state.
|
||||
|
||||
use super::*;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::ChatComposerConfig;
|
||||
|
||||
impl App {
|
||||
pub(super) fn sync_agents_overview_composer(&self) {
|
||||
let mut state = self
|
||||
.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let composer = state.composer.get_or_insert_with(|| {
|
||||
let mut composer = ChatComposer::new_with_config(
|
||||
/*has_input_focus*/ true,
|
||||
self.app_event_tx.clone(),
|
||||
self.enhanced_keys_supported,
|
||||
"Describe a new task".to_string(),
|
||||
self.config.disable_paste_burst,
|
||||
ChatComposerConfig {
|
||||
trim_submission: false,
|
||||
..ChatComposerConfig::plain_text()
|
||||
},
|
||||
);
|
||||
// A new task has no context usage to report yet.
|
||||
composer.set_context_window_pending(/*pending*/ true);
|
||||
composer.set_footer_hint_override(Some(Vec::new()));
|
||||
composer
|
||||
});
|
||||
composer.set_app_event_sender(self.app_event_tx.clone());
|
||||
composer.set_disable_paste_burst(self.config.disable_paste_burst);
|
||||
let vim_enabled = self.chat_widget.composer_is_vim_enabled();
|
||||
if composer.is_vim_enabled() != vim_enabled {
|
||||
composer.set_vim_enabled(vim_enabled);
|
||||
composer.resume_text_entry();
|
||||
}
|
||||
composer.set_keymap_bindings(&self.keymap);
|
||||
}
|
||||
|
||||
pub(super) fn restore_agents_overview_prompt(&mut self, prompt: String) {
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock()
|
||||
&& let Some(composer) = state.composer.as_mut()
|
||||
&& composer.is_empty()
|
||||
&& !composer.is_in_paste_burst()
|
||||
&& !composer.popup_active()
|
||||
{
|
||||
composer.set_text_content(prompt, Vec::new(), Vec::new());
|
||||
composer.move_cursor_to_end();
|
||||
return;
|
||||
}
|
||||
self.chat_widget
|
||||
.add_info_message(format!("Unsent task: {prompt}"), /*hint*/ None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Focus and input routing for the agent dashboard. Refreshes retain the shared
|
||||
//! composer; dashboard actions never consume keys while its editor owns focus.
|
||||
|
||||
use super::*;
|
||||
use crate::bottom_pane::InputResult;
|
||||
|
||||
impl AgentsOverviewView {
|
||||
pub(super) fn handle_composer_key(&mut self, key: KeyEvent) {
|
||||
let mut state = self.state();
|
||||
let offline = state.connection_notice.is_some();
|
||||
let status_grouping = state.status_grouping;
|
||||
if key.code == KeyCode::Esc && !state.composer_owns_escape() {
|
||||
state.focus = AgentsOverviewFocus::List;
|
||||
return;
|
||||
}
|
||||
let Some(composer) = state.composer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if offline
|
||||
&& !composer.popup_active()
|
||||
&& (self.composer_keymap.submit.is_pressed(key)
|
||||
|| self.composer_keymap.queue.is_pressed(key))
|
||||
{
|
||||
if key.code == KeyCode::Enter {
|
||||
composer.handle_paste_enter(std::time::Instant::now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (result, _) = composer.handle_key_event(key);
|
||||
drop(state);
|
||||
if let InputResult::Submitted { text, .. } = result {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::DispatchAgentsOverviewTask {
|
||||
prompt: text,
|
||||
cwd: (!status_grouping)
|
||||
.then(|| self.selected_row().map(|row| row.thread.cwd.clone()))
|
||||
.flatten(),
|
||||
});
|
||||
}
|
||||
}
|
||||
pub(super) fn layout_areas(&self, area: Rect) -> [Rect; 7] {
|
||||
let footer_height = if self.state().composing() {
|
||||
0
|
||||
} else {
|
||||
(self.footer_lines(area.width.saturating_sub(4)).len() as u16)
|
||||
.min(area.height.saturating_sub(7))
|
||||
};
|
||||
let mut state = self.state();
|
||||
let composing = state.composing();
|
||||
let mut hints = if state.connection_notice.is_some() && composing {
|
||||
vec![("esc".to_string(), "tasks · dispatch paused".to_string())]
|
||||
} else if composing {
|
||||
self.composer_hints.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let Some((key, _)) = hints.last_mut()
|
||||
&& state.composer_owns_escape()
|
||||
{
|
||||
*key = "esc esc".to_string();
|
||||
}
|
||||
if area.width < 60 && hints.len() > 2 {
|
||||
hints.remove(/*index*/ 1);
|
||||
}
|
||||
if composing && let Some(override_hints) = &state.key_chord_hint {
|
||||
hints = override_hints.clone();
|
||||
}
|
||||
let metadata = state.editing_metadata();
|
||||
let input_height = if metadata {
|
||||
1
|
||||
} else if let Some(composer) = state.composer.as_mut() {
|
||||
composer.set_footer_hint_override(Some(hints));
|
||||
composer
|
||||
.desired_height(area.width)
|
||||
.min((area.height / 3).max(/*other*/ 5))
|
||||
.min(area.height.saturating_sub(/*rhs*/ 7))
|
||||
.max(/*other*/ 3)
|
||||
} else {
|
||||
3
|
||||
};
|
||||
Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(u16::from(!metadata)),
|
||||
Constraint::Length(input_height),
|
||||
Constraint::Length(footer_height),
|
||||
])
|
||||
.areas(area)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
//! The prompt and its cursor reserve the same height for wrapped footer hints.
|
||||
|
||||
use super::*;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
|
||||
impl AgentsOverviewView {
|
||||
fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
pub(super) fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if self.state().connection_notice.is_some() {
|
||||
return vec![
|
||||
"ctrl+c quit · actions paused until the list is refreshed"
|
||||
"ctrl+c clear input, then quit · actions paused until the list is refreshed"
|
||||
.dim()
|
||||
.into(),
|
||||
];
|
||||
@@ -63,6 +64,12 @@ impl AgentsOverviewView {
|
||||
true,
|
||||
);
|
||||
add_hint(list_hint(ListAction::Accept), "open", true);
|
||||
add_hint(
|
||||
self.agents_keymap
|
||||
.primary_hint("new_task", &self.agents_keymap.new_task),
|
||||
"new task",
|
||||
true,
|
||||
);
|
||||
add_hint(
|
||||
self.agents_keymap
|
||||
.primary_hint("search", &self.agents_keymap.search),
|
||||
@@ -106,22 +113,39 @@ impl Renderable for AgentsOverviewView {
|
||||
24
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
let state = self.state();
|
||||
if state.composing()
|
||||
&& let Some(composer) = &state.composer
|
||||
{
|
||||
composer.cursor_style(area)
|
||||
} else {
|
||||
SetCursorStyle::DefaultUserShape
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let state = self.state().clone();
|
||||
if area.width < 12 || area.height < 8 {
|
||||
return None;
|
||||
}
|
||||
let [_, _, _, _, _, prompt, _] = self.layout_areas(area);
|
||||
let state = self.state();
|
||||
if state.composing() {
|
||||
return state.composer.as_ref()?.cursor_pos(prompt);
|
||||
}
|
||||
if !state.editing_metadata() {
|
||||
return None;
|
||||
}
|
||||
let (label, input) = if state.searching {
|
||||
(" Search › ", &state.search)
|
||||
} else if state.renaming {
|
||||
(" Rename › ", &state.input)
|
||||
} else {
|
||||
(" New task › ", &state.input)
|
||||
(" Rename › ", &state.input)
|
||||
};
|
||||
let x = area
|
||||
.x
|
||||
.saturating_add((label.width() + input.width()) as u16)
|
||||
.min(area.right().saturating_sub(3));
|
||||
let footer_height = (self.footer_lines(area.width.saturating_sub(4)).len() as u16)
|
||||
.min(area.height.saturating_sub(7));
|
||||
Some((x, area.bottom().saturating_sub(footer_height + 1)))
|
||||
Some((x, prompt.y))
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -129,17 +153,7 @@ impl Renderable for AgentsOverviewView {
|
||||
return;
|
||||
}
|
||||
Clear.render(area, buf);
|
||||
let footer_lines = self.footer_lines(area.width.saturating_sub(4));
|
||||
let footer_height = (footer_lines.len() as u16).min(area.height.saturating_sub(7));
|
||||
let [header, summary, divider, body, prompt, footer] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(footer_height),
|
||||
])
|
||||
.areas(area);
|
||||
let [header, summary, divider, body, title, prompt, footer] = self.layout_areas(area);
|
||||
let inset =
|
||||
|rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
|
||||
Line::from("Agent command center".bold()).render(inset(header), buf);
|
||||
@@ -179,18 +193,11 @@ impl Renderable for AgentsOverviewView {
|
||||
} else {
|
||||
self.render_rows(body, buf);
|
||||
}
|
||||
let state = self.state().clone();
|
||||
let state = self.state();
|
||||
let (label, input) = if state.searching {
|
||||
("Search › ", &state.search)
|
||||
} else if state.renaming {
|
||||
} else {
|
||||
("Rename › ", &state.input)
|
||||
} else {
|
||||
("New task › ", &state.input)
|
||||
};
|
||||
let placeholder = if input.is_empty() && !state.searching && !state.renaming {
|
||||
"Describe a task and press enter to dispatch it"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let available_width = usize::from(inset(prompt).width)
|
||||
.saturating_sub(label.width())
|
||||
@@ -205,9 +212,19 @@ impl Renderable for AgentsOverviewView {
|
||||
visible_width += width;
|
||||
visible_start = index;
|
||||
}
|
||||
let input = &input[visible_start..];
|
||||
Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()])
|
||||
.render(inset(prompt), buf);
|
||||
Paragraph::new(footer_lines).render(inset(footer), buf);
|
||||
if state.editing_metadata() {
|
||||
Line::from(vec![label.cyan().bold(), input[visible_start..].into()])
|
||||
.render(inset(prompt), buf);
|
||||
} else {
|
||||
Line::from("New task".dim()).render(inset(title), buf);
|
||||
if let Some(composer) = &state.composer {
|
||||
composer.render(prompt, buf);
|
||||
}
|
||||
}
|
||||
if state.composing() {
|
||||
return;
|
||||
}
|
||||
drop(state);
|
||||
Paragraph::new(self.footer_lines(inset(footer).width)).render(inset(footer), buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::app::test_support::make_test_app;
|
||||
use crate::app_event::AgentsOverviewThreadRefresh;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::chatwidget::tests::helpers::render_bottom_popup;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::test_support::PathBufExt;
|
||||
@@ -27,11 +28,195 @@ use codex_config::types::KeybindingsSpec;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
static OVERVIEW_TIMESTAMP: std::sync::LazyLock<i64> =
|
||||
std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120);
|
||||
|
||||
fn overview_draft(app: &App) -> (String, usize) {
|
||||
let state = app.agents_overview.view_state.lock().unwrap();
|
||||
let composer = state.composer.as_ref().unwrap();
|
||||
(composer.current_text_with_pending(), composer.cursor())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_composer_preserves_editing_and_routes_focus() {
|
||||
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 threads = vec![overview_thread(
|
||||
thread_id,
|
||||
/*parent_thread_id*/ None,
|
||||
"Existing task",
|
||||
ThreadStatus::Idle,
|
||||
)];
|
||||
let mut view = app.agents_overview_view(threads.clone(), Some(thread_id));
|
||||
view.handle_paste(" /first @file $skill\nthird \t".into());
|
||||
view.handle_key_event(KeyCode::Home.into());
|
||||
view.handle_paste("!second\n".into());
|
||||
let expected = overview_draft(&app);
|
||||
assert_eq!(
|
||||
expected,
|
||||
(" /first @file $skill\n!second\nthird \t".into(), 32)
|
||||
);
|
||||
view = app.agents_overview_view(threads.clone(), Some(thread_id));
|
||||
assert_eq!(overview_draft(&app), expected);
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled);
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
view.handle_paste(" renamed".into());
|
||||
view.handle_key_event(KeyCode::Enter.into());
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name == "Existing task renamed")
|
||||
);
|
||||
view = app.agents_overview_view(threads, Some(thread_id));
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
view.handle_key_event(KeyCode::Tab.into());
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::DispatchAgentsOverviewTask { prompt, .. }) if prompt == expected.0)
|
||||
);
|
||||
view.handle_key_event(KeyCode::Up.into());
|
||||
assert_eq!(overview_draft(&app).0, expected.0);
|
||||
assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled);
|
||||
view.handle_paste(" \t\n".into());
|
||||
view.handle_key_event(KeyCode::Enter.into());
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled);
|
||||
let mut server = crate::start_embedded_app_server_for_picker(&app.config)
|
||||
.await
|
||||
.unwrap();
|
||||
app.cli_kv_overrides = vec![("model".into(), toml::Value::Integer(1))];
|
||||
app.dispatch_agents_overview_task(&mut server, "retry me".into(), Some(app.config.cwd.clone()))
|
||||
.await;
|
||||
view.handle_paste(" later".into());
|
||||
app.submit_agents_overview_prompt(&server, thread_id, "older failure".into())
|
||||
.await;
|
||||
assert_eq!(overview_draft(&app).0, "retry me later");
|
||||
server.shutdown().await.unwrap();
|
||||
while rx.try_recv().is_ok() {}
|
||||
view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
view.handle_paste(format!(
|
||||
"x{}",
|
||||
" ".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS)
|
||||
));
|
||||
view.handle_key_event(KeyCode::Enter.into());
|
||||
assert!(matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))));
|
||||
assert!(view.next_frame_delay().is_some());
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" Message too long; limit 1048576 characters");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_composer_preserves_pastes_and_editor_bindings() {
|
||||
let mut app = make_test_app().await;
|
||||
app.config.disable_paste_burst = true;
|
||||
app.chat_widget.toggle_vim_mode_and_notify();
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string())));
|
||||
keymap.editor.insert_newline = Some(KeybindingsSpec::One(KeybindingSpec("f7".to_string())));
|
||||
app.keymap = RuntimeKeymap::from_config(&keymap).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);
|
||||
let pasted = "long paste ".repeat(/*n*/ 200);
|
||||
view.handle_paste(pasted.clone());
|
||||
view.handle_key_event(KeyCode::F(7).into());
|
||||
view.handle_paste("last line".into());
|
||||
view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 24,
|
||||
);
|
||||
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
|
||||
.unwrap();
|
||||
insta::assert_snapshot!(terminal.backend().to_string().lines().last().unwrap(), @r#"" f8 create task f7 newline esc esc tasks ""#);
|
||||
assert_eq!(view.cursor_style(area), SetCursorStyle::SteadyBar);
|
||||
view.handle_key_event(KeyCode::Home.into());
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
assert_eq!(view.cursor_style(area), SetCursorStyle::DefaultUserShape);
|
||||
view.handle_key_event(KeyCode::Char('d').into());
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
assert!(view.cursor_pos(area).is_some());
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
assert_eq!(view.cursor_pos(area), None);
|
||||
view.handle_key_event(KeyCode::Char('x').into());
|
||||
assert_eq!(view.cursor_pos(area), None);
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
|
||||
assert_eq!(view.cursor_style(area), SetCursorStyle::SteadyBar);
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
view.handle_paste("abc".into());
|
||||
view.handle_key_event(KeyCode::Char('x').into());
|
||||
view.handle_key_event(KeyCode::F(8).into());
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::DispatchAgentsOverviewTask { prompt, .. }) if prompt == format!("{pasted}\nabcxlast line"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_composer_flushes_pending_typing_after_refresh() {
|
||||
for disable_paste_burst in [false, true] {
|
||||
let mut app = make_test_app().await;
|
||||
app.config.disable_paste_burst = false;
|
||||
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
view.handle_key_event(KeyCode::Char('x').into());
|
||||
app.config.disable_paste_burst = disable_paste_burst;
|
||||
if !disable_paste_burst {
|
||||
app.chat_widget.toggle_vim_mode_and_notify();
|
||||
}
|
||||
view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
assert_eq!(overview_draft(&app).0, "x");
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
if !disable_paste_burst {
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
}
|
||||
assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled);
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
|
||||
view.handle_key_event(KeyCode::Up.into());
|
||||
assert_eq!(overview_draft(&app).0, "x");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn offline_overview_preserves_unbracketed_paste_newlines() {
|
||||
let mut app = make_test_app().await;
|
||||
app.config.disable_paste_burst = false;
|
||||
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.connection_notice = Some("Reconnecting");
|
||||
for ch in "a\nbc\nd".chars() {
|
||||
view.handle_key_event(
|
||||
if ch == '\n' {
|
||||
KeyCode::Enter
|
||||
} else {
|
||||
KeyCode::Char(ch)
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
view.handle_key_event(KeyCode::Tab.into());
|
||||
assert_eq!(view.on_ctrl_c(), CancellationEvent::Handled);
|
||||
view.handle_key_event(KeyCode::Up.into());
|
||||
view.handle_key_event(KeyCode::Enter.into());
|
||||
assert_eq!(overview_draft(&app).0, "a\nbc\nd");
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" ctrl+c clear input, then quit · actions paused until the list is refreshed");
|
||||
}
|
||||
|
||||
fn overview_thread(
|
||||
thread_id: ThreadId,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
@@ -236,6 +421,8 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res
|
||||
.copied()
|
||||
.collect();
|
||||
assert_eq!(visible, expected);
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
|
||||
@@ -471,6 +658,7 @@ async fn shared_overview_shows_only_root_sessions() {
|
||||
);
|
||||
let state = &app.agents_overview.view_state;
|
||||
assert!(!state.lock().unwrap().status_grouping);
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert!(state.lock().unwrap().status_grouping);
|
||||
app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
@@ -478,13 +666,13 @@ async fn shared_overview_shows_only_root_sessions() {
|
||||
assert!(
|
||||
action_view.handle_paste("Use \u{1b}[31mthe\u{1b}[0m current project\u{7}".to_string())
|
||||
);
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(
|
||||
event_rx.try_recv(),
|
||||
Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: None })
|
||||
if prompt == "Use the current project"
|
||||
));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert!(action_view.handle_paste("Fix the flaky tests after all retries complete".to_string()));
|
||||
let area = ratatui::layout::Rect::new(
|
||||
@@ -506,6 +694,7 @@ async fn shared_overview_shows_only_root_sessions() {
|
||||
&& cwd == test_path_buf("/tmp/project").abs()
|
||||
));
|
||||
assert!(action_view.handle_paste(" ".to_string()));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
|
||||
assert!(action_view.handle_paste("Repair authentication".to_string()));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
@@ -521,6 +710,7 @@ async fn shared_overview_shows_only_root_sessions() {
|
||||
if prompt.trim() == "Continue working"
|
||||
));
|
||||
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
|
||||
action_view.handle_paste("First line\nSecond line".into());
|
||||
crate::chatwidget::tests::helpers::set_active_cell(
|
||||
&mut app.chat_widget,
|
||||
Box::new(crate::history_cell::PlainHistoryCell::new(vec![
|
||||
@@ -577,6 +767,8 @@ async fn shared_overview_shows_only_root_sessions() {
|
||||
.collect();
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
|
||||
@@ -668,10 +860,12 @@ async fn daemon_start_result_snapshots() -> Result<()> {
|
||||
#[tokio::test]
|
||||
async fn filtered_dashboard_actions_use_configured_shortcuts() {
|
||||
let mut app = make_test_app().await;
|
||||
app.chat_widget.toggle_vim_mode_and_notify();
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.agents.search = Some(KeybindingsSpec::One(KeybindingSpec("f6".to_string())));
|
||||
keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string())));
|
||||
keymap.agents.resume = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string())));
|
||||
keymap.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("v".into())));
|
||||
app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||||
let first = ThreadId::new();
|
||||
let second = ThreadId::new();
|
||||
@@ -701,12 +895,20 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() {
|
||||
/*exit_on_cancel*/ false,
|
||||
crate::app_event_sender::AppEventSender::new(event_tx),
|
||||
app.keymap.clone(),
|
||||
Arc::default(),
|
||||
Arc::clone(&app.agents_overview.view_state),
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
|
||||
assert!(event_rx.try_recv().is_err());
|
||||
assert!(view.handle_paste("Do not dispatch this draft".to_string()));
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
for (key, expected) in [('v', second), ('k', first), ('j', first)] {
|
||||
view.handle_key_event(KeyCode::Char(key).into());
|
||||
let selected = &view.rows[view.selected_index().unwrap()];
|
||||
assert_eq!(selected.thread_id, expected);
|
||||
}
|
||||
assert_eq!(overview_draft(&app).0, "Do not dispatch this draft");
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
|
||||
assert!(view.handle_paste("Second task".to_string()));
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
|
||||
@@ -1146,6 +1348,7 @@ async fn restored_server_permission_profile_survives_cd_without_turn_override()
|
||||
async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> {
|
||||
for primary_thread_id in [None, Some(ThreadId::new())] {
|
||||
let mut app = make_test_app().await;
|
||||
app.config.disable_paste_burst = true;
|
||||
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
|
||||
app.primary_thread_id = primary_thread_id;
|
||||
@@ -1161,8 +1364,11 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()>
|
||||
let view = app.agents_overview_view(threads.into(), Some(selected));
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
for key in [
|
||||
KeyCode::Esc.into(),
|
||||
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
|
||||
KeyCode::Esc.into(),
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE),
|
||||
] {
|
||||
@@ -1197,7 +1403,7 @@ async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()>
|
||||
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
|
||||
selection
|
||||
);
|
||||
assert_eq!(app.agents_overview.view_state.lock().unwrap().input, "d");
|
||||
assert_eq!(overview_draft(&app).0, "d");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1214,7 +1420,8 @@ async fn command_center_cursor_tracks_wrapped_footer() {
|
||||
)],
|
||||
/*selected_thread_id*/ None,
|
||||
);
|
||||
for (key, label) in [('n', "New task ›"), ('f', "Search ›"), ('r', "Rename ›")] {
|
||||
for (key, label) in [('n', "› Describe"), ('f', "Search ›"), ('r', "Rename ›")] {
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL));
|
||||
for width in [48, 96, 120] {
|
||||
let area =
|
||||
@@ -1235,6 +1442,7 @@ async fn command_center_cursor_tracks_wrapped_footer() {
|
||||
view.cursor_pos(area).map(|(_, y)| usize::from(y)),
|
||||
Some(prompt_row)
|
||||
);
|
||||
assert!(area.contains(view.cursor_pos(area).unwrap().into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1246,6 +1454,7 @@ async fn empty_command_center_can_open_resume_picker() {
|
||||
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
app.chat_widget.handle_key_event(KeyCode::Esc.into());
|
||||
while event_rx.try_recv().is_ok() {}
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Dashboard for inspecting and managing the TUI's retained daemon tasks.
|
||||
//! The shared view state retains the new-task editor across metadata refreshes.
|
||||
|
||||
#[path = "agents_overview_input.rs"]
|
||||
mod input;
|
||||
#[path = "agents_overview_render.rs"]
|
||||
mod render;
|
||||
|
||||
@@ -7,6 +10,8 @@ use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::ViewCompletion;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::ShortcutHint;
|
||||
@@ -85,9 +90,13 @@ pub(super) struct AgentsOverviewRow {
|
||||
pub(super) is_current: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Default)]
|
||||
pub(super) struct AgentsOverviewViewState {
|
||||
// Search and rename never borrow the new-task draft.
|
||||
pub(super) input: String,
|
||||
pub(super) composer: Option<ChatComposer>,
|
||||
pub(super) key_chord_hint: Option<Vec<(String, String)>>,
|
||||
pub(super) focus: AgentsOverviewFocus,
|
||||
pub(super) connection_notice: Option<&'static str>,
|
||||
search: String,
|
||||
searching: bool,
|
||||
@@ -97,6 +106,40 @@ pub(super) struct AgentsOverviewViewState {
|
||||
pub(super) completion: Option<ViewCompletion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(super) enum AgentsOverviewFocus {
|
||||
#[default]
|
||||
Composer,
|
||||
List,
|
||||
}
|
||||
|
||||
impl AgentsOverviewViewState {
|
||||
pub(super) fn focus_composer(&mut self) {
|
||||
self.focus = AgentsOverviewFocus::Composer;
|
||||
if let Some(composer) = self.composer.as_mut() {
|
||||
composer.resume_text_entry();
|
||||
}
|
||||
}
|
||||
|
||||
fn editing_metadata(&self) -> bool {
|
||||
self.searching || self.renaming
|
||||
}
|
||||
|
||||
fn composing(&self) -> bool {
|
||||
self.focus == AgentsOverviewFocus::Composer && !self.searching && !self.renaming
|
||||
}
|
||||
|
||||
fn composer_owns_escape(&self) -> bool {
|
||||
self.composer.as_ref().is_some_and(|composer| {
|
||||
composer.popup_active()
|
||||
|| (composer.is_vim_enabled()
|
||||
&& !composer
|
||||
.keymap_contexts()
|
||||
.contains(KeymapContext::VimNormal))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct AgentsOverviewView {
|
||||
pub(super) rows: Vec<AgentsOverviewRow>,
|
||||
selected: usize,
|
||||
@@ -105,6 +148,8 @@ pub(super) struct AgentsOverviewView {
|
||||
app_event_tx: AppEventSender,
|
||||
keymap: ListKeymap,
|
||||
agents_keymap: AgentsKeymap,
|
||||
composer_hints: Vec<(String, String)>,
|
||||
composer_keymap: crate::keymap::ComposerKeymap,
|
||||
}
|
||||
|
||||
impl AgentsOverviewView {
|
||||
@@ -120,6 +165,18 @@ impl AgentsOverviewView {
|
||||
.and_then(|thread_id| rows.iter().position(|row| row.thread_id == thread_id))
|
||||
.or_else(|| rows.iter().position(|row| row.is_current))
|
||||
.unwrap_or(0);
|
||||
let composer_hints = [
|
||||
(KeymapContext::Composer, "submit", "create task"),
|
||||
(KeymapContext::Editor, "insert_newline", "newline"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(context, action, label)| {
|
||||
keymap
|
||||
.primary_hint(context, action)
|
||||
.map(|hint| (hint.display_label().replace(" + ", "+"), label.to_string()))
|
||||
})
|
||||
.chain([("esc".to_string(), "tasks".to_string())])
|
||||
.collect();
|
||||
let mut view = Self {
|
||||
rows,
|
||||
selected,
|
||||
@@ -128,6 +185,8 @@ impl AgentsOverviewView {
|
||||
app_event_tx,
|
||||
keymap: keymap.list,
|
||||
agents_keymap: keymap.agents,
|
||||
composer_hints,
|
||||
composer_keymap: keymap.composer,
|
||||
};
|
||||
view.state().completion = None;
|
||||
let visible = view.visible_indices();
|
||||
@@ -200,37 +259,23 @@ impl AgentsOverviewView {
|
||||
}
|
||||
|
||||
fn activate(&mut self) {
|
||||
let state = self.state().clone();
|
||||
let input = state.input.clone();
|
||||
if !state.searching && !input.is_empty() && input.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if !state.searching && !input.trim().is_empty() {
|
||||
if state.renaming {
|
||||
if let Some(row) = self.selected_row() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::RenameAgentsOverviewThread {
|
||||
thread_id: row.thread_id,
|
||||
name: input.trim().to_string(),
|
||||
});
|
||||
}
|
||||
self.state().renaming = false;
|
||||
} else {
|
||||
let input = self.state().input.clone();
|
||||
if self.state().renaming && !input.trim().is_empty() {
|
||||
if let Some(row) = self.selected_row() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::DispatchAgentsOverviewTask {
|
||||
prompt: input,
|
||||
cwd: (!state.status_grouping)
|
||||
.then(|| self.selected_row().map(|row| row.thread.cwd.clone()))
|
||||
.flatten(),
|
||||
.send(AppEvent::RenameAgentsOverviewThread {
|
||||
thread_id: row.thread_id,
|
||||
name: input.trim().to_string(),
|
||||
});
|
||||
}
|
||||
self.state().renaming = false;
|
||||
self.state().input.clear();
|
||||
} else if let Some(row) = self.selected_row().filter(|_| !state.renaming) {
|
||||
} else if let Some(row) = self.selected_row().filter(|_| !self.state().renaming) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::SelectAgentsOverviewThread {
|
||||
thread_id: row.thread_id,
|
||||
});
|
||||
if state.searching {
|
||||
if self.state().searching {
|
||||
let mut state = self.state();
|
||||
state.search.clear();
|
||||
state.searching = false;
|
||||
@@ -404,6 +449,10 @@ impl AgentsOverviewView {
|
||||
}
|
||||
|
||||
impl BottomPaneView for AgentsOverviewView {
|
||||
fn next_frame_delay(&self) -> Option<std::time::Duration> {
|
||||
self.state().composer.as_ref()?.footer_flash_delay()
|
||||
}
|
||||
|
||||
fn view_id(&self) -> Option<&'static str> {
|
||||
Some(AGENTS_OVERVIEW_VIEW_ID)
|
||||
}
|
||||
@@ -413,7 +462,15 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
}
|
||||
|
||||
fn keymap_contexts(&self) -> KeymapContextSet {
|
||||
KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents)
|
||||
let state = self.state();
|
||||
if state.composing() {
|
||||
state
|
||||
.composer
|
||||
.as_ref()
|
||||
.map_or_else(KeymapContextSet::default, ChatComposer::keymap_contexts)
|
||||
} else {
|
||||
KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents)
|
||||
}
|
||||
}
|
||||
|
||||
fn completion(&self) -> Option<ViewCompletion> {
|
||||
@@ -428,13 +485,63 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
let mut state = self.state();
|
||||
if state.editing_metadata() {
|
||||
state.searching = false;
|
||||
state.renaming = false;
|
||||
state.search.clear();
|
||||
state.input.clear();
|
||||
return CancellationEvent::Handled;
|
||||
}
|
||||
if let Some(composer) = state.composer.as_mut()
|
||||
&& (composer.cancel_vim_search()
|
||||
|| composer.cancel_history_search()
|
||||
|| composer.clear_for_ctrl_c().is_some())
|
||||
{
|
||||
return CancellationEvent::Handled;
|
||||
}
|
||||
CancellationEvent::NotHandled
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
self.edit_input(|input| {
|
||||
input.push_str(&crate::history_cell::sanitize_user_text(pasted.into()))
|
||||
})
|
||||
if self.state().editing_metadata() {
|
||||
return self.edit_input(|input| {
|
||||
input.push_str(&crate::history_cell::sanitize_user_text(pasted.into()))
|
||||
});
|
||||
}
|
||||
let mut state = self.state();
|
||||
if state.focus == AgentsOverviewFocus::List {
|
||||
state.focus_composer();
|
||||
}
|
||||
state
|
||||
.composer
|
||||
.as_mut()
|
||||
.is_some_and(|composer| composer.handle_paste(pasted))
|
||||
}
|
||||
|
||||
fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||
self.state()
|
||||
.composer
|
||||
.as_mut()
|
||||
.is_some_and(ChatComposer::flush_paste_burst_if_due)
|
||||
}
|
||||
|
||||
fn is_in_paste_burst(&self) -> bool {
|
||||
self.state()
|
||||
.composer
|
||||
.as_ref()
|
||||
.is_some_and(ChatComposer::is_in_paste_burst)
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key: KeyEvent) {
|
||||
if key.kind == crossterm::event::KeyEventKind::Release {
|
||||
return;
|
||||
}
|
||||
if self.state().composing() {
|
||||
self.handle_composer_key(key);
|
||||
return;
|
||||
}
|
||||
if key.code == KeyCode::Backspace
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.keymap.action_for(key).is_none()
|
||||
@@ -447,8 +554,20 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
if is_plain_text_key_event(key)
|
||||
&& let KeyCode::Char(character) = key.code
|
||||
{
|
||||
self.edit_input(|input| input.push(character));
|
||||
return;
|
||||
if self.state().editing_metadata() {
|
||||
self.edit_input(|input| input.push(character));
|
||||
return;
|
||||
}
|
||||
if !self
|
||||
.state()
|
||||
.composer
|
||||
.as_ref()
|
||||
.is_some_and(ChatComposer::is_vim_enabled)
|
||||
{
|
||||
self.state().focus_composer();
|
||||
self.handle_composer_key(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if self.agents_keymap.search.is_pressed(key) || {
|
||||
@@ -467,7 +586,8 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.state().connection_notice.is_some() {
|
||||
if self.state().connection_notice.is_some() && !self.agents_keymap.new_task.is_pressed(key)
|
||||
{
|
||||
match self.keymap.action_for(key) {
|
||||
Some(ListAction::MoveUp) => self.move_selection(/*forward*/ false),
|
||||
Some(ListAction::MoveDown) => self.move_selection(/*forward*/ true),
|
||||
@@ -491,6 +611,7 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
state.searching = false;
|
||||
state.renaming = false;
|
||||
state.input.clear();
|
||||
state.focus_composer();
|
||||
return;
|
||||
}
|
||||
if self.agents_keymap.rename.is_pressed(key) {
|
||||
|
||||
@@ -22,7 +22,7 @@ impl App {
|
||||
) {
|
||||
crate::keymap::KeyChordMatch::PassThrough => {
|
||||
if was_pending && !self.key_chord_matcher.is_pending() {
|
||||
self.chat_widget.set_footer_hint_override(/*items*/ None);
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
}
|
||||
Some(key_event)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ impl App {
|
||||
if self.backtrack.primed {
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
self.chat_widget.set_footer_hint_override(Some(vec![
|
||||
self.set_key_chord_hint_override(Some(vec![
|
||||
(
|
||||
format!("{} …", prefix.display_label()),
|
||||
"waiting for next key".to_string(),
|
||||
@@ -42,11 +42,11 @@ impl App {
|
||||
None
|
||||
}
|
||||
crate::keymap::KeyChordMatch::Completed(dispatch_event) => {
|
||||
self.chat_widget.set_footer_hint_override(/*items*/ None);
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
Some(dispatch_event)
|
||||
}
|
||||
crate::keymap::KeyChordMatch::Cancelled => {
|
||||
self.chat_widget.set_footer_hint_override(/*items*/ None);
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
None
|
||||
}
|
||||
crate::keymap::KeyChordMatch::Ignored => None,
|
||||
@@ -59,16 +59,25 @@ impl App {
|
||||
.key_chord_matcher
|
||||
.expire(contexts, tokio::time::Instant::now())
|
||||
{
|
||||
self.chat_widget.set_footer_hint_override(/*items*/ None);
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cancel_pending_key_chord(&mut self) {
|
||||
if self.key_chord_matcher.cancel() {
|
||||
self.chat_widget.set_footer_hint_override(/*items*/ None);
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_key_chord_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||||
self.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.key_chord_hint = items.clone();
|
||||
self.chat_widget.set_footer_hint_override(items);
|
||||
}
|
||||
|
||||
fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
|
||||
if self.overlay.is_some() {
|
||||
return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Pager);
|
||||
|
||||
@@ -199,7 +199,9 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
|
||||
};
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
app.agents_overview.view_state.lock().unwrap().input = "task draft".into();
|
||||
app.chat_widget.handle_paste("task draft".into());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
@@ -232,7 +234,14 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.agents_overview.view_state.lock().unwrap().input,
|
||||
app.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.composer
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.current_text_with_pending(),
|
||||
"task draft!"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -271,3 +271,28 @@ async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<(
|
||||
assert!(app.chat_widget.can_launch_external_editor());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
app.keymap = RuntimeKeymap::from_config(&toml::from_str(
|
||||
"[editor]\ninsert_newline = [\"ctrl-x n\"]",
|
||||
)?)
|
||||
.unwrap();
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let before = render_bottom_popup(&app.chat_widget, /*width*/ 80);
|
||||
assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None);
|
||||
let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
insta::assert_snapshot!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(),
|
||||
@" ctrl + x … waiting for next key esc cancel"
|
||||
);
|
||||
assert_eq!(
|
||||
app.route_key_chord_event(&mut tui, KeyCode::Esc.into()),
|
||||
None
|
||||
);
|
||||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 80), before);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -139,8 +139,20 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
);
|
||||
app.agents_overview.visible_thread_ids = view.thread_ids();
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into();
|
||||
if previous_thread.is_some() {
|
||||
app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into();
|
||||
} else {
|
||||
app.chat_widget.handle_paste("Keep this task draft".into());
|
||||
}
|
||||
app.agents_overview.view_state.lock().unwrap().renaming = previous_thread.is_some();
|
||||
let draft = |app: &App| {
|
||||
let state = app.agents_overview.view_state.lock().unwrap();
|
||||
if previous_thread.is_some() {
|
||||
state.input.clone()
|
||||
} else {
|
||||
state.composer.as_ref().unwrap().current_text_with_pending()
|
||||
}
|
||||
};
|
||||
let stale_request = Uuid::new_v4();
|
||||
app.agents_overview.request_id = Some(stale_request);
|
||||
app.agents_overview.refresh_pending = true;
|
||||
@@ -262,10 +274,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
.await?;
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into()))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.agents_overview.view_state.lock().unwrap().input,
|
||||
"Keep this task draft!"
|
||||
);
|
||||
assert_eq!(draft(&app), "Keep this task draft!");
|
||||
if previous_thread.is_none() {
|
||||
assert_snapshot!(
|
||||
"daemon_command_center_reconnecting",
|
||||
@@ -335,7 +344,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
assert!(!app.agents_overview.visible_thread_ids.contains(&vanished));
|
||||
assert!(app.agents_overview.visible_thread_ids.contains(&added));
|
||||
assert_eq!(
|
||||
app.agents_overview.view_state.lock().unwrap().input,
|
||||
draft(&app),
|
||||
if previous_thread.is_some() {
|
||||
""
|
||||
} else {
|
||||
@@ -386,6 +395,14 @@ 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());
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(id));
|
||||
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
|
||||
|
||||
@@ -424,7 +424,9 @@ async fn removing_remote_thread_omits_disconnect_guidance() -> Result<()> {
|
||||
app.chat_widget.handle_thread_session(resumed.session);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let archived = matches!(&event, AppEvent::ArchiveCurrentThread);
|
||||
let AppRunControl::Exit(reason) = app.handle_event(&mut tui, &mut server, event).await?
|
||||
// Keep the large dispatcher future off the Windows test thread's stack.
|
||||
let AppRunControl::Exit(reason) =
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?
|
||||
else {
|
||||
panic!("removing the current thread must exit");
|
||||
};
|
||||
@@ -1162,14 +1164,15 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac
|
||||
assert_eq!(list_requests[0]["sourceKinds"], serde_json::Value::Null);
|
||||
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.handle_event(
|
||||
// Box each dispatcher await so this test does not retain its large future inline.
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::DynamicToolCallCompleted {
|
||||
request_id,
|
||||
response,
|
||||
},
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
let completed = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), async {
|
||||
loop {
|
||||
@@ -1321,7 +1324,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac
|
||||
panic!("expected background task registration before its first turn: {registration:?}")
|
||||
};
|
||||
assert!(recorded_params(&requests, "turn/start").is_empty());
|
||||
app.handle_event(
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::DynamicToolThreadStarted {
|
||||
@@ -1329,7 +1332,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac
|
||||
task_tools_available,
|
||||
registered,
|
||||
},
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
assert!(
|
||||
app.agents_overview
|
||||
@@ -1409,7 +1412,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac
|
||||
};
|
||||
assert_eq!(continued_thread_id, creation_source);
|
||||
assert_eq!(recorded_params(&requests, "turn/start").len(), 1);
|
||||
app.handle_event(
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::DynamicToolThreadStarted {
|
||||
@@ -1417,7 +1420,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac
|
||||
task_tools_available,
|
||||
registered,
|
||||
},
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
let AppEvent::DynamicToolCallCompleted { response, .. } =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), events.recv())
|
||||
|
||||
+5
-5
@@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › task draft!
|
||||
ctrl+c quit · actions paused until the list is refreshed
|
||||
New task
|
||||
|
||||
› task draft!
|
||||
|
||||
esc tasks · dispatch paused
|
||||
|
||||
+5
-5
@@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › Keep this task draft!
|
||||
ctrl+c quit · actions paused until the list is refreshed
|
||||
New task
|
||||
|
||||
› Keep this task draft!
|
||||
|
||||
esc tasks · dispatch paused
|
||||
|
||||
+5
-5
@@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › Keep this task draft!
|
||||
ctrl+c quit · actions paused until the list is refreshed
|
||||
New task
|
||||
|
||||
› Keep this task draft!
|
||||
|
||||
esc tasks · dispatch paused
|
||||
|
||||
+5
-5
@@ -21,8 +21,8 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › Describe a task and press enter to dispatch it
|
||||
↑↓ navigate ctrl+o resume enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc
|
||||
back
|
||||
New task
|
||||
|
||||
› Describe a new task
|
||||
|
||||
enter create task ctrl+j newline esc tasks
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
//! On submit/queue paths, the composer:
|
||||
//!
|
||||
//! - Expands pending paste placeholders so element ranges align with the final text.
|
||||
//! - Trims whitespace and rebases text elements accordingly.
|
||||
//! - Trims whitespace and rebases elements when `trim_submission` is enabled (the default).
|
||||
//! Otherwise, preserves both.
|
||||
//! - Treats a leading `!` revealed only by paste expansion as literal model input, not shell input.
|
||||
//! - Prunes local attached images so only placeholders that survive expansion are sent.
|
||||
//! - Preserves remote image URLs as separate attachments even when text is empty.
|
||||
@@ -470,10 +471,7 @@ enum PendingPasteHandling {
|
||||
Preserve,
|
||||
}
|
||||
|
||||
/// Feature flags for reusing the chat composer in other bottom-pane surfaces.
|
||||
///
|
||||
/// The default keeps today's behavior intact. Other call sites can opt out of
|
||||
/// specific behaviors by constructing a config with those flags set to `false`.
|
||||
/// Shared composer behavior; defaults match the main chat input.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ChatComposerConfig {
|
||||
/// Whether command/file/skill popups are allowed to appear.
|
||||
@@ -484,6 +482,8 @@ pub(crate) struct ChatComposerConfig {
|
||||
pub(crate) shell_commands_enabled: bool,
|
||||
/// Whether pasting a file path can attach local images.
|
||||
pub(crate) image_paste_enabled: bool,
|
||||
/// Strip leading and trailing whitespace from submissions.
|
||||
pub(crate) trim_submission: bool,
|
||||
}
|
||||
|
||||
impl Default for ChatComposerConfig {
|
||||
@@ -493,6 +493,7 @@ impl Default for ChatComposerConfig {
|
||||
slash_commands_enabled: true,
|
||||
shell_commands_enabled: true,
|
||||
image_paste_enabled: true,
|
||||
trim_submission: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +509,7 @@ impl ChatComposerConfig {
|
||||
slash_commands_enabled: false,
|
||||
shell_commands_enabled: false,
|
||||
image_paste_enabled: false,
|
||||
trim_submission: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3086,11 +3088,11 @@ impl ChatComposer {
|
||||
text_elements = expanded_elements;
|
||||
}
|
||||
|
||||
let expanded_input = text.clone();
|
||||
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
text = text.trim().to_string();
|
||||
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
|
||||
if self.config.trim_submission {
|
||||
let expanded_input = text.clone();
|
||||
text = text.trim().to_string();
|
||||
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
|
||||
}
|
||||
|
||||
if slash_validation == SlashValidation::Immediate
|
||||
&& let SubmissionValidation::UnknownCommand(name) = self
|
||||
@@ -3146,7 +3148,7 @@ impl ChatComposer {
|
||||
}
|
||||
self.attachments
|
||||
.prune_local_images_for_submission(&text, &text_elements);
|
||||
if text.is_empty() && self.attachments.is_empty() {
|
||||
if text.trim().is_empty() && self.attachments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.draft.recent_submission_mention_bindings = original_mention_bindings.clone();
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
//! Offline editing retains the draft, with shared paste Enter handling for submission.
|
||||
//! Offline editing and event-channel rebinding retain the draft in place.
|
||||
//! Paste Enter handling is shared with normal submission so buffered newlines survive both paths.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl ChatComposer {
|
||||
/// Rebind retained editors after the app replaces its event channel.
|
||||
pub(crate) fn set_app_event_sender(&mut self, sender: AppEventSender) {
|
||||
self.app_event_tx = sender;
|
||||
}
|
||||
|
||||
/// Preserve Enter inside a paste burst without attempting submission.
|
||||
pub(super) fn handle_paste_enter(&mut self, now: Instant) -> bool {
|
||||
pub(crate) fn handle_paste_enter(&mut self, now: Instant) -> bool {
|
||||
let in_slash_context = self.slash_commands_enabled()
|
||||
&& !self.draft.is_bash_mode
|
||||
&& (matches!(self.popups.active, ActivePopup::Command(_))
|
||||
|
||||
@@ -1719,6 +1719,10 @@ impl ChatWidget {
|
||||
self.bottom_pane.composer_is_empty() && !self.bottom_pane.is_in_paste_burst()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_vim_enabled(&self) -> bool {
|
||||
self.bottom_pane.composer_is_vim_enabled()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_task_running_for_test(&self) -> bool {
|
||||
self.bottom_pane.is_task_running()
|
||||
|
||||
+6
-6
@@ -20,9 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › Describe a task and press enter to dispatch it
|
||||
↑↓ navigate ctrl+o resume enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop
|
||||
esc back
|
||||
New task
|
||||
|
||||
› First line
|
||||
Second line
|
||||
|
||||
enter create task ctrl+j newline esc tasks
|
||||
|
||||
+5
-5
@@ -19,10 +19,10 @@ expression: "render_bottom_popup(&app.chat_widget, 48)"
|
||||
|
||||
|
||||
|
||||
New task
|
||||
|
||||
› Describe a new task
|
||||
|
||||
|
||||
New task › Describe a task and press enter t
|
||||
↑↓ navigate ctrl+o resume enter open ctrl+f
|
||||
search ctrl+s group ctrl+r rename ctrl+x
|
||||
stop esc back
|
||||
↑↓ navigate ctrl+o resume enter open ctrl+n
|
||||
new task ctrl+f search ctrl+s group ctrl+r
|
||||
rename ctrl+x stop esc back
|
||||
|
||||
Reference in New Issue
Block a user