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:
Eric Traut
2026-09-03 03:16:24 +00:00
committed by copyberry
parent cac96cd7b1
commit 62f553bfd0
20 changed files with 717 additions and 145 deletions
+14 -12
View File
@@ -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)
}
}
+50 -33
View File
@@ -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);
}
}
+213 -4
View File
@@ -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));
+152 -31
View File
@@ -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) {
+15 -6
View File
@@ -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);
+11 -2
View File
@@ -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!(
+25
View File
@@ -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())
@@ -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
@@ -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
@@ -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
@@ -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
+13 -11
View File
@@ -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(_))
+4
View File
@@ -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()
@@ -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
@@ -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