mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
chore(tui/reasoning): append live title snapshot to --order overlay; add debug_title_overlay() on CollapsibleReasoningCell
This commit is contained in:
+27
-6
@@ -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<()> {
|
||||
|
||||
@@ -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 "),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user