chore(tui/reasoning): append live title snapshot to --order overlay; add debug_title_overlay() on CollapsibleReasoningCell

This commit is contained in:
James Peter
2025-09-04 08:36:14 +10:00
parent e937255f95
commit c25146b596
11 changed files with 325 additions and 99 deletions
+27 -6
View File
@@ -71,6 +71,8 @@ pub(crate) struct App<'a> {
/// True when a redraw has been scheduled but not yet executed.
pending_redraw: Arc<AtomicBool>,
/// Controls the input reader thread spawned at startup.
input_running: Arc<AtomicBool>,
// Transcript overlay state
_transcript_overlay: Option<TranscriptApp>,
@@ -137,10 +139,14 @@ impl App<'_> {
// Spawn a dedicated thread for reading the crossterm event loop and
// re-publishing the events as AppEvents, as appropriate.
// Create the input thread stop flag up front so we can store it on `Self`.
let input_running = Arc::new(AtomicBool::new(true));
{
let app_event_tx = app_event_tx.clone();
let input_running_thread = input_running.clone();
std::thread::spawn(move || {
loop {
if !input_running_thread.load(Ordering::Relaxed) { break; }
// This timeout is necessary to avoid holding the event lock
// that crossterm::event::read() acquires. In particular,
// reading the cursor position (crossterm::cursor::position())
@@ -148,7 +154,10 @@ impl App<'_> {
// can't acquire it within 2 sec. Resizing the terminal
// crashes the app if the cursor position can't be read.
// Keep the timeout small to minimize input-to-echo latency.
if let Ok(true) = crossterm::event::poll(Duration::from_millis(5)) {
// Poll slightly less aggressively to reduce wakeups while
// keeping input latency low. If no events are pending,
// sleep briefly to avoid a busy loop.
if let Ok(true) = crossterm::event::poll(Duration::from_millis(10)) {
if let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
@@ -182,8 +191,9 @@ impl App<'_> {
}
}
} else {
// Timeout expired, no `Event` is available; yield cooperatively
std::thread::yield_now();
// Timeout expired, no `Event` is available; sleep briefly
// instead of yielding in a tight loop to reduce CPU at idle.
std::thread::sleep(Duration::from_millis(5));
}
}
});
@@ -240,6 +250,7 @@ impl App<'_> {
config,
file_search,
pending_redraw,
input_running,
_transcript_overlay: None,
_deferred_history_lines: Vec::new(),
_transcript_saved_viewport: None,
@@ -575,7 +586,13 @@ impl App<'_> {
AppEvent::CodexEvent(event) => {
self.dispatch_codex_event(event);
}
AppEvent::ExitRequest => { break 'main; }
AppEvent::ExitRequest => {
// Stop background threads and break the UI loop.
self.commit_anim_running.store(false, Ordering::Release);
self.input_running.store(false, Ordering::Release);
break 'main;
}
// fallthrough handled by break
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
@@ -1038,10 +1055,14 @@ impl App<'_> {
}
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
match &self.app_state {
let usage = match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(),
}
};
// ensure background helpers stop before returning
self.commit_anim_running.store(false, Ordering::Release);
self.input_running.store(false, Ordering::Release);
usage
}
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
+33 -5
View File
@@ -1453,18 +1453,18 @@ impl WidgetRef for ChatComposer {
let mut spans: Vec<Span> = Vec::new();
if !self.ctrl_c_quit_hint {
if self.show_reasoning_hint && include_reasoning {
if !spans.is_empty() { spans.push(Span::from(" • ").style(Style::default())); }
if !spans.is_empty() { spans.push(Span::from(" • ").style(label_style)); }
spans.push(Span::from("Ctrl+R").style(key_hint_style));
let label = if self.reasoning_shown { " hide reasoning" } else { " show reasoning" };
spans.push(Span::from(label).style(label_style));
}
if self.show_diffs_hint && include_diff {
if !spans.is_empty() { spans.push(Span::from(" • ").style(Style::default())); }
if !spans.is_empty() { spans.push(Span::from(" • ").style(label_style)); }
spans.push(Span::from("Ctrl+D").style(key_hint_style));
spans.push(Span::from(" diff viewer").style(label_style));
}
// Always show quit at the end of the command hints
if !spans.is_empty() { spans.push(Span::from(" • ").style(Style::default())); }
if !spans.is_empty() { spans.push(Span::from(" • ").style(label_style)); }
spans.push(Span::from("Ctrl+C").style(key_hint_style));
spans.push(Span::from(" quit").style(label_style));
}
@@ -1508,7 +1508,7 @@ impl WidgetRef for ChatComposer {
// Compose final right spans: hints, optional separator, then tokens
if !hint_spans.is_empty() { right_spans.extend(hint_spans); }
if !right_spans.is_empty() && !token_spans.is_empty() {
right_spans.push(Span::from(" • ").style(Style::default()));
right_spans.push(Span::from(" • ").style(label_style));
}
right_spans.extend(token_spans);
@@ -1524,7 +1524,7 @@ impl WidgetRef for ChatComposer {
line_spans.push(Span::from(" "));
Line::from(line_spans)
.style(Style::default().dim())
.style(Style::default().fg(crate::colors::text_dim()))
.render_ref(bottom_line_rect, buf);
}
}
@@ -1611,6 +1611,34 @@ impl WidgetRef for ChatComposer {
.style(Style::default().dim())
.render_ref(padded_textarea_rect, buf);
}
// Draw a high-contrast cursor overlay under the terminal cursor using the theme's
// `cursor` color. This improves visibility on dark themes where the terminal's own
// cursor color may be hard to see or user-defined.
//
// Implementation notes:
// - We compute the visible cursor position using the same `state` (scroll) used to
// render the textarea so the overlay aligns with wrapped lines.
// - We paint the underlying cell with bg=theme.cursor and fg=theme.background.
// This provides contrast regardless of light/dark theme.
// - The hardware cursor is still positioned via `frame.set_cursor_position` at the
// app layer; this overlay ensures visibility independent of terminal settings.
drop(state); // release the borrow before computing position again
if let Some((cx, cy)) = self
.textarea
.cursor_pos_with_state(padded_textarea_rect, &self.textarea_state.borrow())
{
let overlay_style = Style::default()
.bg(crate::theme::current_theme().cursor)
.fg(crate::colors::background());
if cx < buf.area.width.saturating_add(buf.area.x)
&& cy < buf.area.height.saturating_add(buf.area.y)
{
let cell = &mut buf[(cx, cy)];
// Preserve the displayed character but apply the overlay colors
cell.set_style(overlay_style);
}
}
}
}
@@ -26,24 +26,67 @@ pub struct ResumeSelectionView {
subtitle: String,
rows: Vec<ResumeRow>,
selected: usize,
// Topmost row index currently visible in the table viewport
top: usize,
complete: bool,
app_event_tx: AppEventSender,
}
impl ResumeSelectionView {
pub fn new(title: String, subtitle: String, rows: Vec<ResumeRow>, app_event_tx: AppEventSender) -> Self {
Self { title, subtitle, rows, selected: 0, complete: false, app_event_tx }
Self { title, subtitle, rows, selected: 0, top: 0, complete: false, app_event_tx }
}
fn move_up(&mut self) {
if self.rows.is_empty() { return; }
if self.selected == 0 { self.selected = self.rows.len().saturating_sub(1); }
else { self.selected -= 1; }
self.ensure_selected_visible();
}
fn move_down(&mut self) {
if self.rows.is_empty() { return; }
self.selected = (self.selected + 1) % self.rows.len();
self.ensure_selected_visible();
}
fn page_up(&mut self) {
if self.rows.is_empty() { return; }
let page = self.visible_rows();
if self.selected >= page { self.selected -= page; } else { self.selected = 0; }
self.ensure_selected_visible();
}
fn page_down(&mut self) {
if self.rows.is_empty() { return; }
let page = self.visible_rows();
self.selected = (self.selected + page).min(self.rows.len().saturating_sub(1));
self.ensure_selected_visible();
}
fn go_home(&mut self) {
if self.rows.is_empty() { return; }
self.selected = 0;
self.ensure_selected_visible();
}
fn go_end(&mut self) {
if self.rows.is_empty() { return; }
self.selected = self.rows.len().saturating_sub(1);
self.ensure_selected_visible();
}
fn visible_rows(&self) -> usize {
self.rows.len().clamp(1, MAX_POPUP_ROWS)
}
fn ensure_selected_visible(&mut self) {
let page = self.visible_rows();
if self.selected < self.top {
self.top = self.selected;
} else if self.selected >= self.top.saturating_add(page) {
self.top = self.selected.saturating_sub(page.saturating_sub(1));
}
}
}
@@ -52,6 +95,10 @@ impl BottomPaneView<'_> for ResumeSelectionView {
match key_event.code {
KeyCode::Up => self.move_up(),
KeyCode::Down => self.move_down(),
KeyCode::PageUp => self.page_up(),
KeyCode::PageDown => self.page_down(),
KeyCode::Home => self.go_home(),
KeyCode::End => self.go_end(),
KeyCode::Enter => {
if let Some(row) = self.rows.get(self.selected) {
self.app_event_tx.send(AppEvent::ResumeFrom(row.path.clone()));
@@ -113,8 +160,12 @@ impl BottomPaneView<'_> for ResumeSelectionView {
.saturating_sub(footer_reserved + (next_y - inner.y)),
};
// Build rows
let rows_iter = self.rows.iter().enumerate().map(|(i, r)| {
// Build rows (windowed to the visible viewport)
let page = self.visible_rows();
let start = self.top.min(self.rows.len());
let end = (start + page).min(self.rows.len());
let rows_iter = self.rows[start..end].iter().enumerate().map(|(idx, r)| {
let i = start + idx; // absolute index
let cells = vec![
r.modified.clone(), r.created.clone(), r.msgs.clone(), r.branch.clone(), r.summary.clone()
].into_iter().map(|c| ratatui::widgets::Cell::from(c));
@@ -147,7 +198,7 @@ impl BottomPaneView<'_> for ResumeSelectionView {
// Draw a spacer line above footer (implicit by not drawing into that row)
let footer = Rect { x: inner.x.saturating_add(1), y: inner.y + inner.height - 1, width: inner.width.saturating_sub(1), height: 1 };
let footer_line = Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
Span::styled("↑↓ PgUp PgDn", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Select "),
+83 -40
View File
@@ -969,11 +969,15 @@ impl ChatWidget<'_> {
// Stop any active UI streams immediately so output ceases at once.
self.finalize_active_stream();
self.stream_state.drop_streaming = true;
// Surface an explicit notice in history so users see confirmation.
// We add a lightweight background event (not an error) to match prior UX.
self.history_push(crate::history_cell::new_background_event(
"Cancelled by user.".to_string(),
));
self.submit_op(Op::Interrupt);
// If nothing else is active, drop spinner; otherwise status already updated.
if self.active_task_ids.is_empty() {
self.bottom_pane.set_task_running(false);
}
// Immediately drop the running status so the next message can be typed/run,
// even if backend cleanup (and Error event) arrives slightly later.
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
// Reset with max width to disable wrapping
self.live_builder = RowBuilder::new(usize::MAX);
@@ -2351,6 +2355,11 @@ impl ChatWidget<'_> {
tools::web_search_begin(self, ev.call_id, ev.query)
},
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
// If the user requested an interrupt, ignore late final answers.
if self.stream_state.drop_streaming {
tracing::debug!("Ignoring AgentMessage after interrupt");
return;
}
self.turn_arrival_seq = self.turn_arrival_seq.saturating_add(1);
self.stream_state.seq_answer_final = Some(event.event_seq);
// Bind stream id to current turn and reserve an ordered key from OrderMeta if provided.
@@ -2421,6 +2430,11 @@ impl ChatWidget<'_> {
self.bottom_pane.update_status_text("responding".to_string());
}
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
// Ignore late reasoning if we've dropped streaming due to interrupt.
if self.stream_state.drop_streaming {
tracing::debug!("Ignoring AgentReasoning after interrupt");
return;
}
self.turn_arrival_seq = self.turn_arrival_seq.saturating_add(1);
tracing::debug!("AgentReasoning event with text: {:?}...", text.chars().take(100).collect::<String>());
// Guard: duplicate final Reasoning for the same id can arrive due to provider
@@ -2607,6 +2621,10 @@ impl ChatWidget<'_> {
self.handle_streaming_delta(StreamKind::Reasoning, id.clone(), delta);
}
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
if self.stream_state.drop_streaming {
tracing::debug!("Ignoring AgentReasoningRawContent after interrupt");
return;
}
tracing::debug!("AgentReasoningRawContent event with text: {:?}...", text.chars().take(100).collect::<String>());
if self.stream_state.closed_reasoning_ids.contains(&StreamId(id.clone())) {
tracing::warn!("Ignoring duplicate AgentReasoningRawContent for closed id={}", id);
@@ -3133,7 +3151,7 @@ impl ChatWidget<'_> {
// Gather active agents from current UI state
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("/agents".magenta()));
lines.push(Line::from("/agents").fg(crate::colors::keyword()));
lines.push(Line::from(""));
// Platform + environment summary to aid debugging
@@ -5818,11 +5836,20 @@ impl ChatWidget<'_> {
include_branch: bool,
include_dir: bool| {
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled("Code", Style::default().add_modifier(Modifier::BOLD)));
// Title follows theme text color
spans.push(Span::styled(
"Code",
Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD),
));
if include_model {
spans.push(Span::raw(" • "));
spans.push(Span::styled("Model: ", Style::default().dim()));
spans.push(Span::styled(" • ", Style::default().fg(crate::colors::text_dim())));
spans.push(Span::styled(
"Model: ",
Style::default().fg(crate::colors::text_dim()),
));
spans.push(Span::styled(
self.format_model_name(&self.config.model),
Style::default().fg(crate::colors::info()),
@@ -5830,8 +5857,11 @@ impl ChatWidget<'_> {
}
if include_reasoning {
spans.push(Span::raw(" • "));
spans.push(Span::styled("Reasoning: ", Style::default().dim()));
spans.push(Span::styled(" • ", Style::default().fg(crate::colors::text_dim())));
spans.push(Span::styled(
"Reasoning: ",
Style::default().fg(crate::colors::text_dim()),
));
spans.push(Span::styled(
format!("{}", self.config.model_reasoning_effort),
Style::default().fg(crate::colors::info()),
@@ -5839,15 +5869,21 @@ impl ChatWidget<'_> {
}
if include_dir {
spans.push(Span::raw(" • "));
spans.push(Span::styled("Directory: ", Style::default().dim()));
spans.push(Span::styled(" • ", Style::default().fg(crate::colors::text_dim())));
spans.push(Span::styled(
"Directory: ",
Style::default().fg(crate::colors::text_dim()),
));
spans.push(Span::styled(cwd_str.clone(), Style::default().fg(crate::colors::info())));
}
if include_branch {
if let Some(branch) = &branch_opt {
spans.push(Span::raw(" • "));
spans.push(Span::styled("Branch: ", Style::default().dim()));
spans.push(Span::styled(" • ", Style::default().fg(crate::colors::text_dim())));
spans.push(Span::styled(
"Branch: ",
Style::default().fg(crate::colors::text_dim()),
));
spans.push(Span::styled(
branch.clone(),
Style::default().fg(crate::colors::success_green()),
@@ -5857,11 +5893,11 @@ impl ChatWidget<'_> {
// Add reasoning visibility toggle hint only when reasoning is shown
if self.is_reasoning_shown() {
spans.push(Span::raw(" • "));
spans.push(Span::styled(" • ", Style::default().fg(crate::colors::text_dim())));
let reasoning_hint = "Ctrl+R hide reasoning";
spans.push(Span::styled(
reasoning_hint,
Style::default().dim(),
Style::default().fg(crate::colors::text_dim()),
));
}
@@ -5876,9 +5912,14 @@ impl ChatWidget<'_> {
let mut status_spans = build_spans(include_reasoning, include_model, include_branch, include_dir);
// Now recompute exact available width inside the border + padding before measuring
// Render a bordered status block and explicitly fill its background.
// Without a background fill, some terminals blend with prior frame
// contents, which is especially noticeable on dark themes as dark
// "caps" at the edges. Match the app background for consistency.
let status_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()));
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()));
let inner_area = status_block.inner(padded_area);
let padded_inner = inner_area.inner(Margin::new(1, 0));
let inner_width = padded_inner.width as usize;
@@ -5904,15 +5945,10 @@ impl ChatWidget<'_> {
status_spans = build_spans(include_reasoning, include_model, include_branch, include_dir);
}
// Add reasoning visibility toggle hint only when reasoning is shown
if self.is_reasoning_shown() {
status_spans.push(Span::raw(" • "));
let reasoning_hint = "Ctrl+R hide reasoning";
status_spans.push(Span::styled(
reasoning_hint,
Style::default().dim(),
));
}
// Note: The reasoning visibility hint is appended inside `build_spans`
// so it participates in width measurement and elision. Do not append
// it again here to avoid overflow that caused corrupted glyph boxes on
// some terminals.
let status_line = Line::from(status_spans);
@@ -5920,8 +5956,13 @@ impl ChatWidget<'_> {
status_block.render(padded_area, buf);
// Then render the text inside with padding, centered
let status_widget =
Paragraph::new(vec![status_line]).alignment(ratatui::layout::Alignment::Center);
let status_widget = Paragraph::new(vec![status_line])
.alignment(ratatui::layout::Alignment::Center)
.style(
Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text()),
);
ratatui::widgets::Widget::render(status_widget, padded_inner, buf);
}
@@ -6748,15 +6789,10 @@ impl ChatWidget<'_> {
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Fill entire area with theme background
let bg_style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
buf[(x, y)].set_style(bg_style);
}
}
// Avoid full‑frame background fill each frame. Individual widgets/cells
// paint their own backgrounds, and App clears on the first frame or when
// themes change. Removing this per‑cell loop significantly reduces per‑frame
// work during animations and while typing.
// Remember full frame height for HUD sizing logic
self.layout.last_frame_height.set(area.height);
@@ -6937,9 +6973,10 @@ impl WidgetRef for &ChatWidget<'_> {
if has_active_animation {
tracing::debug!("Active animation detected, scheduling next frame");
// Schedule next frame after debounce window to avoid dropped requests.
// Using 20ms ensures we're past the 16ms debounce and maintain smooth animation.
self.app_event_tx.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(20)));
// Lower animation cadence to reduce CPU while remaining smooth in terminals.
// ~50ms ≈ 20 FPS is typically sufficient.
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(50)));
}
// Calculate scroll position and vertical alignment
@@ -7211,6 +7248,12 @@ impl WidgetRef for &ChatWidget<'_> {
if self.show_order_overlay {
if let Some(Some(info)) = self.cell_order_dbg.get(idx) {
let mut text = format!("⟦{}⟧", info);
// Live reasoning diagnostics: append current title detection snapshot
if let Some(rc) = item.as_any().downcast_ref::<crate::history_cell::CollapsibleReasoningCell>() {
let snap = rc.debug_title_overlay();
text.push_str(" | ");
text.push_str(&snap);
}
let style = Style::default().fg(crate::colors::text_dim());
// Prefer below the item in the one-row spacing area
let below_y = item_area.y.saturating_add(visible_height);
+2 -2
View File
@@ -31,7 +31,7 @@ pub(super) fn finalize_exec_cell_at(
}
pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
let interrupted_msg = "Interrupted by user".to_string();
let interrupted_msg = "Cancelled by user.".to_string();
let stdout_empty = String::new();
let running: Vec<(super::ExecCallId, Option<usize>)> = chat
.exec
@@ -60,7 +60,7 @@ pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
None,
std::time::Duration::from_millis(0),
false,
"Interrupted by user".to_string(),
"Cancelled by user.".to_string(),
);
chat.history_replace_at(idx, Box::new(completed));
}
+4
View File
@@ -99,6 +99,10 @@ pub(crate) fn function() -> Color {
current_theme().function
}
pub(crate) fn keyword() -> Color {
current_theme().keyword
}
// Overlay/scrim helper: a dimmed background used behind modal overlays.
// We derive it from the current theme background so it looks consistent for
// both light and dark themes.
+44 -10
View File
@@ -2801,6 +2801,38 @@ impl CollapsibleReasoningCell {
}
}
/// Build a compact debug summary of detected section titles and line metrics
/// for on-screen overlays. Only used when the TUI `--order` overlay is active.
pub(crate) fn debug_title_overlay(&self) -> String {
use unicode_width::UnicodeWidthStr as _;
let lines = self.normalized_lines();
let mut title_idxs: Vec<usize> = Vec::new();
let mut title_previews: Vec<String> = Vec::new();
for (i, l) in lines.iter().enumerate() {
// Same rule as extract_section_titles: line is a heading if all spans are bold
// (ignoring spans that are purely whitespace when trimmed).
let is_title = !l.spans.is_empty()
&& l
.spans
.iter()
.all(|s| s.style.add_modifier.contains(Modifier::BOLD) || s.content.trim().is_empty());
if is_title {
title_idxs.push(i);
let mut text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
if text.len() > 60 { text.truncate(60); text.push_str("…"); }
title_previews.push(text);
}
}
let total = lines.len();
let titles = title_previews.len();
// Also record final line width to catch cut-offs
let lastw = lines.last().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>().width()).unwrap_or(0);
format!(
"rtitles={} idx={:?} total_lines={} lastw={} prevs={:?}",
titles, title_idxs, total, lastw, title_previews
)
}
pub fn toggle_collapsed(&self) {
self.collapsed.set(!self.collapsed.get());
}
@@ -4172,7 +4204,9 @@ pub(crate) fn new_session_info(
}
} else {
let lines = vec![
Line::from("model changed:".magenta().bold()),
Line::from("model changed:")
.fg(crate::colors::keyword())
.bold(),
Line::from(format!("requested: {}", config.model)),
Line::from(format!("used: {model}")),
// No empty line at end - trimming and spacing handled by renderer
@@ -5589,14 +5623,14 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
pub(crate) fn new_diff_output(diff_output: String) -> DiffCell {
// Parse the diff output into lines
let mut lines = vec![Line::from("/diff".magenta())];
let mut lines = vec![Line::from("/diff").fg(crate::colors::keyword())];
for line in diff_output.lines() {
if line.starts_with('+') && !line.starts_with("+++") {
lines.push(Line::from(line.to_string().green()));
lines.push(Line::from(line.to_string()).fg(crate::colors::success()));
} else if line.starts_with('-') && !line.starts_with("---") {
lines.push(Line::from(line.to_string().red()));
lines.push(Line::from(line.to_string()).fg(crate::colors::error()));
} else if line.starts_with("@@") {
lines.push(Line::from(line.to_string().cyan()));
lines.push(Line::from(line.to_string()).fg(crate::colors::info()));
} else {
lines.push(Line::from(line.to_string()));
}
@@ -5608,7 +5642,7 @@ pub(crate) fn new_diff_output(diff_output: String) -> DiffCell {
pub(crate) fn new_reasoning_output(reasoning_effort: &ReasoningEffort) -> PlainHistoryCell {
let lines = vec![
Line::from(""),
Line::from("Reasoning Effort".magenta().bold()),
Line::from("Reasoning Effort").fg(crate::colors::keyword()).bold(),
Line::from(format!("Value: {}", reasoning_effort)),
];
PlainHistoryCell {
@@ -5622,7 +5656,7 @@ pub(crate) fn new_reasoning_output(reasoning_effort: &ReasoningEffort) -> PlainH
pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("/status".magenta()));
lines.push(Line::from("/status").fg(crate::colors::keyword()));
lines.push(Line::from(""));
// 🔧 Configuration
@@ -5715,7 +5749,7 @@ pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> PlainHis
pub(crate) fn new_prompts_output() -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![
Line::from("/prompts".magenta()),
Line::from("/prompts").fg(crate::colors::keyword()),
Line::from(""),
Line::from(" 1. Explain this codebase"),
Line::from(" 2. Summarize recent commits"),
@@ -5860,7 +5894,7 @@ pub(crate) fn new_patch_event(
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = vec![
Line::from("❌ Patch application failed".red().bold()),
Line::from("❌ Patch application failed").fg(crate::colors::error()).bold(),
Line::from(""),
];
@@ -5868,7 +5902,7 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
let norm = expand_tabs_to_spaces(&norm, 4);
for line in norm.lines() {
if !line.is_empty() {
lines.push(ansi_escape_line(line).red());
lines.push(ansi_escape_line(line).fg(crate::colors::error()));
}
}
+23 -14
View File
@@ -7,7 +7,6 @@ use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
@@ -134,7 +133,10 @@ impl AuthModeWidget {
to_label(current),
to_label(self.preferred_auth_method)
);
lines.push(Line::from(msg).style(Style::default()));
lines.push(
Line::from(msg)
.style(Style::default().fg(crate::colors::text_dim())),
);
lines.push(Line::from(""));
}
}
@@ -149,20 +151,23 @@ impl AuthModeWidget {
let line1 = if is_selected {
Line::from(vec![
format!("{} {}. ", caret, idx + 1).cyan().dim(),
text.to_string().cyan(),
format!("{} {}. ", caret, idx + 1)
.fg(crate::colors::info())
.dim(),
text.to_string().fg(crate::colors::info()),
])
} else {
Line::from(format!(" {}. {text}", idx + 1))
.style(Style::default().fg(crate::colors::text()))
};
let line2 = if is_selected {
Line::from(format!(" {description}"))
.fg(Color::Cyan)
.fg(crate::colors::info())
.add_modifier(Modifier::DIM)
} else {
Line::from(format!(" {description}"))
.style(Style::default().add_modifier(Modifier::DIM))
.style(Style::default().fg(crate::colors::text_dim()))
};
vec![line1, line2]
@@ -197,13 +202,13 @@ impl AuthModeWidget {
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
// But leaving this for a future cleanup.
Line::from(" Press Enter to continue")
.style(Style::default().add_modifier(Modifier::DIM)),
.style(Style::default().fg(crate::colors::text_dim())),
);
if let Some(err) = &self.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
err.as_str(),
Style::default().fg(Color::Red),
Style::default().fg(crate::colors::error()),
)));
}
@@ -226,7 +231,10 @@ impl AuthModeWidget {
lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:"));
lines.push(Line::from(vec![
Span::raw(" "),
state.auth_url.as_str().cyan().underlined(),
state.auth_url
.as_str()
.fg(crate::colors::info())
.underlined(),
]));
lines.push(Line::from(""));
}
@@ -242,7 +250,8 @@ impl AuthModeWidget {
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green),
Line::from("✓ Signed in with your ChatGPT account")
.fg(crate::colors::success()),
Line::from(""),
Line::from("> Before you start:"),
Line::from(""),
@@ -270,7 +279,7 @@ impl AuthModeWidget {
])
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Press Enter to continue").fg(Color::Cyan),
Line::from(" Press Enter to continue").fg(crate::colors::info()),
];
Paragraph::new(lines)
@@ -279,7 +288,7 @@ impl AuthModeWidget {
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green)];
let lines = vec![Line::from("✓ Signed in with your ChatGPT account").fg(crate::colors::success())];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
@@ -287,7 +296,7 @@ impl AuthModeWidget {
}
fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![Line::from("✓ Using OPENAI_API_KEY").fg(Color::Green)];
let lines = vec![Line::from("✓ Using OPENAI_API_KEY").fg(crate::colors::success())];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
@@ -299,7 +308,7 @@ impl AuthModeWidget {
Line::from(
" To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment",
)
.style(Style::default().fg(Color::Cyan)),
.style(Style::default().fg(crate::colors::info())),
Line::from(""),
Line::from(" Press Enter to return")
.style(Style::default().add_modifier(Modifier::DIM)),
+1 -1
View File
@@ -56,7 +56,7 @@ const PAGER_KEY_HINTS: &[(&str, &str)] = &[
// Render a single line of key hints from (key, description) pairs.
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
let key_hint_style = Style::default().fg(Color::Cyan);
let key_hint_style = Style::default().fg(crate::colors::info());
let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true;
for (key, desc) in pairs {
+5 -1
View File
@@ -34,7 +34,11 @@ pub fn list_sessions_for_cwd(cwd: &Path, codex_home: &Path) -> Vec<ResumeCandida
// First: try per-directory index
if let Some(mut v) = read_dir_index(codex_home, cwd) {
v.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
return v.into_iter().take(200).collect();
if !v.is_empty() {
return v.into_iter().take(200).collect();
}
// If the index file exists but is empty or has only irrelevant rows,
// fall through to on-disk scan to avoid hiding recent sessions.
}
// Fallback: scan rollouts
+48 -16
View File
@@ -1,13 +1,14 @@
//! 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::cell::Cell;
use std::time::Duration;
use std::time::Instant;
use codex_core::protocol::Op;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::style::{Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
@@ -26,6 +27,8 @@ pub(crate) struct StatusIndicatorWidget {
queued_messages: Vec<String>,
start_time: Instant,
/// Last time we scheduled a follow-up frame; used to throttle redraws.
last_schedule: Cell<Instant>,
app_event_tx: AppEventSender,
// We schedule frames via AppEventSender; no direct frame requester.
}
@@ -37,6 +40,7 @@ impl StatusIndicatorWidget {
header: String::from("Working"),
queued_messages: Vec::new(),
start_time: Instant::now(),
last_schedule: Cell::new(Instant::now()),
app_event_tx,
}
@@ -85,8 +89,8 @@ impl StatusIndicatorWidget {
pub(crate) fn set_queued_messages(&mut self, queued: Vec<String>) {
self.queued_messages = queued;
// Ensure a redraw so changes are visible.
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(16)));
// Use the app's debounced redraw path; no need to arm a fast timer here.
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
@@ -96,19 +100,38 @@ impl WidgetRef for StatusIndicatorWidget {
return;
}
// Schedule next animation frame.
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(32)));
// Schedule next animation frame at a throttled cadence to reduce CPU.
// 100ms (~10 FPS) is sufficient for shimmer/time updates in terminals.
let now = Instant::now();
let last = self.last_schedule.get();
if now.duration_since(last) >= Duration::from_millis(100) {
self.last_schedule.set(now);
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(100)));
}
let elapsed = self.start_time.elapsed().as_secs();
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let mut spans = vec![" ".into()];
spans.extend(shimmer_spans(&self.header));
// Theme-aware base styles
let bg = crate::colors::background();
let text = crate::colors::text();
let text_dim = crate::colors::text_dim();
let accent = crate::colors::info();
// Build header spans using theme colors (no terminal-default cyan/dim)
let mut spans = vec![ratatui::text::Span::raw(" ")];
// Shimmer uses spans; recolor them with the accent so it tracks theme
let mut shimmer = shimmer_spans(&self.header)
.into_iter()
.map(|s| s.style(Style::default().fg(accent)))
.collect::<Vec<_>>();
spans.append(&mut shimmer);
spans.extend(vec![
" ".into(),
format!("({elapsed}s • ").dim(),
"Esc".dim().bold(),
" to interrupt)".dim(),
ratatui::text::Span::raw(" "),
// (12s • Esc to interrupt)
ratatui::text::Span::raw(format!("({elapsed}s • ")).style(Style::default().fg(text_dim)),
ratatui::text::Span::raw("Esc").style(Style::default().fg(accent).add_modifier(ratatui::style::Modifier::BOLD)),
ratatui::text::Span::raw(")").style(Style::default().fg(text_dim)),
]);
// Build lines: status, then queued messages, then spacer.
@@ -124,17 +147,26 @@ impl WidgetRef for StatusIndicatorWidget {
for (i, piece) in wrapped.iter().take(3).enumerate() {
let prefix = if i == 0 { " ↳ " } else { " " };
let content = format!("{prefix}{piece}");
lines.push(Line::from(content.dim().italic()));
lines.push(Line::from(content).style(Style::default().fg(text_dim).italic()));
}
if wrapped.len() > 3 {
lines.push(Line::from(" …".dim().italic()));
lines.push(Line::from(" …").style(Style::default().fg(text_dim).italic()));
}
}
if !self.queued_messages.is_empty() {
lines.push(Line::from(vec![" ".into(), "Alt+↑".cyan(), " edit".into()]).dim());
lines.push(
Line::from(vec![
ratatui::text::Span::raw(" "),
// Key hint in accent, label in dim text
ratatui::text::Span::raw("Alt+↑").style(Style::default().fg(accent)),
ratatui::text::Span::raw(" edit").style(Style::default().fg(text_dim)),
])
.style(Style::default()),
);
}
let paragraph = Paragraph::new(lines);
// Ensure background/foreground reflect theme
let paragraph = Paragraph::new(lines).style(Style::default().bg(bg).fg(text));
paragraph.render_ref(area, buf);
}
}