Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions codex-rs/tui/src/app/agents_overview.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -252,6 +260,8 @@ impl App {
});
}

self.sync_agents_overview_composer();

AgentsOverviewView::new(
rows,
selected_thread_id,
Expand Down Expand Up @@ -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}"));
Expand All @@ -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());
Expand Down Expand Up @@ -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}"));
}
Expand Down Expand Up @@ -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}"));
}
Expand Down
56 changes: 56 additions & 0 deletions codex-rs/tui/src/app/agents_overview_composer.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
92 changes: 92 additions & 0 deletions codex-rs/tui/src/app/agents_overview_input.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
83 changes: 50 additions & 33 deletions codex-rs/tui/src/app/agents_overview_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
];
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -106,40 +113,47 @@ 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) {
if area.width < 12 || area.height < 8 {
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);
Expand Down Expand Up @@ -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 {
("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 {
""
("Rename › ", &state.input)
};
let available_width = usize::from(inset(prompt).width)
.saturating_sub(label.width())
Expand All @@ -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);
}
}
Loading
Loading