Extract focused TUI logic into submodules (#42397)

## What changed

- Move `AgentsOverviewView` rendering and cursor placement into
  `agents_overview_render.rs`.
- Extract paste-burst Enter handling from the main chat composer into the
  reconnect module while preserving the existing submission behavior.

GitOrigin-RevId: 641daf9ad6cba79d805a9ae100f63ef81470757d
This commit is contained in:
Eric Traut
2026-09-03 00:27:16 +00:00
committed by copyberry
parent 9bb1ea035f
commit e6a944ad75
4 changed files with 234 additions and 223 deletions
@@ -0,0 +1,194 @@
//! Layout rendering and cursor placement for the agent dashboard.
use super::*;
impl Renderable for AgentsOverviewView {
fn desired_height(&self, _width: u16) -> u16 {
24
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let state = self.state().clone();
let (label, input) = if state.searching {
(" Search › ", &state.search)
} else if state.renaming {
(" Rename › ", &state.input)
} else {
(" New task › ", &state.input)
};
let x = area
.x
.saturating_add((label.width() + input.width()) as u16)
.min(area.right().saturating_sub(3));
Some((x, area.bottom().saturating_sub(2)))
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.width < 12 || area.height < 8 {
return;
}
Clear.render(area, buf);
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(1),
])
.areas(area);
let inset =
|rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
Line::from("Agent command center".bold()).render(inset(header), buf);
let (needs_you, working, ready) = self.rows.iter().fold((0, 0, 0), |counts, row| {
let (needs_you, working, ready) = counts;
match row.group {
AgentsOverviewGroup::NeedsYou => (needs_you + 1, working, ready),
AgentsOverviewGroup::Working => (needs_you, working + 1, ready),
AgentsOverviewGroup::Ready => (needs_you, working, ready + 1),
AgentsOverviewGroup::Finished => counts,
}
});
let attention = format!("{needs_you} need input");
if let Some(notice) = self.state().connection_notice {
Line::from(notice.cyan()).render(inset(summary), buf);
} else {
Line::from(format!("{attention} {working} working {ready} ready").dim())
.render(inset(summary), buf);
}
Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim())
.render(inset(divider), buf);
let body = inset(body);
if body.width >= 90 {
let [list, gap, details] = Layout::horizontal([
Constraint::Min(46),
Constraint::Length(3),
Constraint::Length(38),
])
.areas(body);
for y in gap.y..gap.bottom() {
buf[(gap.x + 1, y)]
.set_symbol("│")
.set_style(Style::new().dim());
}
self.render_rows(list, buf);
self.render_details(details, buf);
} else {
self.render_rows(body, buf);
}
let state = self.state().clone();
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 {
""
};
let available_width = usize::from(inset(prompt).width)
.saturating_sub(label.width())
.saturating_sub(1);
let mut visible_start = input.len();
let mut visible_width = 0;
for (index, character) in input.char_indices().rev() {
let width = character.width().unwrap_or(0);
if visible_width + width > available_width {
break;
}
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);
if state.connection_notice.is_some() {
Line::from("ctrl+c quit · actions paused until the list is refreshed".dim())
.render(inset(footer), buf);
return;
}
let list_hint = |action| {
self.keymap.primary_hint(action).filter(|hint| {
!matches!(hint, ShortcutHint::Single(binding)
if is_plain_text_key_event(KeyEvent::new(
binding.parts().0,
binding.parts().1,
)) || [
&self.agents_keymap.search,
&self.agents_keymap.new_task,
&self.agents_keymap.rename,
&self.agents_keymap.stop,
&self.agents_keymap.toggle_grouping,
]
.into_iter()
.any(|bindings| bindings.contains(binding)))
})
};
let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown]
.into_iter()
.filter_map(list_hint)
.map(|hint| hint.display_label().replace(" + ", "+"))
.collect::<Vec<_>>();
let navigation_hint = navigation_hints.join(
if navigation_hints
.iter()
.all(|hint| hint.chars().count() == 1)
{
""
} else {
" "
},
);
let mut footer_spans = Vec::new();
if !navigation_hint.is_empty() {
footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]);
}
let mut add_hint = |hint: Option<ShortcutHint>, label: &'static str, enabled: bool| {
if let Some(hint) = hint {
let key = hint.display_label().replace(" + ", "+");
footer_spans.push(if enabled { key.bold() } else { key.dim() });
footer_spans.push(format!(" {label} ").dim());
}
};
add_hint(list_hint(ListAction::Accept), "open", true);
add_hint(
self.agents_keymap
.primary_hint("search", &self.agents_keymap.search),
"search",
true,
);
add_hint(
self.agents_keymap
.primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping),
"group",
true,
);
add_hint(
self.agents_keymap
.primary_hint("rename", &self.agents_keymap.rename),
"rename",
true,
);
add_hint(
self.agents_keymap
.primary_hint("stop", &self.agents_keymap.stop),
"stop",
self.selected_row()
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })),
);
add_hint(list_hint(ListAction::Cancel), "back", true);
let footer_area = inset(footer);
let mut footer_line: Line = footer_spans.into();
if footer_line.width() > usize::from(footer_area.width) {
for span in &mut footer_line.spans {
if span.content.ends_with(" ") {
span.content.to_mut().pop();
}
}
}
footer_line.render(footer_area, buf);
}
}
+3 -191
View File
@@ -1,5 +1,8 @@
//! Dashboard for inspecting and managing the TUI's retained daemon tasks.
#[path = "agents_overview_render.rs"]
mod render;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -555,194 +558,3 @@ impl BottomPaneView for AgentsOverviewView {
}
}
}
impl Renderable for AgentsOverviewView {
fn desired_height(&self, _width: u16) -> u16 {
24
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let state = self.state().clone();
let (label, input) = if state.searching {
(" Search › ", &state.search)
} else if state.renaming {
(" Rename › ", &state.input)
} else {
(" New task › ", &state.input)
};
let x = area
.x
.saturating_add((label.width() + input.width()) as u16)
.min(area.right().saturating_sub(3));
Some((x, area.bottom().saturating_sub(2)))
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.width < 12 || area.height < 8 {
return;
}
Clear.render(area, buf);
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(1),
])
.areas(area);
let inset =
|rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
Line::from("Agent command center".bold()).render(inset(header), buf);
let (needs_you, working, ready) = self.rows.iter().fold((0, 0, 0), |counts, row| {
let (needs_you, working, ready) = counts;
match row.group {
AgentsOverviewGroup::NeedsYou => (needs_you + 1, working, ready),
AgentsOverviewGroup::Working => (needs_you, working + 1, ready),
AgentsOverviewGroup::Ready => (needs_you, working, ready + 1),
AgentsOverviewGroup::Finished => counts,
}
});
let attention = format!("{needs_you} need input");
if let Some(notice) = self.state().connection_notice {
Line::from(notice.cyan()).render(inset(summary), buf);
} else {
Line::from(format!("{attention} {working} working {ready} ready").dim())
.render(inset(summary), buf);
}
Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim())
.render(inset(divider), buf);
let body = inset(body);
if body.width >= 90 {
let [list, gap, details] = Layout::horizontal([
Constraint::Min(46),
Constraint::Length(3),
Constraint::Length(38),
])
.areas(body);
for y in gap.y..gap.bottom() {
buf[(gap.x + 1, y)]
.set_symbol("│")
.set_style(Style::new().dim());
}
self.render_rows(list, buf);
self.render_details(details, buf);
} else {
self.render_rows(body, buf);
}
let state = self.state().clone();
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 {
""
};
let available_width = usize::from(inset(prompt).width)
.saturating_sub(label.width())
.saturating_sub(1);
let mut visible_start = input.len();
let mut visible_width = 0;
for (index, character) in input.char_indices().rev() {
let width = character.width().unwrap_or(0);
if visible_width + width > available_width {
break;
}
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);
if state.connection_notice.is_some() {
Line::from("ctrl+c quit · actions paused until the list is refreshed".dim())
.render(inset(footer), buf);
return;
}
let list_hint = |action| {
self.keymap.primary_hint(action).filter(|hint| {
!matches!(hint, ShortcutHint::Single(binding)
if is_plain_text_key_event(KeyEvent::new(
binding.parts().0,
binding.parts().1,
)) || [
&self.agents_keymap.search,
&self.agents_keymap.new_task,
&self.agents_keymap.rename,
&self.agents_keymap.stop,
&self.agents_keymap.toggle_grouping,
]
.into_iter()
.any(|bindings| bindings.contains(binding)))
})
};
let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown]
.into_iter()
.filter_map(list_hint)
.map(|hint| hint.display_label().replace(" + ", "+"))
.collect::<Vec<_>>();
let navigation_hint = navigation_hints.join(
if navigation_hints
.iter()
.all(|hint| hint.chars().count() == 1)
{
""
} else {
" "
},
);
let mut footer_spans = Vec::new();
if !navigation_hint.is_empty() {
footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]);
}
let mut add_hint = |hint: Option<ShortcutHint>, label: &'static str, enabled: bool| {
if let Some(hint) = hint {
let key = hint.display_label().replace(" + ", "+");
footer_spans.push(if enabled { key.bold() } else { key.dim() });
footer_spans.push(format!(" {label} ").dim());
}
};
add_hint(list_hint(ListAction::Accept), "open", true);
add_hint(
self.agents_keymap
.primary_hint("search", &self.agents_keymap.search),
"search",
true,
);
add_hint(
self.agents_keymap
.primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping),
"group",
true,
);
add_hint(
self.agents_keymap
.primary_hint("rename", &self.agents_keymap.rename),
"rename",
true,
);
add_hint(
self.agents_keymap
.primary_hint("stop", &self.agents_keymap.stop),
"stop",
self.selected_row()
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })),
);
add_hint(list_hint(ListAction::Cancel), "back", true);
let footer_area = inset(footer);
let mut footer_line: Line = footer_spans.into();
if footer_line.width() > usize::from(footer_area.width) {
for span in &mut footer_line.spans {
if span.content.ends_with(" ") {
span.content.to_mut().pop();
}
}
}
footer_line.render(footer_area, buf);
}
}
+1 -31
View File
@@ -3184,37 +3184,7 @@ impl ChatComposer {
should_queue: bool,
now: Instant,
) -> (InputResult, bool) {
// Preserve newlines that are part of a paste before applying parent-owned submission
// policy. Queued startup input still flushes the burst into its queued message below.
let in_slash_context = self.slash_commands_enabled()
&& !self.draft.is_bash_mode
&& (matches!(self.popups.active, ActivePopup::Command(_))
|| self
.draft
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/'));
if !should_queue
&& !self.draft.disable_paste_burst
&& self.draft.paste_burst.is_active()
&& !in_slash_context
&& self.draft.paste_burst.append_newline_if_active(now)
{
return (InputResult::None, true);
}
if !should_queue
&& !in_slash_context
&& !self.draft.disable_paste_burst
&& self
.draft
.paste_burst
.newline_should_insert_instead_of_submit(now)
{
self.draft.textarea.insert_str("\n");
self.draft.paste_burst.extend_window(now);
if !should_queue && self.handle_paste_enter(now) {
return (InputResult::None, true);
}
@@ -1,8 +1,43 @@
//! Offline editing bypasses command dispatch and popup actions, retaining the draft in place.
//! Offline editing retains the draft, with shared paste Enter handling for submission.
use super::*;
impl ChatComposer {
/// Preserve Enter inside a paste burst without attempting submission.
pub(super) 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(_))
|| self
.draft
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/'));
if !self.draft.disable_paste_burst
&& self.draft.paste_burst.is_active()
&& !in_slash_context
&& self.draft.paste_burst.append_newline_if_active(now)
{
return true;
}
if !in_slash_context
&& !self.draft.disable_paste_burst
&& self
.draft
.paste_burst
.newline_should_insert_instead_of_submit(now)
{
self.draft.textarea.insert_str("\n");
self.draft.paste_burst.extend_window(now);
return true;
}
false
}
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
self.cancel_history_search();
self.attachments.clear_remote_image_selection();