mirror of
https://github.com/openai/codex.git
synced 2026-10-22 01:34:54 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user