tui: separator above final agent message (#4324)

Adds a separator line before the final agent message

<img width="1011" height="884" alt="Screenshot 2025-09-26 at 4 55 01 PM"
src="https://github.com/user-attachments/assets/7c91adbf-6035-4578-8b88-a6921f11bcbc"
/>
This commit is contained in:
Jeremy Rose
2025-09-26 22:49:59 -07:00
committed by GitHub
parent 90c3a5650c
commit c0960c0f49
6 changed files with 61 additions and 2 deletions

View File

@@ -102,6 +102,10 @@ impl BottomPane {
} }
} }
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
self.status.as_ref()
}
fn active_view(&self) -> Option<&dyn BottomPaneView> { fn active_view(&self) -> Option<&dyn BottomPaneView> {
self.view_stack.last().map(std::convert::AsRef::as_ref) self.view_stack.last().map(std::convert::AsRef::as_ref)
} }

View File

@@ -254,6 +254,8 @@ pub(crate) struct ChatWidget {
// List of ghost commits corresponding to each turn. // List of ghost commits corresponding to each turn.
ghost_snapshots: Vec<GhostCommit>, ghost_snapshots: Vec<GhostCommit>,
ghost_snapshots_disabled: bool, ghost_snapshots_disabled: bool,
// Whether to add a final message separator after the last message
needs_final_message_separator: bool,
} }
struct UserMessage { struct UserMessage {
@@ -648,6 +650,14 @@ impl ChatWidget {
self.flush_active_cell(); self.flush_active_cell();
if self.stream_controller.is_none() { if self.stream_controller.is_none() {
if self.needs_final_message_separator {
let elapsed_seconds = self
.bottom_pane
.status_widget()
.map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds);
self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds));
self.needs_final_message_separator = false;
}
self.stream_controller = Some(StreamController::new(self.config.clone())); self.stream_controller = Some(StreamController::new(self.config.clone()));
} }
if let Some(controller) = self.stream_controller.as_mut() if let Some(controller) = self.stream_controller.as_mut()
@@ -901,6 +911,7 @@ impl ChatWidget {
is_review_mode: false, is_review_mode: false,
ghost_snapshots: Vec::new(), ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: true, ghost_snapshots_disabled: true,
needs_final_message_separator: false,
} }
} }
@@ -962,6 +973,7 @@ impl ChatWidget {
is_review_mode: false, is_review_mode: false,
ghost_snapshots: Vec::new(), ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: true, ghost_snapshots_disabled: true,
needs_final_message_separator: false,
} }
} }
@@ -1188,6 +1200,7 @@ impl ChatWidget {
fn flush_active_cell(&mut self) { fn flush_active_cell(&mut self) {
if let Some(active) = self.active_cell.take() { if let Some(active) = self.active_cell.take() {
self.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
} }
} }
@@ -1200,6 +1213,7 @@ impl ChatWidget {
if !cell.display_lines(u16::MAX).is_empty() { if !cell.display_lines(u16::MAX).is_empty() {
// Only break exec grouping if the cell renders visible lines. // Only break exec grouping if the cell renders visible lines.
self.flush_active_cell(); self.flush_active_cell();
self.needs_final_message_separator = true;
} }
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
} }
@@ -1241,6 +1255,7 @@ impl ChatWidget {
if !text.is_empty() { if !text.is_empty() {
self.add_to_history(history_cell::new_user_prompt(text)); self.add_to_history(history_cell::new_user_prompt(text));
} }
self.needs_final_message_separator = false;
} }
fn capture_ghost_snapshot(&mut self) { fn capture_ghost_snapshot(&mut self) {

View File

@@ -1,5 +1,6 @@
--- ---
source: tui/src/chatwidget/tests.rs source: tui/src/chatwidget/tests.rs
assertion_line: 1152
expression: "lines[start_idx..].join(\"\\n\")" expression: "lines[start_idx..].join(\"\\n\")"
--- ---
• I need to check the codex-rs repository to explain why the project's binaries • I need to check the codex-rs repository to explain why the project's binaries
@@ -9,6 +10,8 @@ expression: "lines[start_idx..].join(\"\\n\")"
is set up. I should look into the Cargo.toml file to confirm features and is set up. I should look into the Cargo.toml file to confirm features and
profiles without needing to edit any code. Let's get started on this! profiles without needing to edit any code. Let's get started on this!
─ Worked for 0s ────────────────────────────────────────────────────────────────
• Im going to scan the workspace and Cargo manifests to see build profiles and • Im going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then Ill summarize the main causes. dependencies that impact binary size. Then Ill summarize the main causes.
@@ -110,6 +113,8 @@ expression: "lines[start_idx..].join(\"\\n\")"
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for "Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
reducing size, but I want to stay focused on answering the user's question. reducing size, but I want to stay focused on answering the user's question.
─ Worked for 0s ────────────────────────────────────────────────────────────────
• Heres whats driving size in this workspaces binaries. • Heres whats driving size in this workspaces binaries.
Main Causes Main Causes

View File

@@ -340,6 +340,7 @@ fn make_chatwidget_manual() -> (
is_review_mode: false, is_review_mode: false,
ghost_snapshots: Vec::new(), ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: false, ghost_snapshots_disabled: false,
needs_final_message_separator: false,
}; };
(widget, rx, op_rx) (widget, rx, op_rx)
} }

View File

@@ -1090,6 +1090,40 @@ pub(crate) fn new_reasoning_summary_block(
Box::new(new_reasoning_block(full_reasoning_buffer, config)) Box::new(new_reasoning_block(full_reasoning_buffer, config))
} }
#[derive(Debug)]
pub struct FinalMessageSeparator {
elapsed_seconds: Option<u64>,
}
impl FinalMessageSeparator {
pub(crate) fn new(elapsed_seconds: Option<u64>) -> Self {
Self { elapsed_seconds }
}
}
impl HistoryCell for FinalMessageSeparator {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let elapsed_seconds = self
.elapsed_seconds
.map(super::status_indicator_widget::fmt_elapsed_compact);
if let Some(elapsed_seconds) = elapsed_seconds {
let worked_for = format!("─ Worked for {elapsed_seconds}");
let worked_for_width = worked_for.width();
vec![
Line::from_iter([
worked_for,
"".repeat((width as usize).saturating_sub(worked_for_width)),
])
.dim(),
]
} else {
vec![Line::from_iter(["".repeat(width as usize).dim()])]
}
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
vec![]
}
}
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
let args_str = invocation let args_str = invocation
.arguments .arguments

View File

@@ -34,7 +34,7 @@ pub(crate) struct StatusIndicatorWidget {
// Format elapsed seconds into a compact human-friendly form used by the status line. // Format elapsed seconds into a compact human-friendly form used by the status line.
// Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s // Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s
fn fmt_elapsed_compact(elapsed_secs: u64) -> String { pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
if elapsed_secs < 60 { if elapsed_secs < 60 {
return format!("{elapsed_secs}s"); return format!("{elapsed_secs}s");
} }
@@ -142,7 +142,7 @@ impl StatusIndicatorWidget {
elapsed.as_secs() elapsed.as_secs()
} }
fn elapsed_seconds(&self) -> u64 { pub fn elapsed_seconds(&self) -> u64 {
self.elapsed_seconds_at(Instant::now()) self.elapsed_seconds_at(Instant::now())
} }
} }