mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user