feat: enhance TUI with improved styling and animations

- Fix reasoning text dimming and markdown formatting
- Preserve empty lines in markdown processing
- Add smart spacing between messages
- Implement weighted random spinner selection (stars, diamonds, rare sparkle)
- Use secondary color for spinners and model names throughout UI
- Remove compilation warnings and unused code
- Clean up StatusIndicatorWidget and related unused functions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
James Peter
2025-08-12 22:50:26 +10:00
co-authored by Claude
parent 0387e602ad
commit e2930ce7fe
9 changed files with 533 additions and 540 deletions
+4 -5
View File
@@ -207,11 +207,10 @@ impl App<'_> {
while let Ok(event) = self.app_event_rx.recv() { while let Ok(event) = self.app_event_rx.recv() {
match event { match event {
AppEvent::InsertHistory(_lines) => { AppEvent::InsertHistory(lines) => match &mut self.app_state {
// No longer using scrollback, history is managed in ChatWidget AppState::Chat { widget } => widget.insert_history_lines(lines),
// Just trigger a redraw AppState::Onboarding { .. } => {}
self.app_event_tx.send(AppEvent::RequestRedraw); },
}
AppEvent::RequestRedraw => { AppEvent::RequestRedraw => {
self.schedule_redraw(); self.schedule_redraw();
} }
+167 -23
View File
@@ -26,6 +26,8 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState; use crate::bottom_pane::textarea::TextAreaState;
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use std::{thread, time::Duration};
use codex_file_search::FileMatch; use codex_file_search::FileMatch;
use std::cell::RefCell; use std::cell::RefCell;
@@ -37,6 +39,8 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
/// Result returned when the user interacts with the text area. /// Result returned when the user interacts with the text area.
pub enum InputResult { pub enum InputResult {
Submitted(String), Submitted(String),
ScrollUp,
ScrollDown,
None, None,
} }
@@ -61,6 +65,11 @@ pub(crate) struct ChatComposer {
has_focus: bool, has_focus: bool,
has_chat_history: bool, has_chat_history: bool,
last_esc_time: Option<std::time::Instant>, last_esc_time: Option<std::time::Instant>,
is_task_running: bool,
// Current status message to display when task is running
status_message: String,
// Animation thread for spinning icon when task is running
animation_running: Option<Arc<AtomicBool>>,
} }
/// Popup state – at most one can be visible at any time. /// Popup state – at most one can be visible at any time.
@@ -93,6 +102,9 @@ impl ChatComposer {
has_focus: has_input_focus, has_focus: has_input_focus,
has_chat_history: false, has_chat_history: false,
last_esc_time: None, last_esc_time: None,
is_task_running: false,
status_message: String::from("coding"),
animation_running: None,
} }
} }
@@ -100,6 +112,75 @@ impl ChatComposer {
self.has_chat_history = has_history; self.has_chat_history = has_history;
} }
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
if running {
// Start animation thread if not already running
if self.animation_running.is_none() {
let animation_flag = Arc::new(AtomicBool::new(true));
let animation_flag_clone = Arc::clone(&animation_flag);
let app_event_tx_clone = self.app_event_tx.clone();
thread::spawn(move || {
while animation_flag_clone.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(200)); // Slower animation
app_event_tx_clone.send(AppEvent::RequestRedraw);
}
});
self.animation_running = Some(animation_flag);
}
} else {
// Stop animation thread
if let Some(animation_flag) = self.animation_running.take() {
animation_flag.store(false, Ordering::Relaxed);
}
}
}
pub fn update_status_message(&mut self, message: String) {
self.status_message = Self::map_status_message(&message);
}
// Map technical status messages to user-friendly ones
fn map_status_message(technical_message: &str) -> String {
let lower = technical_message.to_lowercase();
// Thinking/reasoning patterns
if lower.contains("reasoning") || lower.contains("thinking") || lower.contains("planning")
|| lower.contains("waiting for model") || lower.contains("model") {
"Thinking".to_string()
}
// Tool/command execution patterns
else if lower.contains("tool") || lower.contains("command") || lower.contains("running command")
|| lower.contains("executing") || lower.contains("bash") || lower.contains("shell") {
"Using tools".to_string()
}
// Response generation patterns
else if lower.contains("generating") || lower.contains("responding") || lower.contains("streaming")
|| lower.contains("writing response") || lower.contains("assistant")
|| lower.contains("chat completions") || lower.contains("completion") {
"Responding".to_string()
}
// File/code editing patterns
else if lower.contains("editing") || lower.contains("writing") || lower.contains("modifying")
|| lower.contains("creating file") || lower.contains("updating") || lower.contains("patch") {
"Coding".to_string()
}
// Catch some common technical terms
else if lower.contains("processing") || lower.contains("analyzing") {
"Thinking".to_string()
}
else if lower.contains("reading") || lower.contains("searching") {
"Reading".to_string()
}
else {
// Default fallback - use "working" for unknown status
"Working".to_string()
}
}
pub fn desired_height(&self, width: u16) -> u16 { pub fn desired_height(&self, width: u16) -> u16 {
// Calculate hint/popup height // Calculate hint/popup height
let hint_height = match &self.active_popup { let hint_height = match &self.active_popup {
@@ -551,32 +632,43 @@ impl ChatComposer {
(InputResult::None, false) (InputResult::None, false)
} }
// ------------------------------------------------------------- // -------------------------------------------------------------
// History navigation (Up / Down) – only when the composer is not // Up/Down key handling - check modifiers to determine action
// empty or when the cursor is at the correct position, to avoid
// interfering with normal cursor movement.
// ------------------------------------------------------------- // -------------------------------------------------------------
KeyEvent { KeyEvent {
code: KeyCode::Up | KeyCode::Down, code: KeyCode::Up | KeyCode::Down,
modifiers,
.. ..
} => { } => {
if self // Check if Shift is held for history navigation
.history if modifiers.contains(KeyModifiers::SHIFT) {
.should_handle_navigation(self.textarea.text(), self.textarea.cursor()) // History navigation with Shift+Up/Down
{ if self
let replace_text = match key_event.code { .history
KeyCode::Up => self .should_handle_navigation(self.textarea.text(), self.textarea.cursor())
.history {
.navigate_up(self.textarea.text(), &self.app_event_tx), let replace_text = match key_event.code {
KeyCode::Down => self.history.navigate_down(&self.app_event_tx), KeyCode::Up => self
_ => unreachable!(), .history
}; .navigate_up(self.textarea.text(), &self.app_event_tx),
if let Some(text) = replace_text { KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
self.textarea.set_text(&text); _ => None,
self.textarea.set_cursor(0); };
return (InputResult::None, true); if let Some(text) = replace_text {
self.textarea.set_text(&text);
self.textarea.set_cursor(0);
return (InputResult::None, true);
}
}
// If history navigation didn't happen, just ignore the key
(InputResult::None, false)
} else {
// No Shift modifier - trigger chat scrolling
match key_event.code {
KeyCode::Up => (InputResult::ScrollUp, false),
KeyCode::Down => (InputResult::ScrollDown, false),
_ => (InputResult::None, false),
} }
} }
self.handle_input_basic(key_event)
} }
KeyEvent { KeyEvent {
code: KeyCode::Enter, code: KeyCode::Enter,
@@ -835,11 +927,63 @@ impl WidgetRef for &ChatComposer {
.render_ref(bottom_line_rect, buf); .render_ref(bottom_line_rect, buf);
} }
} }
// Draw border around input area (no title) - use lighter border color // Draw border around input area with optional "Coding" title when task is running
let input_block = Block::default() let mut input_block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border())); .border_style(Style::default().fg(crate::colors::border()));
if self.is_task_running {
// Randomly select a spinner style
use ratatui::text::{Line, Span};
use ratatui::style::Style;
// Use a hash of the task start time to pick a consistent spinner for this task
// (so it doesn't change every frame, but does change between tasks)
let spinner_seed = if let Some(ref animation_flag) = self.animation_running {
// Use the animation flag's address as a seed for consistency during a task
animation_flag.as_ref() as *const _ as usize
} else {
// Fallback to current time for initial frame
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as usize
};
// Weighted random spinner selection:
// - 1% chance for sparkle ✨
// - 49.5% chance for star spinner (✧✦✧)
// - 49.5% chance for one of the diamond spinners (✶, ◇, ◆)
let selected_spinner = if spinner_seed % 100 == 0 {
// Very rare sparkle (1% chance)
&['✨'][..]
} else if spinner_seed % 2 == 0 {
// Star spinner (49.5% chance)
&['✧', '✦', '✧'][..]
} else {
// Diamond spinner (49.5% chance) - randomly pick one
match spinner_seed % 3 {
0 => &['✶'][..],
1 => &['◇'][..],
_ => &['◆'][..],
}
};
let frame_idx = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() / 150) as usize; // 150ms per frame for smooth animation
let spinner = selected_spinner[frame_idx % selected_spinner.len()];
// Create centered title with spinner and spaces
let title_line = Line::from(vec![
Span::raw(" "), // Space before spinner
Span::styled(spinner.to_string(), Style::default().fg(crate::colors::secondary())),
Span::styled(format!(" {}... ", self.status_message), Style::default().fg(crate::colors::secondary())), // Space after spinner and after text
]).centered();
input_block = input_block.title(title_line);
}
let textarea_rect = input_block.inner(input_area); let textarea_rect = input_block.inner(input_area);
input_block.render_ref(input_area, buf); input_block.render_ref(input_area, buf);
@@ -1165,7 +1309,7 @@ mod tests {
// When a slash command is dispatched, the composer should not submit // When a slash command is dispatched, the composer should not submit
// literal text and should clear its textarea. // literal text and should clear its textarea.
match result { match result {
InputResult::None => {} InputResult::None | InputResult::ScrollUp | InputResult::ScrollDown => {}
InputResult::Submitted(text) => { InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}") panic!("expected command dispatch, but composer submitted literal text: {text}")
} }
@@ -1203,7 +1347,7 @@ mod tests {
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result { match result {
InputResult::None => {} InputResult::None | InputResult::ScrollUp | InputResult::ScrollDown => {}
InputResult::Submitted(text) => { InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}") panic!("expected command dispatch, but composer submitted literal text: {text}")
} }
+21 -62
View File
@@ -34,7 +34,6 @@ pub(crate) enum CancellationEvent {
pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::InputResult;
use crate::status_indicator_widget::StatusIndicatorWidget;
use approval_modal_view::ApprovalModalView; use approval_modal_view::ApprovalModalView;
use codex_core::config_types::{ReasoningEffort, ThemeName}; use codex_core::config_types::{ReasoningEffort, ThemeName};
use reasoning_selection_view::ReasoningSelectionView; use reasoning_selection_view::ReasoningSelectionView;
@@ -54,10 +53,6 @@ pub(crate) struct BottomPane<'a> {
is_task_running: bool, is_task_running: bool,
ctrl_c_quit_hint: bool, ctrl_c_quit_hint: bool,
/// Optional live, multi‑line status/"live cell" rendered directly above
/// the composer while a task is running. Unlike `active_view`, this does
/// not replace the composer; it augments it.
live_status: Option<StatusIndicatorWidget>,
/// Optional transient ring shown above the composer. This is a rendering-only /// Optional transient ring shown above the composer. This is a rendering-only
/// container used during development before we wire it to ChatWidget events. /// container used during development before we wire it to ChatWidget events.
@@ -89,7 +84,6 @@ impl BottomPane<'_> {
has_input_focus: params.has_input_focus, has_input_focus: params.has_input_focus,
is_task_running: false, is_task_running: false,
ctrl_c_quit_hint: false, ctrl_c_quit_hint: false,
live_status: None,
live_ring: None, live_ring: None,
status_view_active: false, status_view_active: false,
} }
@@ -100,11 +94,7 @@ impl BottomPane<'_> {
} }
pub fn desired_height(&self, width: u16) -> u16 { pub fn desired_height(&self, width: u16) -> u16 {
let overlay_status_h = self let overlay_status_h: u16 = 0;
.live_status
.as_ref()
.map(|s| s.desired_height(width))
.unwrap_or(0);
let ring_h = self let ring_h = self
.live_ring .live_ring
.as_ref() .as_ref()
@@ -120,7 +110,8 @@ impl BottomPane<'_> {
}; };
spacer + view.desired_height(width) spacer + view.desired_height(width)
} else { } else {
self.composer.desired_height(width) // Add 1 for the empty line above the composer
1 + self.composer.desired_height(width)
}; };
overlay_status_h overlay_status_h
@@ -135,13 +126,10 @@ impl BottomPane<'_> {
if self.active_view.is_some() { if self.active_view.is_some() {
None None
} else { } else {
// Calculate y_offset for status overlay if present // Account for the empty line above the composer
let mut y_offset = 0u16; let y_offset = 1u16;
if let Some(status) = &self.live_status {
y_offset = status.desired_height(area.width).min(area.height);
}
// Adjust composer area to account for status overlay and padding // Adjust composer area to account for empty line and padding
let horizontal_padding = 2u16; // Message input uses 2 chars padding let horizontal_padding = 2u16; // Message input uses 2 chars padding
let composer_rect = Rect { let composer_rect = Rect {
x: area.x + horizontal_padding, x: area.x + horizontal_padding,
@@ -216,28 +204,18 @@ impl BottomPane<'_> {
/// to allow continued input while processing. /// to allow continued input while processing.
pub(crate) fn update_status_text(&mut self, text: String) { pub(crate) fn update_status_text(&mut self, text: String) {
// If there's an active modal view that can handle status updates, let it // If there's an active modal view that can handle status updates, let it
let mut handled_by_view = false;
if let Some(view) = self.active_view.as_mut() { if let Some(view) = self.active_view.as_mut() {
if matches!( if matches!(
view.update_status_text(text.clone()), view.update_status_text(text.clone()),
bottom_pane_view::ConditionalUpdate::NeedsRedraw bottom_pane_view::ConditionalUpdate::NeedsRedraw
) { ) {
handled_by_view = true; self.request_redraw();
return;
} }
} }
// If not handled by a modal, show as overlay above composer // Pass status message to composer for dynamic title display
if !handled_by_view && self.active_view.is_none() { self.composer.update_status_message(text);
if self.live_status.is_none() {
self.live_status = Some(StatusIndicatorWidget::new(self.app_event_tx.clone()));
}
if let Some(status) = &mut self.live_status {
status.update_text(text);
}
} else if !handled_by_view {
// Ensure any previous overlay is cleared when a modal becomes active.
self.live_status = None;
}
self.request_redraw(); self.request_redraw();
} }
@@ -263,18 +241,13 @@ impl BottomPane<'_> {
pub fn set_task_running(&mut self, running: bool) { pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running; self.is_task_running = running;
self.composer.set_task_running(running);
if running { if running {
// Show status as overlay above composer instead of replacing it // No longer need separate status widget - title shows in composer
if self.live_status.is_none() {
self.live_status = Some(StatusIndicatorWidget::new(self.app_event_tx.clone()));
}
if let Some(status) = &mut self.live_status {
status.update_text("waiting for model".to_string());
}
self.request_redraw(); self.request_redraw();
} else { } else {
self.live_status = None; // Status now shown in composer title
// Drop the status view when a task completes, but keep other // Drop the status view when a task completes, but keep other
// modal views (e.g. approval dialogs). // modal views (e.g. approval dialogs).
if let Some(mut view) = self.active_view.take() { if let Some(mut view) = self.active_view.take() {
@@ -325,7 +298,7 @@ impl BottomPane<'_> {
let modal = ApprovalModalView::new(request, self.app_event_tx.clone()); let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
self.active_view = Some(Box::new(modal)); self.active_view = Some(Box::new(modal));
// Hide any overlay status while a modal is visible. // Hide any overlay status while a modal is visible.
self.live_status = None; // Status shown in composer title now
self.status_view_active = false; self.status_view_active = false;
self.request_redraw() self.request_redraw()
} }
@@ -334,7 +307,7 @@ impl BottomPane<'_> {
pub fn show_reasoning_selection(&mut self, current_effort: ReasoningEffort) { pub fn show_reasoning_selection(&mut self, current_effort: ReasoningEffort) {
let view = ReasoningSelectionView::new(current_effort, self.app_event_tx.clone()); let view = ReasoningSelectionView::new(current_effort, self.app_event_tx.clone());
self.active_view = Some(Box::new(view)); self.active_view = Some(Box::new(view));
self.live_status = None; // Status shown in composer title now
self.status_view_active = false; self.status_view_active = false;
self.request_redraw() self.request_redraw()
} }
@@ -343,7 +316,7 @@ impl BottomPane<'_> {
pub fn show_theme_selection(&mut self, current_theme: ThemeName) { pub fn show_theme_selection(&mut self, current_theme: ThemeName) {
let view = ThemeSelectionView::new(current_theme, self.app_event_tx.clone()); let view = ThemeSelectionView::new(current_theme, self.app_event_tx.clone());
self.active_view = Some(Box::new(view)); self.active_view = Some(Box::new(view));
self.live_status = None; // Status shown in composer title now
self.status_view_active = false; self.status_view_active = false;
self.request_redraw() self.request_redraw()
} }
@@ -391,7 +364,7 @@ impl BottomPane<'_> {
// Reset any transient state that might affect focus // Reset any transient state that might affect focus
// Clear any temporary status overlays that might interfere // Clear any temporary status overlays that might interfere
if !self.is_task_running { if !self.is_task_running {
self.live_status = None; // Status now shown in composer title
} }
// Ensure composer knows it has focus // Ensure composer knows it has focus
self.composer self.composer
@@ -423,24 +396,7 @@ impl WidgetRef for &BottomPane<'_> {
// Leave one empty line // Leave one empty line
y_offset = y_offset.saturating_add(1); y_offset = y_offset.saturating_add(1);
} }
if let Some(status) = &self.live_status { // Status is now shown in the composer title, no need for separate rendering
// Add horizontal padding (3 chars on each side) to match composer
let horizontal_padding = 3u16;
let padded_width = area.width.saturating_sub(horizontal_padding * 2);
let live_h = status
.desired_height(padded_width)
.min(area.height.saturating_sub(y_offset));
if live_h > 0 {
let live_rect = Rect {
x: area.x + horizontal_padding,
y: area.y + y_offset,
width: padded_width,
height: live_h,
};
status.render_ref(live_rect, buf);
y_offset = y_offset.saturating_add(live_h);
}
}
if let Some(view) = &self.active_view { if let Some(view) = &self.active_view {
if y_offset < area.height { if y_offset < area.height {
@@ -458,6 +414,9 @@ impl WidgetRef for &BottomPane<'_> {
view.render(view_rect, buf); view.render(view_rect, buf);
} }
} else if y_offset < area.height { } else if y_offset < area.height {
// Always add an empty line above the input box
y_offset = y_offset.saturating_add(1);
// Add horizontal padding (2 chars on each side) for Message input // Add horizontal padding (2 chars on each side) for Message input
let horizontal_padding = 2u16; let horizontal_padding = 2u16;
let composer_rect = Rect { let composer_rect = Rect {
+192 -51
View File
@@ -332,13 +332,58 @@ impl ChatWidget<'_> {
if self.stream_header_emitted { if self.stream_header_emitted {
return; return;
} }
let header = match kind { match kind {
StreamKind::Reasoning => RLine::from("thinking".magenta().italic()), StreamKind::Reasoning => {
StreamKind::Answer => RLine::from("codex".magenta().bold()), // Always add space above reasoning (unless history is completely empty)
}; if !self.history_cells.is_empty() {
self.app_event_tx self.history_cells.push(HistoryCell::new_text_line(RLine::from("")));
.send(AppEvent::InsertHistory(vec![header])); }
self.stream_header_emitted = true; self.stream_header_emitted = true;
self.request_redraw();
}
StreamKind::Answer => {
// Add space above GPT-5 header only if the last item isn't already a blank line
let needs_space = if self.history_cells.is_empty() {
false
} else {
// Check if the last item is a blank line (from any cell type that could be empty)
match self.history_cells.last() {
Some(HistoryCell::BackgroundEvent { view })
| Some(HistoryCell::StyledText { view })
| Some(HistoryCell::DimmedReasoning { view })
if view.lines.len() == 1 =>
{
// Check if it's an empty line
let line = &view.lines[0];
let is_empty = line.spans.is_empty() ||
(line.spans.len() == 1 && line.spans[0].content.is_empty());
!is_empty // Need space if the line is NOT empty
}
_ => true // Always need space for other cell types
}
};
if needs_space {
self.history_cells.push(HistoryCell::new_text_line(RLine::from("")));
}
// Add GPT-5 header with secondary color
let formatted_model = self.format_model_name(&self.config.model);
use ratatui::style::{Style, Modifier};
use ratatui::text::Span;
self.history_cells.push(HistoryCell::new_styled_text_line(
RLine::from(Span::styled(
formatted_model,
Style::default()
.fg(crate::colors::secondary())
.add_modifier(Modifier::BOLD)
))
));
self.stream_header_emitted = true;
self.request_redraw();
}
}
} }
fn finalize_active_stream(&mut self) { fn finalize_active_stream(&mut self) {
if let Some(kind) = self.current_stream { if let Some(kind) = self.current_stream {
@@ -446,6 +491,34 @@ impl ChatWidget<'_> {
} }
} }
/// Format model name with proper capitalization (e.g., "gpt-4" -> "GPT-4")
fn format_model_name(&self, model_name: &str) -> String {
if model_name.to_lowercase().starts_with("gpt-") {
format!("GPT{}", &model_name[3..])
} else {
model_name.to_string()
}
}
/// Calculate the maximum scroll offset based on current content size
fn calculate_max_scroll_offset(&self, content_area_height: u16) -> u16 {
let mut total_height = 0u16;
// Calculate total content height (same logic as render method)
for cell in &self.history_cells {
let h = cell.desired_height(80); // Use reasonable width for height calculation
total_height = total_height.saturating_add(h);
}
if let Some(ref cell) = self.active_history_cell {
let h = cell.desired_height(80);
total_height = total_height.saturating_add(h);
}
// Max scroll is content height minus available height
total_height.saturating_sub(content_area_height)
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Press { if key_event.kind == KeyEventKind::Press {
self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.clear_ctrl_c_quit_hint();
@@ -456,6 +529,24 @@ impl ChatWidget<'_> {
let user_message = self.parse_message_with_images(text); let user_message = self.parse_message_with_images(text);
self.submit_user_message(user_message); self.submit_user_message(user_message);
} }
InputResult::ScrollUp => {
// Scroll up in chat history (increase offset, towards older content)
// Estimate max scroll based on content - use reasonable content area height of 30
let estimated_max_scroll = self.calculate_max_scroll_offset(30);
let new_offset = self.scroll_offset.saturating_add(3).min(estimated_max_scroll);
self.scroll_offset = new_offset;
self.app_event_tx.send(AppEvent::RequestRedraw);
}
InputResult::ScrollDown => {
// Scroll down in chat history (decrease offset, towards bottom)
if self.scroll_offset >= 3 {
self.scroll_offset = self.scroll_offset.saturating_sub(3);
self.app_event_tx.send(AppEvent::RequestRedraw);
} else if self.scroll_offset > 0 {
self.scroll_offset = 0;
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
InputResult::None => {} InputResult::None => {}
} }
} }
@@ -566,8 +657,13 @@ impl ChatWidget<'_> {
// Store in memory instead of sending to scrollback // Store in memory instead of sending to scrollback
self.history_cells.push(cell); self.history_cells.push(cell);
// Auto-scroll to bottom when new content arrives // Auto-scroll to bottom when new content arrives, but only if we're already at the bottom
self.scroll_offset = 0; // This preserves the user's scroll position if they've manually scrolled up
if self.scroll_offset == 0 {
// Already at bottom, stay there
self.scroll_offset = 0;
}
// If user has scrolled up (scroll_offset > 0), don't change their position
// Check if there's actual conversation history (any user prompts submitted) // Check if there's actual conversation history (any user prompts submitted)
let has_conversation = self let has_conversation = self
.history_cells .history_cells
@@ -840,8 +936,10 @@ impl ChatWidget<'_> {
match mouse_event.kind { match mouse_event.kind {
MouseEventKind::ScrollUp => { MouseEventKind::ScrollUp => {
// For now, just scroll up without bounds checking - the render logic will clamp it // Scroll up with proper bounds checking
self.scroll_offset = self.scroll_offset.saturating_add(3); let estimated_max_scroll = self.calculate_max_scroll_offset(30);
let new_offset = self.scroll_offset.saturating_add(3).min(estimated_max_scroll);
self.scroll_offset = new_offset;
self.app_event_tx.send(AppEvent::RequestRedraw); self.app_event_tx.send(AppEvent::RequestRedraw);
} }
MouseEventKind::ScrollDown => { MouseEventKind::ScrollDown => {
@@ -893,6 +991,10 @@ impl ChatWidget<'_> {
} }
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
self.begin_stream(StreamKind::Answer); self.begin_stream(StreamKind::Answer);
// Update status to show we're generating a response
if self.bottom_pane.is_task_running() {
self.bottom_pane.update_status_text("responding".to_string());
}
self.answer_buffer.push_str(&delta); self.answer_buffer.push_str(&delta);
self.stream_push_and_maybe_commit(&delta); self.stream_push_and_maybe_commit(&delta);
self.request_redraw(); self.request_redraw();
@@ -901,6 +1003,10 @@ impl ChatWidget<'_> {
// Stream CoT into the live pane; keep input visible and commit // Stream CoT into the live pane; keep input visible and commit
// overflow rows incrementally to scrollback. // overflow rows incrementally to scrollback.
self.begin_stream(StreamKind::Reasoning); self.begin_stream(StreamKind::Reasoning);
// Update status to show we're thinking/reasoning
if self.bottom_pane.is_task_running() {
self.bottom_pane.update_status_text("reasoning".to_string());
}
self.reasoning_buffer.push_str(&delta); self.reasoning_buffer.push_str(&delta);
self.stream_push_and_maybe_commit(&delta); self.stream_push_and_maybe_commit(&delta);
self.request_redraw(); self.request_redraw();
@@ -919,6 +1025,10 @@ impl ChatWidget<'_> {
}) => { }) => {
// Treat raw reasoning content the same as summarized reasoning for UI flow. // Treat raw reasoning content the same as summarized reasoning for UI flow.
self.begin_stream(StreamKind::Reasoning); self.begin_stream(StreamKind::Reasoning);
// Update status to show we're thinking/reasoning
if self.bottom_pane.is_task_running() {
self.bottom_pane.update_status_text("reasoning".to_string());
}
self.reasoning_buffer.push_str(&delta); self.reasoning_buffer.push_str(&delta);
self.stream_push_and_maybe_commit(&delta); self.stream_push_and_maybe_commit(&delta);
self.request_redraw(); self.request_redraw();
@@ -945,6 +1055,9 @@ impl ChatWidget<'_> {
}) => { }) => {
self.bottom_pane.set_task_running(false); self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring(); self.bottom_pane.clear_live_ring();
// Reset stream state for next conversation
self.current_stream = None;
self.stream_header_emitted = false;
self.request_redraw(); self.request_redraw();
} }
EventMsg::TokenCount(token_usage) => { EventMsg::TokenCount(token_usage) => {
@@ -1367,6 +1480,14 @@ impl ChatWidget<'_> {
} }
} }
pub(crate) fn insert_history_lines(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
// Insert lines directly into history as text line cells
for line in lines {
self.history_cells.push(HistoryCell::new_text_line(line));
}
self.request_redraw();
}
pub(crate) fn handle_browser_command(&mut self, command_text: String) { pub(crate) fn handle_browser_command(&mut self, command_text: String) {
// Parse the browser subcommand // Parse the browser subcommand
let parts: Vec<&str> = command_text.trim().split_whitespace().collect(); let parts: Vec<&str> = command_text.trim().split_whitespace().collect();
@@ -1777,20 +1898,26 @@ impl ChatWidget<'_> {
// Build status line spans // Build status line spans
let mut status_spans = vec![ let mut status_spans = vec![
Span::styled("Coder", Style::default().add_modifier(Modifier::BOLD)), Span::styled("Coder", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" • "), Span::raw( " • "),
Span::styled("Model: ", Style::default().dim()), Span::styled("Model: ", Style::default().dim()),
Span::styled( Span::styled(
format!("{}", self.config.model), self.format_model_name(&self.config.model),
Style::default().fg(crate::colors::secondary()),
),
Span::raw(" • "),
Span::styled("Reasoning: ", Style::default().dim()),
Span::styled(
format!("{}", self.config.model_reasoning_effort),
Style::default().fg(crate::colors::info()), Style::default().fg(crate::colors::info()),
), ),
Span::raw(" • "), Span::raw(" • "),
Span::styled("Directory: ", Style::default().dim()), Span::styled("Directory: ", Style::default().dim()),
Span::styled(cwd_str, Style::default().fg(crate::colors::info())), Span::styled(cwd_str, Style::default().fg(crate::colors::info())),
]; ];
// Add git branch if available // Add git branch if available
if let Some(branch) = self.get_git_branch() { if let Some(branch) = self.get_git_branch() {
status_spans.push(Span::raw(" • ")); status_spans.push(Span::raw(" • "));
status_spans.push(Span::styled("Branch: ", Style::default().dim())); status_spans.push(Span::styled("Branch: ", Style::default().dim()));
status_spans.push(Span::styled( status_spans.push(Span::styled(
branch, branch,
@@ -1812,8 +1939,9 @@ impl ChatWidget<'_> {
// Render the block first // Render the block first
status_block.render(padded_area, buf); status_block.render(padded_area, buf);
// Then render the text inside with padding // Then render the text inside with padding, centered
let status_widget = Paragraph::new(vec![status_line]); let status_widget = Paragraph::new(vec![status_line])
.alignment(ratatui::layout::Alignment::Center);
ratatui::widgets::Widget::render(status_widget, padded_inner, buf); ratatui::widgets::Widget::render(status_widget, padded_inner, buf);
} }
@@ -2247,9 +2375,6 @@ impl ChatWidget<'_> {
self.stream_header_emitted = false; self.stream_header_emitted = false;
// Clear any previous live content; we're starting a new stream. // Clear any previous live content; we're starting a new stream.
self.live_builder = RowBuilder::new(self.live_builder.width()); self.live_builder = RowBuilder::new(self.live_builder.width());
// Ensure the waiting status is visible (composer replaced).
self.bottom_pane
.update_status_text("waiting for model".to_string());
self.emit_stream_header(kind); self.emit_stream_header(kind);
} }
} }
@@ -2263,24 +2388,26 @@ impl ChatWidget<'_> {
.drain_commit_ready(self.live_max_rows as usize); .drain_commit_ready(self.live_max_rows as usize);
if !drained.is_empty() { if !drained.is_empty() {
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new(); let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
if !self.stream_header_emitted {
match self.current_stream {
Some(StreamKind::Reasoning) => {
lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
}
Some(StreamKind::Answer) => {
lines.push(ratatui::text::Line::from("codex".magenta().bold()));
}
None => {}
}
self.stream_header_emitted = true;
}
for r in drained { for r in drained {
lines.push(ratatui::text::Line::from(r.text)); let line = match self.current_stream {
Some(StreamKind::Reasoning) => {
// Use dimmer color for reasoning text
use ratatui::text::Span;
use ratatui::style::Style;
ratatui::text::Line::from(Span::styled(r.text, Style::default().fg(crate::colors::text_dim())))
}
_ => ratatui::text::Line::from(r.text)
};
lines.push(line);
} }
// Add to in-memory history instead of scrollback // Add to in-memory history instead of scrollback
for line in lines { for line in lines {
self.history_cells.push(HistoryCell::new_text_line(line)); // Use dimmed reasoning for thinking to get both markdown and dimming
if matches!(self.current_stream, Some(StreamKind::Reasoning)) {
self.history_cells.push(HistoryCell::new_dimmed_reasoning_line(line));
} else {
self.history_cells.push(HistoryCell::new_text_line(line));
}
} }
// Ensure input focus is maintained when adding streamed content // Ensure input focus is maintained when adding streamed content
self.bottom_pane.ensure_input_focus(); self.bottom_pane.ensure_input_focus();
@@ -2302,27 +2429,32 @@ impl ChatWidget<'_> {
// TODO: Re-add markdown rendering for assistant answers and reasoning. // TODO: Re-add markdown rendering for assistant answers and reasoning.
// When finalizing, pass the accumulated text through `markdown::append_markdown` // When finalizing, pass the accumulated text through `markdown::append_markdown`
// to build styled `Line<'static>` entries instead of raw plain text lines. // to build styled `Line<'static>` entries instead of raw plain text lines.
if !remaining.is_empty() || !self.stream_header_emitted { if !remaining.is_empty() {
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new(); let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
if !self.stream_header_emitted {
match kind {
StreamKind::Reasoning => {
lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
}
StreamKind::Answer => {
lines.push(ratatui::text::Line::from("codex".magenta().bold()));
}
}
self.stream_header_emitted = true;
}
for r in remaining { for r in remaining {
lines.push(ratatui::text::Line::from(r.text)); let line = match kind {
StreamKind::Reasoning => {
// Use dimmer color for reasoning text
use ratatui::text::Span;
use ratatui::style::Style;
ratatui::text::Line::from(Span::styled(r.text, Style::default().fg(crate::colors::text_dim())))
}
_ => ratatui::text::Line::from(r.text)
};
lines.push(line);
}
// Close the block with a blank line for readability (except for reasoning which already has spacing)
if kind != StreamKind::Reasoning {
lines.push(ratatui::text::Line::from(""));
} }
// Close the block with a blank line for readability.
lines.push(ratatui::text::Line::from(""));
// Add to in-memory history instead of scrollback // Add to in-memory history instead of scrollback
for line in lines { for line in lines {
self.history_cells.push(HistoryCell::new_text_line(line)); // Use dimmed reasoning for thinking to get both markdown and dimming
if matches!(self.current_stream, Some(StreamKind::Reasoning)) {
self.history_cells.push(HistoryCell::new_dimmed_reasoning_line(line));
} else {
self.history_cells.push(HistoryCell::new_text_line(line));
}
} }
// Ensure input focus is maintained when adding streamed content // Ensure input focus is maintained when adding streamed content
self.bottom_pane.ensure_input_focus(); self.bottom_pane.ensure_input_focus();
@@ -2402,7 +2534,16 @@ impl WidgetRef for &ChatWidget<'_> {
.live_builder .live_builder
.display_rows() .display_rows()
.into_iter() .into_iter()
.map(|r| ratatui::text::Line::from(r.text)) .map(|r| {
if matches!(self.current_stream, Some(StreamKind::Reasoning)) {
// Apply dimming to live reasoning content
use ratatui::text::Span;
use ratatui::style::Style;
ratatui::text::Line::from(Span::styled(r.text, Style::default().fg(crate::colors::text_dim())))
} else {
ratatui::text::Line::from(r.text)
}
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let streaming_cell = if !streaming_lines.is_empty() { let streaming_cell = if !streaming_lines.is_empty() {
+4 -4
View File
@@ -36,6 +36,10 @@ pub(crate) fn border() -> Color {
current_theme().border current_theme().border
} }
pub(crate) fn border_focused() -> Color {
current_theme().border_focused
}
pub(crate) fn text() -> Color { pub(crate) fn text() -> Color {
current_theme().text current_theme().text
} }
@@ -52,10 +56,6 @@ pub(crate) fn info() -> Color {
current_theme().info current_theme().info
} }
pub(crate) fn spinner() -> Color {
current_theme().spinner
}
// Alias for text_dim // Alias for text_dim
pub(crate) fn dim() -> Color { pub(crate) fn dim() -> Color {
text_dim() text_dim()
+102 -9
View File
@@ -112,6 +112,12 @@ pub(crate) enum HistoryCell {
/// Background event. /// Background event.
BackgroundEvent { view: TextBlock }, BackgroundEvent { view: TextBlock },
/// Styled text that bypasses markdown processing to preserve styling
StyledText { view: TextBlock },
/// Dimmed reasoning text with markdown support
DimmedReasoning { view: TextBlock },
/// Output from the `/diff` command. /// Output from the `/diff` command.
GitDiffOutput { view: TextBlock }, GitDiffOutput { view: TextBlock },
@@ -272,6 +278,8 @@ impl HistoryCell {
HistoryCell::WelcomeMessage { view } HistoryCell::WelcomeMessage { view }
| HistoryCell::UserPrompt { view } | HistoryCell::UserPrompt { view }
| HistoryCell::BackgroundEvent { view } | HistoryCell::BackgroundEvent { view }
| HistoryCell::StyledText { view }
| HistoryCell::DimmedReasoning { view }
| HistoryCell::GitDiffOutput { view } | HistoryCell::GitDiffOutput { view }
| HistoryCell::ReasoningOutput { view } | HistoryCell::ReasoningOutput { view }
| HistoryCell::StatusOutput { view } | HistoryCell::StatusOutput { view }
@@ -313,6 +321,15 @@ impl HistoryCell {
let processed_lines = self.get_processed_lines(width); let processed_lines = self.get_processed_lines(width);
processed_lines.len() as u16 processed_lines.len() as u16
} }
HistoryCell::StyledText { view } => {
// For styled text, use the lines as-is without markdown processing
view.lines.len() as u16
}
HistoryCell::DimmedReasoning { view: _ } => {
// For dimmed reasoning, use dimmed markdown processing
let processed_lines = self.get_processed_lines(width);
processed_lines.len() as u16
}
_ => Paragraph::new(Text::from(self.plain_lines())) _ => Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.line_count(width) .line_count(width)
@@ -370,14 +387,11 @@ impl HistoryCell {
pub(crate) fn new_user_prompt(message: String) -> Self { pub(crate) fn new_user_prompt(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled( // Style message text with primary color
"user", lines.extend(message.lines().map(|l| Line::from(Span::styled(
Style::default() l.to_string(),
.fg(crate::colors::primary()) Style::default().fg(crate::colors::primary())
.add_modifier(Modifier::BOLD), ))));
)));
lines.extend(message.lines().map(|l| Line::from(l.to_string())));
lines.push(Line::from(""));
HistoryCell::UserPrompt { HistoryCell::UserPrompt {
view: TextBlock::new(lines), view: TextBlock::new(lines),
@@ -744,9 +758,15 @@ impl HistoryCell {
// 🧠 Model // 🧠 Model
lines.push(Line::from(vec!["🧠 ".into(), "Model".bold()])); lines.push(Line::from(vec!["🧠 ".into(), "Model".bold()]));
// Format model name with proper capitalization
let formatted_model = if config.model.to_lowercase().starts_with("gpt-") {
format!("GPT{}", &config.model[3..])
} else {
config.model.clone()
};
lines.push(Line::from(vec![ lines.push(Line::from(vec![
" • Name: ".into(), " • Name: ".into(),
config.model.clone().into(), formatted_model.into(),
])); ]));
let provider_disp = pretty_provider_name(&config.model_provider_id); let provider_disp = pretty_provider_name(&config.model_provider_id);
lines.push(Line::from(vec![ lines.push(Line::from(vec![
@@ -1001,6 +1021,20 @@ impl HistoryCell {
} }
} }
/// Create a text line that preserves styling and bypasses markdown processing
pub(crate) fn new_styled_text_line(line: Line<'static>) -> Self {
HistoryCell::StyledText {
view: TextBlock::new(vec![line]),
}
}
/// Create a text line for dimmed reasoning content with markdown support
pub(crate) fn new_dimmed_reasoning_line(line: Line<'static>) -> Self {
HistoryCell::DimmedReasoning {
view: TextBlock::new(vec![line]),
}
}
/// Create a streaming content cell for live model output /// Create a streaming content cell for live model output
pub(crate) fn new_streaming_content(lines: Vec<Line<'static>>) -> Self { pub(crate) fn new_streaming_content(lines: Vec<Line<'static>>) -> Self {
HistoryCell::BackgroundEvent { HistoryCell::BackgroundEvent {
@@ -1028,6 +1062,27 @@ impl HistoryCell {
// Process with markdown support and word wrapping // Process with markdown support and word wrapping
crate::text_processing::process_markdown_text(&text, width) crate::text_processing::process_markdown_text(&text, width)
} }
HistoryCell::StyledText { view } => {
// For styled text, return lines as-is to preserve styling
view.lines.clone()
}
HistoryCell::DimmedReasoning { view } => {
// Convert TextBlock to text and apply dimmed markdown processing
let text = view
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
// Process with dimmed markdown support
crate::text_processing::process_dimmed_markdown_text(&text, width)
}
_ => self.plain_lines(), _ => self.plain_lines(),
} }
} }
@@ -1099,6 +1154,44 @@ impl WidgetRef for &HistoryCell {
) )
.render(area, buf); .render(area, buf);
} }
HistoryCell::StyledText { .. } => {
// Use processed lines as-is to preserve styling
let processed_lines = self.get_processed_lines(area.width);
Paragraph::new(Text::from(processed_lines))
.render(area, buf);
}
HistoryCell::DimmedReasoning { .. } => {
// Use processed lines with dimmed markdown support
let processed_lines = self.get_processed_lines(area.width);
Paragraph::new(Text::from(processed_lines))
.render(area, buf);
}
HistoryCell::UserPrompt { view } => {
// Special rendering for user prompts with left border
// Draw left border in active border color
for y in area.top()..area.bottom() {
if area.left() < area.right() {
buf[(area.left(), y)].set_char('│')
.set_fg(crate::colors::border_focused());
}
}
// Render text with 2-char left margin for the border
let text_area = Rect {
x: area.x + 2,
y: area.y,
width: area.width.saturating_sub(2),
height: area.height,
};
Paragraph::new(Text::from(view.lines.iter().map(line_to_static).collect::<Vec<_>>()))
.wrap(Wrap { trim: false })
.style(
Style::default()
.bg(crate::colors::background()),
)
.render(text_area, buf);
}
_ => { _ => {
// Apply theme background and text color to the paragraph // Apply theme background and text color to the paragraph
Paragraph::new(Text::from(self.plain_lines())) Paragraph::new(Text::from(self.plain_lines()))
-1
View File
@@ -44,7 +44,6 @@ mod markdown;
pub mod onboarding; pub mod onboarding;
mod shimmer; mod shimmer;
mod slash_command; mod slash_command;
mod status_indicator_widget;
mod terminal_info; mod terminal_info;
mod text_block; mod text_block;
mod text_formatting; mod text_formatting;
-383
View File
@@ -1,383 +0,0 @@
//! A live status indicator that shows the *latest* log line emitted by the
//! application while the agent is processing a long‑running task.
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use crate::colors;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
// We render the live text using markdown so it visually matches the history
// cells. Before rendering we strip any ANSI escape sequences to avoid writing
// raw control bytes into the back buffer.
use codex_ansi_escape::ansi_escape_line;
pub(crate) struct StatusIndicatorWidget {
/// Latest text to display (truncated to the available width at render
/// time).
text: String,
/// Animation state: reveal target `text` progressively like a typewriter.
/// We compute the currently visible prefix length based on the current
/// frame index and a constant typing speed. The `base_frame` and
/// `reveal_len_at_base` form the anchor from which we advance.
last_target_len: usize,
base_frame: usize,
reveal_len_at_base: usize,
frame_idx: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
start_time: Instant,
// Keep one sender alive to prevent the channel from closing while the
// animation thread is still running. The field itself is currently not
// accessed anywhere, therefore the leading underscore silences the
// `dead_code` warning without affecting behavior.
_app_event_tx: AppEventSender,
}
impl StatusIndicatorWidget {
/// Create a new status indicator and start the animation timer.
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
let frame_idx = Arc::new(AtomicUsize::new(0));
let running = Arc::new(AtomicBool::new(true));
// Animation thread.
{
let frame_idx_clone = Arc::clone(&frame_idx);
let running_clone = Arc::clone(&running);
let app_event_tx_clone = app_event_tx.clone();
thread::spawn(move || {
let mut counter = 0usize;
while running_clone.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(100));
counter = counter.wrapping_add(1);
frame_idx_clone.store(counter, Ordering::Relaxed);
app_event_tx_clone.send(AppEvent::RequestRedraw);
}
});
}
Self {
text: String::from("waiting for model"),
last_target_len: 0,
base_frame: 0,
reveal_len_at_base: 0,
frame_idx,
running,
start_time: Instant::now(),
_app_event_tx: app_event_tx,
}
}
pub fn desired_height(&self, _width: u16) -> u16 {
3 // 1 line for status + 1 line padding above + 1 line padding below
}
/// Update the line that is displayed in the widget.
pub(crate) fn update_text(&mut self, text: String) {
// If the text hasn't changed, don't reset the baseline; let the
// animation continue advancing naturally.
if text == self.text {
return;
}
// Update the target text, preserving newlines so wrapping matches history cells.
// Strip ANSI escapes for the character count so the typewriter animation speed is stable.
let stripped = {
let line = ansi_escape_line(&text);
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
};
let new_len = stripped.chars().count();
// Compute how many characters are currently revealed so we can carry
// this forward as the new baseline when target text changes.
let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
let shown_now = self.current_shown_len(current_frame);
self.text = text;
self.last_target_len = new_len;
self.base_frame = current_frame;
self.reveal_len_at_base = shown_now.min(new_len);
}
/// Reset the animation and start revealing `text` from the beginning.
#[cfg(test)]
pub(crate) fn restart_with_text(&mut self, text: String) {
let sanitized = text.replace(['\n', '\r'], " ");
let stripped = {
let line = ansi_escape_line(&sanitized);
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
};
let new_len = stripped.chars().count();
let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
self.text = sanitized;
self.last_target_len = new_len;
self.base_frame = current_frame;
// Start from zero revealed characters for a fresh typewriter cycle.
self.reveal_len_at_base = 0;
}
/// Calculate how many characters should currently be visible given the
/// animation baseline and frame counter.
fn current_shown_len(&self, current_frame: usize) -> usize {
// Increase typewriter speed (~5x): reveal more characters per frame.
const TYPING_CHARS_PER_FRAME: usize = 7;
let frames = current_frame.saturating_sub(self.base_frame);
let advanced = self
.reveal_len_at_base
.saturating_add(frames.saturating_mul(TYPING_CHARS_PER_FRAME));
advanced.min(self.last_target_len)
}
}
impl Drop for StatusIndicatorWidget {
fn drop(&mut self) {
use std::sync::atomic::Ordering;
self.running.store(false, Ordering::Relaxed);
}
}
impl WidgetRef for StatusIndicatorWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Ensure minimal height
if area.height == 0 || area.width == 0 {
return;
}
// Center the status line within the 3-line area (line 2 of 3)
// If we have less than 3 lines, render on the middle line if possible
let render_line = if area.height >= 3 {
1 // Render on line 2 (0-indexed) for padding above and below
} else if area.height == 2 {
0 // Render on first line if only 2 lines available
} else {
0 // Single line - render on it
};
let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
let elapsed = self.start_time.elapsed().as_secs();
let shown_now = self.current_shown_len(idx);
let status_prefix: String = self.text.chars().take(shown_now).collect();
let animated_text = "Coding";
let header_chars: Vec<char> = animated_text.chars().collect();
let padding = 4usize; // virtual padding around the animated segment for smoother loop
let period = header_chars.len() + padding * 2;
let pos = idx % period;
let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false);
let band_half_width = 2.0; // width of the bright band in characters
let mut animated_spans: Vec<Span<'static>> = Vec::new();
for (i, ch) in header_chars.iter().enumerate() {
let i_pos = i as isize + padding as isize;
let pos = pos as isize;
let dist = (i_pos - pos).abs() as f32;
let t = if dist <= band_half_width {
let x = std::f32::consts::PI * (dist / band_half_width);
0.5 * (1.0 + x.cos())
} else {
0.0
};
let brightness = 0.4 + 0.6 * t;
let level = (brightness * 255.0).clamp(0.0, 255.0) as u8;
let style = if has_true_color {
Style::default()
.fg(Color::Rgb(level, level, level))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color_for_level(level))
};
animated_spans.push(Span::styled(ch.to_string(), style));
}
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let inner_width = area.width as usize;
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled("▌ ", Style::default().fg(colors::info())));
// Moon phase spinner animation
let spinner_frames = ['◯', '◔', '◑', '◕', '●', '◕', '◑', '◔'];
const SPINNER_SLOWDOWN: usize = 2;
let spinner_ch = spinner_frames[(idx / SPINNER_SLOWDOWN) % spinner_frames.len()];
spans.push(Span::styled(
spinner_ch.to_string(),
Style::default().fg(colors::spinner()),
));
spans.push(Span::raw(" "));
// Space after header
// Animated header after the left bar
spans.extend(animated_spans);
// Space between header and bracket block
spans.push(Span::raw(" "));
// Non-animated, dim bracket content, with keys bold
let bracket_prefix = format!("({elapsed}s • ");
spans.push(Span::styled(
bracket_prefix,
Style::default()
.fg(colors::text_dim())
.add_modifier(Modifier::DIM),
));
spans.push(Span::styled(
"Esc",
Style::default()
.fg(colors::text_dim())
.add_modifier(Modifier::DIM | Modifier::BOLD),
));
spans.push(Span::styled(
" to interrupt)",
Style::default()
.fg(colors::text_dim())
.add_modifier(Modifier::DIM),
));
// Add a space and then the log text (not animated by the gradient)
if !status_prefix.is_empty() {
spans.push(Span::styled(
" ",
Style::default()
.fg(colors::text_dim())
.add_modifier(Modifier::DIM),
));
spans.push(Span::styled(
status_prefix,
Style::default()
.fg(colors::text_dim())
.add_modifier(Modifier::DIM),
));
}
// Truncate spans to fit the width.
let mut acc: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
for s in spans {
let w = s.content.width();
if used + w <= inner_width {
acc.push(s);
used += w;
} else {
break;
}
}
let lines = vec![Line::from(acc)];
// No-op once full text is revealed; the app no longer reacts to a completion event.
// Render the paragraph on the calculated line for padding
let render_area = Rect {
x: area.x,
y: area.y + render_line,
width: area.width,
height: 1, // Always render just the one line
};
let paragraph = Paragraph::new(lines);
paragraph.render_ref(render_area, buf);
}
}
fn color_for_level(level: u8) -> Color {
if level < 128 {
colors::spinner()
} else if level < 192 {
colors::text_dim()
} else {
colors::text_bright()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use std::sync::mpsc::channel;
#[test]
fn renders_without_left_border_or_padding() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx);
w.restart_with_text("Hello".to_string());
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
// Allow a short delay so the typewriter reveals the first character.
std::thread::sleep(std::time::Duration::from_millis(120));
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
// Leftmost column has the left bar
let ch0 = buf[(0, 0)].symbol().chars().next().unwrap_or(' ');
assert_eq!(ch0, '▌', "expected left bar at col 0: {ch0:?}");
}
#[test]
fn working_header_is_present_on_last_line() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx);
w.restart_with_text("Hi".to_string());
// Ensure some frames elapse so we get a stable state.
std::thread::sleep(std::time::Duration::from_millis(120));
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
// Single line; it should contain the animated "Working" header.
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
}
assert!(row.contains("Coding"), "expected Coding header: {row:?}");
}
#[test]
fn spinner_is_rendered() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx);
w.restart_with_text("Hello".to_string());
std::thread::sleep(std::time::Duration::from_millis(120));
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
let ch = buf[(2, 0)].symbol().chars().next().unwrap_or(' ');
assert!(
matches!(ch, '◯' | '◔' | '◑' | '◕' | '●'),
"expected spinner char at col 2: {ch:?}"
);
}
}
+43 -2
View File
@@ -5,14 +5,55 @@ use ratatui::style::{Modifier, Style};
pub(crate) fn process_markdown_text(text: &str, _width: u16) -> Vec<Line<'static>> { pub(crate) fn process_markdown_text(text: &str, _width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
// Split by existing newlines to preserve intentional line breaks // Split by newlines but preserve empty lines (text.lines() skips empty lines!)
for line in text.lines() { for line in text.split('\n') {
lines.push(parse_markdown_line(line)); lines.push(parse_markdown_line(line));
} }
lines lines
} }
/// Process text with markdown support and apply dimming to all text
pub(crate) fn process_dimmed_markdown_text(text: &str, width: u16) -> Vec<Line<'static>> {
let lines = process_markdown_text(text, width);
// Remove empty lines that immediately follow bold titles (common in thinking content)
let mut filtered_lines = Vec::new();
let mut last_was_bold_title = false;
for line in &lines {
let is_empty = line.spans.is_empty() ||
(line.spans.len() == 1 && line.spans[0].content.is_empty());
// Check if this line has bold content (likely a title)
let has_bold = line.spans.iter().any(|span|
span.style.add_modifier.contains(ratatui::style::Modifier::BOLD)
);
// Skip empty lines that come right after bold titles
if is_empty && last_was_bold_title {
last_was_bold_title = false; // Reset flag
continue; // Skip this empty line
}
filtered_lines.push(line.clone());
last_was_bold_title = has_bold;
}
// Apply dimming to all spans in all remaining lines
filtered_lines.into_iter().map(|line| {
let dimmed_spans: Vec<_> = line.spans.into_iter().map(|span| {
// Apply dim color while preserving other styling (bold, etc.)
ratatui::text::Span::styled(
span.content,
span.style.fg(crate::colors::text_dim())
)
}).collect();
ratatui::text::Line::from(dimmed_spans)
}).collect()
}
/// Parse a single line with markdown formatting /// Parse a single line with markdown formatting
fn parse_markdown_line(text: &str) -> Line<'static> { fn parse_markdown_line(text: &str) -> Line<'static> {
let mut spans = Vec::new(); let mut spans = Vec::new();