fix(tui/redraw): ensure scheduled animation frames are not suppressed by debounce

- Add  flag independent of
- Arm exactly one timer for AppEvent::ScheduleFrameIn while allowing immediate redraw debounce
- Prevents welcome logo animation from stalling until input

Also: revert intro color fade profile to original (no visual change request).
This commit is contained in:
James Peter
2025-09-05 08:21:47 +10:00
parent 7fb2815445
commit 7350fd1bd6
12 changed files with 715 additions and 418 deletions
+1
View File
@@ -813,6 +813,7 @@ dependencies = [
"codex-protocol-ts",
"codex-tui",
"regex",
"serde",
"serde_json",
"tokio",
"tracing",
+1
View File
@@ -27,6 +27,7 @@ codex-login = { path = "../login" }
codex-mcp-server = { path = "../mcp-server" }
codex-protocol = { path = "../protocol" }
codex-tui = { path = "../tui" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
tokio = { version = "1", features = [
+2 -12
View File
@@ -5,17 +5,8 @@ use serde_json::Value;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Key(u64);
fn decode_key(k: u64) -> (bool, u64, u64, u64) {
// returns (unordered, request_ordinal, output_index, sequence_number)
let unordered = (k >> 63) == 1;
let req = (k >> 48) & 0xFFFF;
let out = (k >> 32) & 0xFFFF;
let seq = k & 0xFFFF_FFFF;
(unordered, req, out, seq)
}
// Note: helper types for decoding packed keys were removed
// because this binary now parses structured logs instead.
fn parse_response_expected(path: &Path) -> Result<Vec<(u64, u64)>> {
// Returns vector of (out, seq) in the order they appear if we sort by out then seq
@@ -103,4 +94,3 @@ fn main() -> Result<()> {
Ok(())
}
+82
View File
@@ -2137,6 +2137,8 @@ async fn run_turn(
);
let mut retries = 0;
// Ensure we only auto-compact once per turn to avoid loops
let mut did_auto_compact = false;
// Attempt input starts as the provided input, and may be augmented with
// items from a previous dropped stream attempt so we don't lose progress.
let mut attempt_input: Vec<ResponseItem> = input.clone();
@@ -2183,6 +2185,86 @@ async fn run_turn(
return Err(e);
}
Err(e) => {
// Detect context-window overflow and auto-run a compact summarization once
if !did_auto_compact {
if let CodexErr::Stream(msg, _maybe_delay) = &e {
let lower = msg.to_ascii_lowercase();
let looks_like_context_overflow =
lower.contains("exceeds the context window")
|| lower.contains("exceed the context window")
|| lower.contains("context length exceeded")
|| lower.contains("maximum context length")
|| (lower.contains("context window")
&& (lower.contains("exceed")
|| lower.contains("exceeded")
|| lower.contains("full")
|| lower.contains("too long")));
if looks_like_context_overflow {
did_auto_compact = true;
// Inform UI and run a one-off compact turn inline, then retry
sess
.notify_stream_error(
&sub_id,
"Model hit context-window limit; running /compact and retrying…"
.to_string(),
)
.await;
const SUMMARIZATION_PROMPT: &str =
include_str!("prompt_for_compact_command.md");
let compact_input = response_input_from_core_items(vec![InputItem::Text {
text: "Start Summarization".to_string(),
}]);
let compact_turn_input: Vec<ResponseItem> =
sess.turn_input_with_history(vec![compact_input.clone().into()]);
let compact_prompt = Prompt {
input: compact_turn_input,
user_instructions: None,
store: !sess.disable_response_storage,
tools: Vec::new(),
base_instructions_override: Some(SUMMARIZATION_PROMPT.to_string()),
environment_context: None,
status_items: Vec::new(),
};
match drain_to_completed(sess, &sub_id, &compact_prompt).await {
Ok(()) => {
// Keep only the summary to shrink history
{
let mut state = sess.state.lock().unwrap();
state.history.keep_last_messages(1);
}
// Reset any partial attempt state and retry immediately
sess.clear_scratchpad();
sess
.notify_stream_error(
&sub_id,
"/compact completed; retrying with condensed history…"
.to_string(),
)
.await;
attempt_input = input.clone();
continue;
}
Err(err) => {
sess
.notify_stream_error(
&sub_id,
format!(
"/compact failed: {err}; falling back to normal retries…"
),
)
.await;
}
}
}
}
}
// Use the configured provider-specific stream retry budget.
let max_retries = sess.client.get_provider().stream_max_retries();
if retries < max_retries {
+15 -11
View File
@@ -69,8 +69,12 @@ pub(crate) struct App<'a> {
file_search: FileSearchManager,
/// True when a redraw has been scheduled but not yet executed.
/// True when a redraw has been scheduled but not yet executed (debounce window).
pending_redraw: Arc<AtomicBool>,
/// True while a one-shot timer for a future animation frame is armed.
/// This prevents arming multiple timers at once, while allowing timers
/// to run independently of the short debounce used for immediate redraws.
scheduled_frame_armed: Arc<AtomicBool>,
/// Controls the input reader thread spawned at startup.
input_running: Arc<AtomicBool>,
@@ -140,6 +144,7 @@ impl App<'_> {
let (bulk_tx, app_event_rx_bulk) = channel();
let app_event_tx = AppEventSender::new_dual(high_tx.clone(), bulk_tx.clone());
let pending_redraw = Arc::new(AtomicBool::new(false));
let scheduled_frame_armed = Arc::new(AtomicBool::new(false));
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
@@ -265,6 +270,7 @@ impl App<'_> {
config,
file_search,
pending_redraw,
scheduled_frame_armed,
input_running,
_transcript_overlay: None,
_deferred_history_lines: Vec::new(),
@@ -307,22 +313,20 @@ impl App<'_> {
/// Schedule a redraw after the specified duration
fn schedule_redraw_in(&self, duration: Duration) {
// Coalesce: if a redraw is already pending (either debounce timer or a
// previously scheduled frame), skip arming another timer. This avoids
// rapid-fire frame scheduling that can make spinners appear too fast.
// Coalesce timers: only arm one future frame at a time. Crucially, do
// NOT gate this on the short debounce flag used for immediate redraws,
// otherwise animations can stall if the timer is suppressed by debounce.
if self
.pending_redraw
.scheduled_frame_armed
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return;
}
let pending_redraw = self.pending_redraw.clone();
{ return; }
let scheduled = self.scheduled_frame_armed.clone();
let tx = self.app_event_tx.clone();
thread::spawn(move || {
thread::sleep(duration);
// Clear and draw exactly one frame at the requested cadence.
pending_redraw.store(false, Ordering::Release);
// Allow a subsequent timer to be armed.
scheduled.store(false, Ordering::Release);
tx.send(AppEvent::Redraw);
});
}
+243 -101
View File
@@ -357,19 +357,49 @@ impl ChatWidget<'_> {
OrderKey { req: om.request_ordinal, out: om.output_index.map(|v| v as i32).unwrap_or(0), seq: om.sequence_number.unwrap_or(0) }
}
// Track latest request index observed from provider so internal inserts can anchor to it.
fn note_order(&mut self, order: Option<&codex_core::protocol::OrderMeta>) {
if let Some(om) = order { self.last_seen_request_index = self.last_seen_request_index.max(om.request_ordinal); }
}
fn debug_fmt_order_key(ok: OrderKey) -> String {
format!("O:req={} out={} seq={}", ok.req, ok.out, ok.seq)
}
// Allocate a new synthetic key for internal (non-LLM) messages.
// Allocate a new synthetic key for internal (non-LLM) messages at the bottom of the
// current (active) request: (req = last_seen, out = +∞, seq = monotonic).
fn next_internal_key(&mut self) -> OrderKey {
// Ensure current_request_index always moves forward from the last seen provider index
if self.current_request_index < self.last_seen_request_index {
self.current_request_index = self.last_seen_request_index;
}
self.current_request_index = self.current_request_index.saturating_add(1);
// Anchor to the current provider request if known; otherwise step a synthetic counter.
let req = if self.last_seen_request_index > 0 {
self.last_seen_request_index
} else {
// Ensure current_request_index always moves forward
if self.current_request_index < self.last_seen_request_index {
self.current_request_index = self.last_seen_request_index;
}
self.current_request_index = self.current_request_index.saturating_add(1);
self.current_request_index
};
self.internal_seq = self.internal_seq.saturating_add(1);
OrderKey { req: self.current_request_index, out: -1, seq: self.internal_seq }
// Place internal notices at the end of the current request window by using
// a maximal out so they sort after any model-provided output_index.
OrderKey { req, out: i32::MAX, seq: self.internal_seq }
}
// Synthetic key for internal content that should appear at the TOP of the NEXT request
// (e.g., the user’s prompt preceding the model’s output for that turn).
fn next_req_key_top(&mut self) -> OrderKey {
let req = self.last_seen_request_index.saturating_add(1);
self.internal_seq = self.internal_seq.saturating_add(1);
OrderKey { req, out: i32::MIN, seq: self.internal_seq }
}
// Synthetic key for a user prompt that should appear just after banners but
// still before any model output within the next request.
fn next_req_key_prompt(&mut self) -> OrderKey {
let req = self.last_seen_request_index.saturating_add(1);
self.internal_seq = self.internal_seq.saturating_add(1);
OrderKey { req, out: i32::MIN + 1, seq: self.internal_seq }
}
/// Hide the bottom spinner/status if the UI is idle (no streams, tools, agents, or tasks).
fn maybe_hide_spinner(&mut self) {
@@ -1096,11 +1126,9 @@ impl ChatWidget<'_> {
// Browser manager is now handled through the global state
// The core session will use the same global manager when browser tools are invoked
// Add initial animated welcome message to history
let mut history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
let welcome_cell = Box::new(history_cell::new_animated_welcome());
// add AnimatedWelcomeCell silently
history_cells.push(welcome_cell);
// Add initial animated welcome message to history (top of first request)
let history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
// Insert later via history_push_top_next_req once struct is constructed
// Removed startup tip for /resume; /resume is now shown under "Popular commands".
@@ -1190,11 +1218,11 @@ impl ChatWidget<'_> {
internal_seq: 0,
show_order_overlay,
};
// Note: Initial redraw needs to be triggered after widget is added to app_state
// ready; trigger initial redraw
new_widget
// Insert the welcome cell as top-of-first-request so future model output
// appears below it.
let mut w = new_widget;
w.history_push_top_next_req(history_cell::new_animated_welcome()); // tag: prelude
w
}
/// Construct a ChatWidget from an existing conversation (forked session).
@@ -1232,10 +1260,9 @@ impl ChatWidget<'_> {
});
// Basic widget state mirrors `new`
let mut history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
history_cells.push(Box::new(history_cell::new_animated_welcome()));
let history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
Self {
let mut w = Self {
app_event_tx: app_event_tx.clone(),
codex_op_tx,
bottom_pane: BottomPane::new(BottomPaneParams {
@@ -1314,7 +1341,10 @@ impl ChatWidget<'_> {
internal_seq: 0,
show_order_overlay,
}
};
// Welcome at top of first request for forked session too
w.history_push_top_next_req(history_cell::new_animated_welcome());
w
}
/// Export current user/assistant messages into ResponseItem list for forking.
@@ -1615,6 +1645,11 @@ impl ChatWidget<'_> {
fn flash_scrollbar(&self) { layout_scroll::flash_scrollbar(self); }
fn history_insert_with_key_global(&mut self, cell: Box<dyn HistoryCell>, key: OrderKey) -> usize {
self.history_insert_with_key_global_tagged(cell, key, "untagged")
}
// Internal: same as above but with a short tag for debug overlays.
fn history_insert_with_key_global_tagged(&mut self, cell: Box<dyn HistoryCell>, key: OrderKey, tag: &'static str) -> usize {
// Any ordered insert of a non-reasoning cell means reasoning is no longer the
// bottom-most active block; drop the in-progress ellipsis on collapsed titles.
let is_reasoning_cell = cell
@@ -1639,11 +1674,12 @@ impl ChatWidget<'_> {
}
tracing::info!(
"[order] insert: {} pos={} len_before={} order_len_before={}",
"[order] insert: {} pos={} len_before={} order_len_before={} tag={}",
Self::debug_fmt_order_key(key),
pos,
self.history_cells.len(),
self.cell_order_seq.len()
self.cell_order_seq.len(),
tag
);
// If order overlay is enabled, compute a short, inline debug summary for
// reasoning titles so we can spot mid‑word character drops quickly.
@@ -1704,21 +1740,23 @@ impl ChatWidget<'_> {
let req_dbg = format!("{}", key.req);
let dbg = if let Some(tdbg) = reasoning_title_dbg {
format!(
"insert: {} req={} key={} {} pos={} | {}",
"insert: {} req={} key={} {} pos={} tag={} | {}",
ordered,
req_dbg,
0,
Self::debug_fmt_order_key(key),
pos,
tag,
tdbg
)
} else {
format!(
"insert: {} req={} {} pos={}",
"insert: {} req={} {} pos={} tag={}",
ordered,
req_dbg,
Self::debug_fmt_order_key(key),
pos
pos,
tag
)
};
if self.cell_order_dbg.len() < pos { self.cell_order_dbg.resize(pos, None); }
@@ -1733,11 +1771,12 @@ impl ChatWidget<'_> {
pos
}
/// Push a cell using a synthetic global order key (internal event).
fn history_push(&mut self, cell: impl HistoryCell + 'static) {
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global(Box::new(cell), key);
}
/// Push a cell using a synthetic global order key at the bottom of the current request.
fn history_push(&mut self, cell: impl HistoryCell + 'static) { let key = self.next_internal_key(); let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "epilogue"); }
/// Push a cell using a synthetic key at the TOP of the NEXT request.
fn history_push_top_next_req(&mut self, cell: impl HistoryCell + 'static) { let key = self.next_req_key_top(); let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "prelude"); }
/// Push a user prompt so it appears right under banners and above model output for the next request.
fn history_push_prompt_next_req(&mut self, cell: impl HistoryCell + 'static) { let key = self.next_req_key_prompt(); let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "prompt"); }
fn history_replace_at(&mut self, idx: usize, cell: Box<dyn HistoryCell>) {
if idx < self.history_cells.len() {
@@ -2026,7 +2065,7 @@ impl ChatWidget<'_> {
// Idle path: append the user cell first so the upcoming TaskStarted
// window begins after it, ensuring assistant output renders below.
if !original_text.is_empty() {
self.history_push(history_cell::new_user_prompt(original_text.clone()));
self.history_push_prompt_next_req(history_cell::new_user_prompt(original_text.clone()));
self.pending_user_prompts_for_next_turn = self.pending_user_prompts_for_next_turn.saturating_add(1);
}
@@ -2072,6 +2111,8 @@ impl ChatWidget<'_> {
serde_json::to_string_pretty(&event).unwrap_or_default()
);
// Strict ordering: all LLM/tool events must carry OrderMeta; internal events use synthetic keys.
// Track provider order to anchor internal inserts at the bottom of the active request.
self.note_order(event.order.as_ref());
let Event { id, msg, .. } = event.clone();
match msg {
@@ -2086,7 +2127,7 @@ impl ChatWidget<'_> {
if is_first {
self.welcome_shown = true;
}
self.history_push(history_cell::new_session_info(&self.config, event, is_first));
self.history_push_top_next_req(history_cell::new_session_info(&self.config, event, is_first)); // tag: prelude
if let Some(user_message) = self.initial_user_message.take() {
// If the user provided an initial message, add it to the
@@ -2306,7 +2347,7 @@ impl ChatWidget<'_> {
// Move it from the sticky queue preview into history
let UserMessage { display_text, ordered_items } = next;
if !display_text.is_empty() {
self.history_push(history_cell::new_user_prompt(display_text.clone()));
self.history_push_prompt_next_req(history_cell::new_user_prompt(display_text.clone()));
self.pending_user_prompts_for_next_turn = self.pending_user_prompts_for_next_turn.saturating_add(1);
}
self.codex_op_tx
@@ -3380,7 +3421,7 @@ impl ChatWidget<'_> {
} else if let Some(reason) = cell.as_any_mut().downcast_mut::<history_cell::CollapsibleReasoningCell>() {
history_cell::retint_lines_in_place(&mut reason.lines, &old, &new);
} else if let Some(stream) = cell.as_any_mut().downcast_mut::<history_cell::StreamingContentCell>() {
history_cell::retint_lines_in_place(&mut stream.lines, &old, &new);
stream.retint(&old, &new);
} else if let Some(assist) = cell.as_any_mut().downcast_mut::<history_cell::AssistantMarkdownCell>() {
// Fully rebuild from raw to apply new theme + syntax highlight
assist.rebuild(&self.config);
@@ -3692,7 +3733,7 @@ impl ChatWidget<'_> {
lines.remove(0);
}
}
stream_cell.lines.extend(lines);
stream_cell.extend_lines(lines);
self.invalidate_height_cache();
self.autoscroll_if_near_bottom();
self.request_redraw();
@@ -3711,7 +3752,7 @@ impl ChatWidget<'_> {
}).unwrap_or(false) {
if lines.len() == 1 { return; } else { lines.remove(0); }
}
stream_cell.lines.extend(lines);
stream_cell.extend_lines(lines);
self.invalidate_height_cache();
self.autoscroll_if_near_bottom();
self.request_redraw();
@@ -3737,7 +3778,7 @@ impl ChatWidget<'_> {
}).unwrap_or(false) {
if lines.len() == 1 { return; } else { lines.remove(0); }
}
stream_cell.lines.extend(lines);
stream_cell.extend_lines(lines);
self.invalidate_height_cache();
self.autoscroll_if_near_bottom();
self.request_redraw();
@@ -5346,10 +5387,9 @@ impl ChatWidget<'_> {
self.active_exec_cell = None;
self.clear_token_usage();
// Add a new animated welcome cell (ordered)
let welcome_cell = Box::new(history_cell::new_animated_welcome());
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global(welcome_cell, key);
// Add a new animated welcome cell at the top of the next request so
// upcoming output appears below it.
self.history_push_top_next_req(history_cell::new_animated_welcome());
self.reasoning_index.clear();
self.stream_order_seq.clear();
@@ -5401,46 +5441,35 @@ impl ChatWidget<'_> {
}
fn get_git_branch(&self) -> Option<String> {
use std::process::Command;
use std::fs;
use std::path::Path;
let output = Command::new("git")
.arg("rev-parse")
.arg("--abbrev-ref")
.arg("HEAD")
.current_dir(&self.config.cwd)
.output()
.ok()?;
// Read .git/HEAD to avoid spawning `git` every frame.
// Formats:
// - "ref: refs/heads/<branch>" when on a named branch
// - "<40-hex-SHA>" when in a detached HEAD state
let head_path = self.config.cwd.join(".git/HEAD");
let head_contents = fs::read_to_string(&head_path).ok()?;
let head = head_contents.trim();
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !branch.is_empty() && branch != "HEAD" {
Some(branch)
} else {
// Try to get short commit hash if in detached HEAD state
let commit_output = Command::new("git")
.arg("rev-parse")
.arg("--short")
.arg("HEAD")
.current_dir(&self.config.cwd)
.output()
.ok()?;
if commit_output.status.success() {
let commit = String::from_utf8_lossy(&commit_output.stdout)
.trim()
.to_string();
if !commit.is_empty() {
Some(format!("detached: {}", commit))
} else {
None
}
} else {
None
}
if let Some(rest) = head.strip_prefix("ref: ") {
// Extract last path segment as branch name
if let Some(name) = Path::new(rest)
.file_name()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
{
return Some(name.to_string());
}
} else {
None
}
// Detached HEAD: display short SHA if it looks like a commit hash
let is_hex = head.len() >= 7 && head.as_bytes().iter().all(|b| b.is_ascii_hexdigit());
if is_hex {
return Some(format!("detached: {}", &head[..7]));
}
None
}
fn render_status_bar(&self, area: Rect, buf: &mut Buffer) {
@@ -6435,11 +6464,20 @@ impl ChatWidget<'_> {
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Top-level widget render timing
let _perf_widget_start = if self.perf_state.enabled { Some(std::time::Instant::now()) } else { None };
// 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.
// Ensure a consistent background even when individual widgets skip
// painting unchanged regions. Without this, gutters and inter‑cell
// spacing can show through after we reduced full clears.
// Cost: one Block render across the frame (O(area)); acceptable and
// fixes visual artifacts reported after redraw reductions.
{
use ratatui::style::Style;
use ratatui::widgets::Block;
let bg = Block::default().style(Style::default().bg(crate::colors::background()));
bg.render(area, buf);
}
// Remember full frame height for HUD sizing logic
self.layout.last_frame_height.set(area.height);
@@ -6562,11 +6600,16 @@ impl WidgetRef for &ChatWidget<'_> {
.downcast_ref::<crate::history_cell::ExecCell>()
.map(|e| e.output.is_some())
.unwrap_or(false);
// Assistant markdown cells are static once built; cache their heights
let is_assistant_static = item
.as_any()
.downcast_ref::<crate::history_cell::AssistantMarkdownCell>()
.is_some();
let is_streaming = item
.as_any()
.downcast_ref::<crate::history_cell::StreamingContentCell>()
.is_some();
let is_cacheable = ((!item.has_custom_render()) || is_stable_exec)
let is_cacheable = ((!item.has_custom_render()) || is_stable_exec || is_assistant_static)
&& !item.is_animating()
&& !is_streaming;
let h = if is_cacheable {
@@ -6703,17 +6746,50 @@ impl WidgetRef for &ChatWidget<'_> {
// Record current viewport height for the next frame
self.layout.last_history_viewport_height.set(content_area.height);
// Clear the entire history region (including left/right padding), not just
// the inner content area. This avoids occasional artifacts at the margins
// and ensures background is fully painted even when widths shrink.
// Fill with spaces while preserving the theme background color.
// Targeted clears: only pad stripes and any top gap inside content area.
let clear_style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in history_area.y..history_area.y.saturating_add(history_area.height) {
for x in history_area.x..history_area.x.saturating_add(history_area.width) {
buf[(x, y)].set_char(' ').set_style(clear_style);
let _perf_hist_clear_start = if self.perf_state.enabled { Some(std::time::Instant::now()) } else { None };
let mut cleared_cells: u64 = 0;
// Left/right padding stripes
let left_pad_w = content_area.x.saturating_sub(history_area.x);
let right_pad_start = content_area.x.saturating_add(content_area.width);
let right_pad_w = history_area
.x
.saturating_add(history_area.width)
.saturating_sub(right_pad_start);
if left_pad_w > 0 {
for y in history_area.y..history_area.y.saturating_add(history_area.height) {
for x in history_area.x..history_area.x.saturating_add(left_pad_w) {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
}
cleared_cells = cleared_cells.saturating_add((left_pad_w as u64) * (history_area.height as u64));
}
if right_pad_w > 0 {
for y in history_area.y..history_area.y.saturating_add(history_area.height) {
for x in right_pad_start..right_pad_start.saturating_add(right_pad_w) {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
}
cleared_cells = cleared_cells.saturating_add((right_pad_w as u64) * (history_area.height as u64));
}
// Top gap inside content area when content is bottom-aligned
if start_y > content_area.y {
let gap_h = start_y.saturating_sub(content_area.y);
for y in content_area.y..content_area.y.saturating_add(gap_h) {
for x in content_area.x..content_area.x.saturating_add(content_area.width) {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
}
cleared_cells = cleared_cells.saturating_add((gap_h as u64) * (content_area.width as u64));
}
if let Some(t0) = _perf_hist_clear_start {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
p.ns_history_clear = p.ns_history_clear.saturating_add(dt);
p.cells_history_clear = p.cells_history_clear.saturating_add(cleared_cells);
}
// Render the scrollable content with spacing using prefix sums
@@ -6822,18 +6898,28 @@ impl WidgetRef for &ChatWidget<'_> {
crate::colors::background()
};
let gutter_bg_style = Style::default().bg(gutter_bg);
for y in gutter_area.y..gutter_area.y.saturating_add(gutter_area.height) {
// Gutter’s two columns (symbol + space)
if gutter_area.width > 0 {
buf[(gutter_area.x, y)].set_char(' ').set_style(gutter_bg_style);
// Paint gutter background for assistant cells so the tinted
// strip appears contiguous with the message body. This avoids
// the light "hole" seen after we reduced redraws. For other
// cell types keep the default background (already painted by
// the frame bg fill above).
if is_assistant && gutter_area.width > 0 && gutter_area.height > 0 {
let _perf_gutter_start = if self.perf_state.enabled { Some(std::time::Instant::now()) } else { None };
let style = Style::default().bg(gutter_bg);
for y in gutter_area.y..gutter_area.y.saturating_add(gutter_area.height) {
// Only the first column (symbol column) needs tint; the second is spacing to content
// but tint both for visual continuity with the assistant block.
for x in gutter_area.x..gutter_area.x.saturating_add(gutter_area.width) {
buf[(x, y)].set_char(' ').set_style(style);
}
}
if gutter_area.width > 1 {
buf[(gutter_area.x + 1, y)].set_char(' ').set_style(gutter_bg_style);
}
// One extra column to the left for Assistant only (no layout change)
if is_assistant && gutter_area.x > 0 {
buf[(gutter_area.x - 1, y)].set_char(' ').set_style(gutter_bg_style);
if let Some(t0) = _perf_gutter_start {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
p.ns_gutter_paint = p.ns_gutter_paint.saturating_add(dt);
// Rough accounting: area of gutter rectangle (clamped to u64)
let area_cells: u64 = (gutter_area.width as u64).saturating_mul(gutter_area.height as u64);
p.cells_gutter_paint = p.cells_gutter_paint.saturating_add(area_cells);
}
}
@@ -6990,6 +7076,24 @@ impl WidgetRef for &ChatWidget<'_> {
}
}
// Clear any bottom gap inside the content area that wasn’t covered by items
if screen_y < content_area.y + content_area.height {
let _perf_hist_clear2 = if self.perf_state.enabled { Some(std::time::Instant::now()) } else { None };
for y in screen_y..content_area.y + content_area.height {
for x in content_area.x..content_area.x + content_area.width {
buf[(x, y)].set_char(' ').set_style(clear_style);
}
}
if let Some(t0) = _perf_hist_clear2 {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
p.ns_history_clear = p.ns_history_clear.saturating_add(dt);
let cells = (content_area.width as u64)
* ((content_area.y + content_area.height - screen_y) as u64);
p.cells_history_clear = p.cells_history_clear.saturating_add(cells);
}
}
// Render vertical scrollbar when content is scrollable and currently visible
// Auto-hide after a short delay to avoid copying it along with text.
let now = std::time::Instant::now();
@@ -7046,6 +7150,7 @@ impl WidgetRef for &ChatWidget<'_> {
let scrim_bg = Style::default()
.bg(crate::colors::overlay_scrim())
.fg(crate::colors::text_dim());
let _perf_scrim_start = if self.perf_state.enabled { Some(std::time::Instant::now()) } else { None };
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
// Overwrite with a dimmed style; we don't Clear so existing glyphs remain,
@@ -7053,6 +7158,13 @@ impl WidgetRef for &ChatWidget<'_> {
buf[(x, y)].set_style(scrim_bg);
}
}
if let Some(t0) = _perf_scrim_start {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
p.ns_overlay_scrim = p.ns_overlay_scrim.saturating_add(dt);
let cells = (area.width as u64) * (area.height as u64);
p.cells_overlay_scrim = p.cells_overlay_scrim.saturating_add(cells);
}
// Match the horizontal padding used by status bar and input
let padding = 1u16;
let area = Rect {
@@ -7065,11 +7177,19 @@ impl WidgetRef for &ChatWidget<'_> {
// Clear and repaint the overlay area with theme scrim background
Clear.render(area, buf);
let bg_style = Style::default().bg(crate::colors::overlay_scrim());
let _perf_overlay_area_bg_start = if self.perf_state.enabled { Some(std::time::Instant::now()) } else { None };
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
buf[(x, y)].set_style(bg_style);
}
}
if let Some(t0) = _perf_overlay_area_bg_start {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
p.ns_overlay_body_bg = p.ns_overlay_body_bg.saturating_add(dt);
let cells = (area.width as u64) * (area.height as u64);
p.cells_overlay_body_bg = p.cells_overlay_body_bg.saturating_add(cells);
}
// Build a styled title: keys/icons in normal text color; descriptors and dividers dim
let t_dim = Style::default().fg(crate::colors::text_dim());
@@ -7113,11 +7233,19 @@ impl WidgetRef for &ChatWidget<'_> {
// Paint inner content background as the normal theme background
let inner_bg = Style::default().bg(crate::colors::background());
let _perf_overlay_inner_bg_start = if self.perf_state.enabled { Some(std::time::Instant::now()) } else { None };
for y in inner.y..inner.y + inner.height {
for x in inner.x..inner.x + inner.width {
buf[(x, y)].set_style(inner_bg);
}
}
if let Some(t0) = _perf_overlay_inner_bg_start {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
p.ns_overlay_body_bg = p.ns_overlay_body_bg.saturating_add(dt);
let cells = (inner.width as u64) * (inner.height as u64);
p.cells_overlay_body_bg = p.cells_overlay_body_bg.saturating_add(cells);
}
// Split into header tabs and body/footer
// Add one cell padding around the entire inside of the window
@@ -7248,11 +7376,19 @@ impl WidgetRef for &ChatWidget<'_> {
_ => bg,
};
let body_bg = Style::default().bg(paper_color);
let _perf_overlay_body_bg2 = if self.perf_state.enabled { Some(std::time::Instant::now()) } else { None };
for y in body_inner.y..body_inner.y + body_inner.height {
for x in body_inner.x..body_inner.x + body_inner.width {
buf[(x, y)].set_style(body_bg);
}
}
if let Some(t0) = _perf_overlay_body_bg2 {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
p.ns_overlay_body_bg = p.ns_overlay_body_bg.saturating_add(dt);
let cells = (body_inner.width as u64) * (body_inner.height as u64);
p.cells_overlay_body_bg = p.cells_overlay_body_bg.saturating_add(cells);
}
let paragraph = Paragraph::new(RtText::from(visible.to_vec())).wrap(ratatui::widgets::Wrap { trim: false });
ratatui::widgets::Widget::render(paragraph, body_inner, buf);
@@ -7288,6 +7424,12 @@ impl WidgetRef for &ChatWidget<'_> {
}
}
}
// Finalize widget render timing
if let Some(t0) = _perf_widget_start {
let dt = t0.elapsed().as_nanos();
let mut p = self.perf_state.stats.borrow_mut();
p.ns_widget_render_total = p.ns_widget_render_total.saturating_add(dt);
}
}
}
+27 -2
View File
@@ -13,6 +13,18 @@ pub struct PerfStats {
pub height_misses_render: u64,
pub ns_total_height: u128,
pub ns_render_loop: u128,
// Full widget render wall time (outer render wrapper)
pub ns_widget_render_total: u128,
// Explicit paint/clear hotspots we perform outside ratatui widgets
pub ns_history_clear: u128,
pub cells_history_clear: u64,
pub ns_gutter_paint: u128,
pub cells_gutter_paint: u64,
// Diff overlay fills
pub ns_overlay_scrim: u128,
pub cells_overlay_scrim: u64,
pub ns_overlay_body_bg: u128,
pub cells_overlay_body_bg: u64,
// Hotspots: time spent computing heights on cache misses
pub hot_total: std::collections::HashMap<(usize, u16), ItemStat>,
pub hot_render: std::collections::HashMap<(usize, u16), ItemStat>,
@@ -33,9 +45,14 @@ impl PerfStats {
pub fn summary(&self) -> String {
let ms_total_height = (self.ns_total_height as f64) / 1_000_000.0;
let ms_render = (self.ns_render_loop as f64) / 1_000_000.0;
let ms_widget = (self.ns_widget_render_total as f64) / 1_000_000.0;
let ms_hist_clear = (self.ns_history_clear as f64) / 1_000_000.0;
let ms_gutter = (self.ns_gutter_paint as f64) / 1_000_000.0;
let ms_scrim = (self.ns_overlay_scrim as f64) / 1_000_000.0;
let ms_overlay_body = (self.ns_overlay_body_bg as f64) / 1_000_000.0;
let mut out = String::new();
out.push_str(&format!(
"perf: frames={}\n prefix_rebuilds={}\n height_cache: total hits={} misses={}\n height_cache (render): hits={} misses={}\n time: total_height={:.2}ms render_visible={:.2}ms",
"perf: frames={}\n prefix_rebuilds={}\n height_cache: total hits={} misses={}\n height_cache (render): hits={} misses={}\n time: total_height={:.2}ms render_visible={:.2}ms\n time: widget_render_total={:.2}ms\n paint: history_clear={:.2}ms (cells={}) gutter_bg={:.2}ms (cells={})\n paint: overlay_scrim={:.2}ms (cells={}) overlay_body_bg={:.2}ms (cells={})",
self.frames,
self.prefix_rebuilds,
self.height_hits_total,
@@ -44,6 +61,15 @@ impl PerfStats {
self.height_misses_render,
ms_total_height,
ms_render,
ms_widget,
ms_hist_clear,
self.cells_history_clear,
ms_gutter,
self.cells_gutter_paint,
ms_scrim,
self.cells_overlay_scrim,
ms_overlay_body,
self.cells_overlay_body_bg,
));
// Top hotspots by (index,width)
@@ -134,4 +160,3 @@ impl PerfStats {
ek.ns = ek.ns.saturating_add(ns);
}
}
+4 -9
View File
@@ -32,10 +32,8 @@ pub fn render_intro_outline_fill(area: Rect, buf: &mut Buffer, t: f32) {
let t = t.clamp(0.0, 1.0);
let outline_p = smoothstep(0.00, 0.60, t); // outline draws L->R
let fill_p = smoothstep(0.35, 0.95, t); // interior fills L->R
// Keep the animation vivid until the very end. Only begin a subtle fade in
// the last ~2% of progress, and render the final frame fully bright.
let mut fade = smoothstep(0.98, 1.00, t);
if (t - 1.0).abs() < 1e-6 { fade = 0.0; }
// Original fade profile: begin soft fade near the end.
let fade = smoothstep(0.90, 1.00, t);
let scan_p = smoothstep(0.55, 0.85, t); // scanline sweep
let frame = (t * 60.0) as u32;
@@ -95,11 +93,8 @@ pub fn render_intro_outline_fill_with_alpha(area: Rect, buf: &mut Buffer, t: f32
let alpha = alpha.clamp(0.0, 1.0);
let outline_p = smoothstep(0.00, 0.60, t); // outline draws L->R
let fill_p = smoothstep(0.35, 0.95, t); // interior fills L->R
// Match the non-alpha path: keep vivid until the end and avoid dimming the
// terminal-stable final frame. Any longer fade-out is handled by the caller
// (AnimatedWelcomeCell) using alpha blending.
let mut fade = smoothstep(0.98, 1.00, t);
if (t - 1.0).abs() < 1e-6 { fade = 0.0; }
// Match original fade profile for alpha path as well.
let fade = smoothstep(0.90, 1.00, t);
let scan_p = smoothstep(0.55, 0.85, t); // scanline sweep
let frame = (t * 60.0) as u32;
+196 -280
View File
@@ -539,6 +539,8 @@ pub(crate) struct AssistantMarkdownCell {
pub(crate) id: Option<String>,
// Pre-rendered lines (first line is a hidden "codex" header)
pub(crate) lines: Vec<Line<'static>>, // includes hidden header "codex"
// Cached per-width wrap plan to avoid re-segmentation and re-measure
cached_layout: std::cell::RefCell<Option<AssistantLayoutCache>>,
}
impl AssistantMarkdownCell {
@@ -556,6 +558,7 @@ impl AssistantMarkdownCell {
raw,
id,
lines: Vec::new(),
cached_layout: std::cell::RefCell::new(None),
};
me.rebuild(cfg);
me
@@ -570,6 +573,100 @@ impl AssistantMarkdownCell {
line.style = line.style.patch(Style::default().fg(bright));
}
self.lines = out;
// Invalidate cached layout on rebuild (theme/lines changed)
*self.cached_layout.borrow_mut() = None;
}
}
// Cached layout for AssistantMarkdownCell (per width)
#[derive(Clone)]
struct AssistantLayoutCache {
width: u16,
segs: Vec<AssistantSeg>,
seg_rows: Vec<u16>,
total_rows_with_padding: u16,
}
#[derive(Clone, Debug)]
enum AssistantSeg {
Text(Vec<Line<'static>>),
Bullet(Vec<Line<'static>>),
Code(Vec<Line<'static>>),
}
impl AssistantMarkdownCell {
fn ensure_layout(&self, width: u16) -> AssistantLayoutCache {
if let Some(cache) = self.cached_layout.borrow().as_ref() {
if cache.width == width {
return cache.clone();
}
}
let text_wrap_width = width;
let mut segs: Vec<AssistantSeg> = Vec::new();
let mut text_buf: Vec<Line<'static>> = Vec::new();
let mut iter = self.display_lines_trimmed().into_iter().peekable();
while let Some(line) = iter.next() {
if crate::render::line_utils::is_code_block_painted(&line) {
if !text_buf.is_empty() { segs.push(AssistantSeg::Text(std::mem::take(&mut text_buf))); }
let mut chunk = vec![line];
while let Some(n) = iter.peek() {
if crate::render::line_utils::is_code_block_painted(n) { chunk.push(iter.next().unwrap()); } else { break; }
}
// Remove language sentinel and trim blank padding rows (as in render)
if let Some(first) = chunk.first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if flat.contains("⟦LANG:") { let _ = chunk.remove(0); }
}
while chunk.first().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { let _ = chunk.remove(0); }
while chunk.last().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { let _ = chunk.pop(); }
segs.push(AssistantSeg::Code(chunk));
continue;
}
if text_wrap_width > 4 && is_horizontal_rule_line(&line) {
if !text_buf.is_empty() { segs.push(AssistantSeg::Text(std::mem::take(&mut text_buf))); }
let hr = Line::from(Span::styled(
std::iter::repeat('─').take(text_wrap_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
segs.push(AssistantSeg::Bullet(vec![hr]));
continue;
}
if text_wrap_width > 4 {
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
if !text_buf.is_empty() { segs.push(AssistantSeg::Text(std::mem::take(&mut text_buf))); }
segs.push(AssistantSeg::Bullet(wrap_bullet_line(
line,
indent_spaces,
&bullet_char,
text_wrap_width,
)));
continue;
}
}
text_buf.push(line);
}
if !text_buf.is_empty() { segs.push(AssistantSeg::Text(std::mem::take(&mut text_buf))); }
// Precompute rows per segment and total with top/bottom padding
let mut seg_rows: Vec<u16> = Vec::with_capacity(segs.len());
let mut total: u16 = 0;
for seg in &segs {
let rows = match seg {
AssistantSeg::Bullet(lines) => lines.len() as u16,
AssistantSeg::Text(lines) => Paragraph::new(Text::from(lines.clone()))
.wrap(Wrap { trim: false })
.line_count(text_wrap_width)
.try_into()
.unwrap_or(0),
AssistantSeg::Code(lines) => lines.len() as u16 + 2,
};
seg_rows.push(rows);
total = total.saturating_add(rows);
}
total = total.saturating_add(2); // top+bottom padding
let cache = AssistantLayoutCache { width, segs, seg_rows, total_rows_with_padding: total };
*self.cached_layout.borrow_mut() = Some(cache.clone());
cache
}
}
@@ -595,98 +692,7 @@ impl HistoryCell for AssistantMarkdownCell {
true
}
fn desired_height(&self, width: u16) -> u16 {
// Match custom rendering exactly:
// - Bullet lines are prewrapped with hanging indent and treated as fixed rows
// - Code blocks render inside a bordered card (add 2 rows for borders)
// - Other text wraps via Paragraph at the available width
// - Add top and bottom padding rows
let text_wrap_width = width;
let src_lines = self.display_lines_trimmed();
#[derive(Debug)]
enum Seg {
Text(Vec<Line<'static>>),
Bullet(Vec<Line<'static>>),
Code(Vec<Line<'static>>),
}
let mut segs: Vec<Seg> = Vec::new();
let mut text_buf: Vec<Line<'static>> = Vec::new();
let mut _is_first_output_line = true;
let mut iter = src_lines.into_iter().peekable();
while let Some(line) = iter.next() {
if crate::render::line_utils::is_code_block_painted(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let mut chunk = vec![line];
while let Some(n) = iter.peek() {
if crate::render::line_utils::is_code_block_painted(n) { chunk.push(iter.next().unwrap()); } else { break; }
}
segs.push(Seg::Code(chunk));
continue;
}
if text_wrap_width > 4 && is_horizontal_rule_line(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let hr = Line::from(Span::styled(
std::iter::repeat('─').take(text_wrap_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
segs.push(Seg::Bullet(vec![hr]));
_is_first_output_line = false;
continue;
}
if text_wrap_width > 4 {
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
// Always use explicit bullet wrapping with hanging indent so
// continuation lines align under the start of the content.
segs.push(Seg::Bullet(wrap_bullet_line(
line,
indent_spaces,
&bullet_char,
text_wrap_width,
)));
_is_first_output_line = false;
continue;
}
}
text_buf.push(line);
_is_first_output_line = false;
}
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let mut total: u16 = 0;
for seg in segs {
match seg {
Seg::Bullet(lines) => {
total = total.saturating_add(lines.len() as u16);
}
Seg::Text(lines) => {
if lines.is_empty() { continue; }
let text = Text::from(lines);
let rows: u16 = Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(text_wrap_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(rows);
}
Seg::Code(mut chunk) => {
// Remove language sentinel and trim blank padding rows
if let Some(first) = chunk.first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if flat.contains("⟦LANG:") { chunk.remove(0); }
}
while chunk.first().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.remove(0); }
while chunk.last().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.pop(); }
total = total.saturating_add(chunk.len() as u16 + 2);
}
}
}
total.saturating_add(2)
self.ensure_layout(width).total_rows_with_padding
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
// Mirror StreamingContentCell rendering so finalized assistant cells look
@@ -701,58 +707,11 @@ impl HistoryCell for AssistantMarkdownCell {
}
}
// Build segments: prewrapped bullets, code (no rewrap), and normal text.
// Build or reuse cached segments for this width
let plan = self.ensure_layout(area.width);
let segs = &plan.segs;
let seg_rows = &plan.seg_rows;
let text_wrap_width = area.width;
#[derive(Debug)]
enum Seg {
Text(Vec<Line<'static>>),
Bullet(Vec<Line<'static>>),
Code(Vec<Line<'static>>),
}
let mut segs: Vec<Seg> = Vec::new();
let mut text_buf: Vec<Line<'static>> = Vec::new();
let mut _is_first_output_line = true;
let mut iter = self.display_lines_trimmed().into_iter().peekable();
while let Some(line) = iter.next() {
if crate::render::line_utils::is_code_block_painted(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let mut chunk = vec![line];
while let Some(n) = iter.peek() {
if crate::render::line_utils::is_code_block_painted(n) { chunk.push(iter.next().unwrap()); } else { break; }
}
// Trim padding rows rendered inside the code card background
while chunk.first().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.remove(0); }
while chunk.last().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.pop(); }
segs.push(Seg::Code(chunk));
continue;
}
if text_wrap_width > 4 && is_horizontal_rule_line(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let hr = Line::from(Span::styled(
std::iter::repeat('─').take(text_wrap_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
segs.push(Seg::Bullet(vec![hr]));
_is_first_output_line = false;
continue;
}
if text_wrap_width > 4 {
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
segs.push(Seg::Bullet(wrap_bullet_line(
line,
indent_spaces,
&bullet_char,
text_wrap_width,
)));
_is_first_output_line = false;
continue;
}
}
text_buf.push(line);
_is_first_output_line = false;
}
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
// Streaming-style top padding row for the entire assistant cell
let mut remaining_skip = skip_rows;
@@ -764,6 +723,8 @@ impl HistoryCell for AssistantMarkdownCell {
remaining_skip = remaining_skip.saturating_sub(1);
// Helpers
#[derive(Debug, Clone)]
enum Seg { Text(Vec<Line<'static>>), Bullet(Vec<Line<'static>>), Code(Vec<Line<'static>>) }
use unicode_width::UnicodeWidthStr as UW;
let measure_line =
|l: &Line<'_>| -> usize { l.spans.iter().map(|s| UW::width(s.content.as_ref())).sum() };
@@ -922,11 +883,23 @@ impl HistoryCell for AssistantMarkdownCell {
}
};
for seg in &segs {
for (seg_idx, seg) in segs.iter().enumerate() {
if cur_y >= end_y {
break;
}
draw_segment(seg, &mut cur_y, &mut remaining_skip);
// Clamp skip to precomputed rows for this segment to avoid extra measure work
let _before = remaining_skip;
let rows = seg_rows.get(seg_idx).copied().unwrap_or(0);
if remaining_skip >= rows {
remaining_skip -= rows;
continue;
}
let seg_draw = match seg {
AssistantSeg::Text(v) => Seg::Text(v.clone()),
AssistantSeg::Bullet(v) => Seg::Bullet(v.clone()),
AssistantSeg::Code(v) => Seg::Code(v.clone()),
};
draw_segment(&seg_draw, &mut cur_y, &mut remaining_skip);
}
// Bottom padding row (blank): area is already cleared to bg
if remaining_skip == 0 && cur_y < end_y {
@@ -2910,8 +2883,13 @@ impl CollapsibleReasoningCell {
}
/// Extracts section titles for collapsed display: any line that appears to
/// be a heading or starts with a leading bold run. Returns at least the
/// first non-empty non-header line if no titles found.
/// be a heading (entire line styled bold). While streaming (`in_progress`),
/// we intentionally suppress the fallback that would otherwise return the
/// first non-empty non-header line — this prevents verbose paragraph
/// content from flashing before the model emits proper section titles.
///
/// After streaming completes, we keep the gentle fallback so providers
/// that never emit bold headings still have a concise summary line.
fn extract_section_titles(&self) -> Vec<Line<'static>> {
let lines = self.normalized_lines();
let mut titles: Vec<Line<'static>> = Vec::new();
@@ -2933,14 +2911,15 @@ impl CollapsibleReasoningCell {
}
}
if titles.is_empty() {
// Fallback: first non-empty line as summary
// If we're still streaming, do NOT fallback to the first non-empty line;
// show an ellipsis in `display_lines` until a real title arrives. This
// avoids flashing full reasoning paragraphs in collapsed mode.
if titles.is_empty() && !self.in_progress.get() {
// Fallback (finalized only): first non-empty line as summary
for l in lines.iter() {
let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.is_empty() { continue; }
titles.push(l.clone());
break;
}
@@ -3092,9 +3071,11 @@ impl HistoryCell for CollapsibleReasoningCell {
pub(crate) struct StreamingContentCell {
pub(crate) id: Option<String>,
pub(crate) lines: Vec<Line<'static>>,
lines: Vec<Line<'static>>,
// Show an ellipsis on a new line while streaming is in progress
pub(crate) show_ellipsis: bool,
// Cached per-width wrap plan to avoid re-segmentation; invalidated on extend
cached_layout: std::cell::RefCell<Option<AssistantLayoutCache>>, // reuse same struct
}
impl HistoryCell for StreamingContentCell {
@@ -3119,81 +3100,8 @@ impl HistoryCell for StreamingContentCell {
true
}
fn desired_height(&self, width: u16) -> u16 {
// Match streaming render path with bullet-aware prewrapping and no double wrap.
let text_wrap_width = width;
let src_lines = self.display_lines_trimmed();
#[derive(Debug)]
enum Seg { Text(Vec<Line<'static>>), Bullet(Vec<Line<'static>>), Code(Vec<Line<'static>>) }
let mut segs: Vec<Seg> = Vec::new();
let mut text_buf: Vec<Line<'static>> = Vec::new();
let mut _is_first_output_line = true;
let mut iter = src_lines.into_iter().peekable();
while let Some(line) = iter.next() {
if crate::render::line_utils::is_code_block_painted(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let mut chunk = vec![line];
while let Some(n) = iter.peek() {
if crate::render::line_utils::is_code_block_painted(n) { chunk.push(iter.next().unwrap()); } else { break; }
}
segs.push(Seg::Code(chunk));
continue;
}
if text_wrap_width > 4 && is_horizontal_rule_line(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let hr = Line::from(Span::styled(
std::iter::repeat('─').take(text_wrap_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
segs.push(Seg::Bullet(vec![hr]));
_is_first_output_line = false;
continue;
}
if text_wrap_width > 4 {
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
segs.push(Seg::Bullet(wrap_bullet_line(
line,
indent_spaces,
&bullet_char,
text_wrap_width,
)));
_is_first_output_line = false;
continue;
}
}
text_buf.push(line);
_is_first_output_line = false;
}
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let mut total: u16 = 0;
for seg in segs {
match seg {
Seg::Bullet(lines) => total = total.saturating_add(lines.len() as u16),
Seg::Text(lines) => {
if lines.is_empty() { continue; }
let text = Text::from(lines);
let rows: u16 = Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(text_wrap_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(rows);
}
Seg::Code(mut chunk) => {
if let Some(first) = chunk.first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if flat.contains("⟦LANG:") { chunk.remove(0); }
}
while chunk.first().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.remove(0); }
while chunk.last().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.pop(); }
total = total.saturating_add(chunk.len() as u16 + 2);
}
}
}
let mut total = total.saturating_add(2);
let plan = self.ensure_stream_layout(width);
let mut total = plan.total_rows_with_padding;
if self.show_ellipsis { total = total.saturating_add(1); }
total
}
@@ -3209,55 +3117,18 @@ impl HistoryCell for StreamingContentCell {
}
}
// Build segments: prewrapped bullets, code (no rewrap), and normal text.
// Build or reuse cached segments for this width
let plan = self.ensure_stream_layout(area.width);
let text_wrap_width = area.width;
#[derive(Debug)]
enum Seg { Text(Vec<Line<'static>>), Bullet(Vec<Line<'static>>), Code(Vec<Line<'static>>) }
let mut segs: Vec<Seg> = Vec::new();
let mut text_buf: Vec<Line<'static>> = Vec::new();
let mut _is_first_output_line = true;
let mut iter = self.display_lines_trimmed().into_iter().peekable();
while let Some(line) = iter.next() {
if crate::render::line_utils::is_code_block_painted(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let mut chunk = vec![line];
while let Some(n) = iter.peek() { if crate::render::line_utils::is_code_block_painted(n) { chunk.push(iter.next().unwrap()); } else { break; } }
// Trim padding rows inside code card
while chunk.first().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.remove(0); }
while chunk.last().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.pop(); }
segs.push(Seg::Code(chunk));
continue;
}
if text_wrap_width > 4 && is_horizontal_rule_line(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
let hr = Line::from(Span::styled(
std::iter::repeat('─').take(text_wrap_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
segs.push(Seg::Bullet(vec![hr]));
_is_first_output_line = false;
continue;
}
if text_wrap_width > 4 {
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
segs.push(Seg::Bullet(wrap_bullet_line(
line,
indent_spaces,
&bullet_char,
text_wrap_width,
)));
_is_first_output_line = false;
continue;
}
}
text_buf.push(line);
_is_first_output_line = false;
}
if !text_buf.is_empty() { segs.push(Seg::Text(std::mem::take(&mut text_buf))); }
// Append an ellipsis line as its own segment when streaming
if self.show_ellipsis {
segs.push(Seg::Text(vec![Line::from("…".dim())]));
let mut segs = plan.segs.clone();
let mut seg_rows = plan.seg_rows.clone();
if self.show_ellipsis {
segs.push(AssistantSeg::Text(vec![Line::from("…".dim())]));
seg_rows.push(Paragraph::new(Text::from(vec![Line::from("…")]))
.wrap(Wrap { trim: false })
.line_count(text_wrap_width)
.try_into()
.unwrap_or(1));
}
// Streaming-style top padding row
@@ -3270,6 +3141,8 @@ impl HistoryCell for StreamingContentCell {
remaining_skip = remaining_skip.saturating_sub(1);
// Helpers
#[derive(Debug, Clone)]
enum Seg { Text(Vec<Line<'static>>), Bullet(Vec<Line<'static>>), Code(Vec<Line<'static>>) }
use unicode_width::UnicodeWidthStr as UW;
let measure_line =
|l: &Line<'_>| -> usize { l.spans.iter().map(|s| UW::width(s.content.as_ref())).sum() };
@@ -3423,11 +3296,22 @@ impl HistoryCell for StreamingContentCell {
}
};
for seg in &segs {
for (i, seg) in segs.iter().enumerate() {
if cur_y >= end_y {
break;
}
draw_segment(seg, &mut cur_y, &mut remaining_skip);
// Fast-skip full segments using precomputed rows
let rows = seg_rows.get(i).copied().unwrap_or(0);
if remaining_skip >= rows {
remaining_skip -= rows;
continue;
}
let seg_draw = match seg {
AssistantSeg::Text(lines) => Seg::Text(lines.clone()),
AssistantSeg::Bullet(lines) => Seg::Bullet(lines.clone()),
AssistantSeg::Code(lines) => Seg::Code(lines.clone()),
};
draw_segment(&seg_draw, &mut cur_y, &mut remaining_skip);
}
// Bottom padding row (blank): area already cleared
if remaining_skip == 0 && cur_y < end_y {
@@ -3454,6 +3338,38 @@ impl HistoryCell for StreamingContentCell {
}
}
impl StreamingContentCell {
pub(crate) fn extend_lines(&mut self, mut new_lines: Vec<Line<'static>>) {
if new_lines.is_empty() { return; }
self.lines.append(&mut new_lines);
// Invalidate cached plan so next render recomputes incrementally for current width
*self.cached_layout.borrow_mut() = None;
}
pub(crate) fn retint(&mut self, old: &crate::theme::Theme, new: &crate::theme::Theme) {
retint_lines_in_place(&mut self.lines, old, new);
*self.cached_layout.borrow_mut() = None;
}
fn ensure_stream_layout(&self, width: u16) -> AssistantLayoutCache {
if let Some(cache) = self.cached_layout.borrow().as_ref() {
if cache.width == width {
return cache.clone();
}
}
// Reuse the same segmentation logic as Assistant, operating on current lines
let tmp = AssistantMarkdownCell {
raw: String::new(),
id: None,
lines: std::iter::once(Line::from("codex")).chain(self.lines.clone()).collect(),
cached_layout: std::cell::RefCell::new(None),
};
let cache = tmp.ensure_layout(width);
*self.cached_layout.borrow_mut() = Some(cache.clone());
cache
}
}
// Detect lines that start with a markdown bullet produced by our renderer and return (indent, bullet)
fn detect_bullet_prefix(line: &ratatui::text::Line<'_>) -> Option<(usize, String)> {
// Treat these as unordered bullets, plus checkbox glyphs for task lists.
@@ -4374,14 +4290,14 @@ pub(crate) fn new_text_line(line: Line<'static>) -> PlainHistoryCell {
}
pub(crate) fn new_streaming_content(lines: Vec<Line<'static>>) -> StreamingContentCell {
StreamingContentCell { id: None, lines, show_ellipsis: true }
StreamingContentCell { id: None, lines, show_ellipsis: true, cached_layout: std::cell::RefCell::new(None) }
}
pub(crate) fn new_streaming_content_with_id(
id: Option<String>,
lines: Vec<Line<'static>>,
) -> StreamingContentCell {
StreamingContentCell { id, lines, show_ellipsis: true }
StreamingContentCell { id, lines, show_ellipsis: true, cached_layout: std::cell::RefCell::new(None) }
}
pub(crate) fn new_animated_welcome() -> AnimatedWelcomeCell {
+3 -2
View File
@@ -134,8 +134,9 @@ pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
}
}
// Wrap stdout in a BufWriter to reduce syscalls during rendering.
let backend = CrosstermBackend::new(BufWriter::new(stdout()));
// Wrap stdout in a larger BufWriter to reduce syscalls and flushes.
// A larger buffer significantly helps during heavy scrolling where many cells change.
let backend = CrosstermBackend::new(BufWriter::with_capacity(512 * 1024, stdout()));
let tui = Terminal::new(backend)?;
Ok((tui, terminal_info))
}
+2 -1
View File
@@ -13,12 +13,13 @@ use codex_core::default_client::create_client;
pub fn get_upgrade_version(config: &Config) -> Option<String> {
let version_file = version_filepath(config);
let info = read_version_info(&version_file).ok();
let originator = config.responses_originator_header.clone();
// Always refresh the cached latest version in the background so TUI startup
// isn’t blocked by a network call. The UI reads the previously cached
// value (if any) for this run; the next run shows the banner if needed.
tokio::spawn(async move {
check_for_update(&version_file)
check_for_update(&version_file, &originator)
.await
.inspect_err(|e| tracing::error!("Failed to update version: {e}"))
});
Executable
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Profile Codex with Apple's Instruments via cargo-instruments.
# Defaults to the same build profile as ./build-fast.sh (dev-fast).
set -euo pipefail
usage() {
cat <<USAGE
Usage: ./profile.sh [cli|tui] [-- program-args]
Runs Time Profiler against the requested binary using Cargo's custom
profile. By default, builds and profiles with the dev-fast profile
(same as ./build-fast.sh).
Positional:
cli Profile the main CLI binary (code) [default]
tui Profile the TUI binary (code-tui)
Env vars:
PROFILE=dev-fast|dev|release|release-prod Cargo profile (default: dev-fast)
TEMPLATE="Time Profiler" Instruments template name
Examples:
./profile.sh # build dev-fast and profile CLI
./profile.sh tui # profile TUI
PROFILE=dev ./profile.sh -- --help # pass args to program after --
USAGE
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
# Resolve repo root reliably relative to this script
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
REPO_ROOT="${SCRIPT_DIR}"
# Which target to run (cli is default)
TARGET="${1:-cli}"
if [[ "$TARGET" != "cli" && "$TARGET" != "tui" ]]; then
echo "Unknown target '$TARGET'" >&2
echo "Use 'cli' or 'tui' (or --help)." >&2
exit 2
fi
# Split off program args after --
PROG_ARGS=()
shift $(( $# > 0 ? 1 : 0 )) || true
if [[ "$#" -gt 0 ]]; then
if [[ "$1" == "--" ]]; then
shift
PROG_ARGS=("$@")
fi
fi
# Defaults that match build-fast.sh
PROFILE="${PROFILE:-dev-fast}"
TEMPLATE="${TEMPLATE:-Time Profiler}"
# Build first exactly like the normal fast path
"${REPO_ROOT}/build-fast.sh"
# Select package/bin per target
if [[ "$TARGET" == "cli" ]]; then
PKG="codex-cli"
BIN="code"
else
PKG="codex-tui"
BIN="code-tui"
fi
# Ensure cargo-instruments exists
if ! command -v cargo >/dev/null 2>&1; then
echo "cargo not found in PATH" >&2
exit 1
fi
if ! cargo instruments -V >/dev/null 2>&1; then
echo "cargo-instruments is required (brew install cargo-instruments)" >&2
exit 1
fi
# Ensure Xcode's xctrace is available (part of full Xcode, not just CLT)
if ! xcrun -f xctrace >/dev/null 2>&1; then
DEV_DIR="$(xcode-select -p 2>/dev/null || true)"
echo "xctrace not found via xcrun." >&2
echo "• Install full Xcode from the App Store (not just Command Line Tools)." >&2
echo "• If Xcode is already installed, point xcode-select at it:" >&2
echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2
echo "Current developer dir: ${DEV_DIR:-<none>}" >&2
exit 1
fi
# Check license acceptance (xctrace will fail if license not accepted)
if ! xcrun xctrace list templates >/dev/null 2>&1; then
echo "xctrace is present but failed to run. If this mentions the Xcode license," >&2
echo "accept it with: sudo xcodebuild -license accept" >&2
exit 1
fi
# Run Instruments with the selected template and profile
cd "${REPO_ROOT}/codex-rs"
echo "Profiling ${PKG}::${BIN} with profile='${PROFILE}', template='${TEMPLATE}'"
set -x
# Optional controls:
# TIME_LIMIT_MS=<millis> or TIME_LIMIT=<e.g., 10s|15000ms>
# NO_OPEN=1 to avoid launching Instruments UI
EXTRA_ARGS=()
if [[ -n "${TIME_LIMIT_MS:-}" ]]; then
EXTRA_ARGS+=(--time-limit "${TIME_LIMIT_MS}")
elif [[ -n "${TIME_LIMIT:-}" ]]; then
case "${TIME_LIMIT}" in
*ms) EXTRA_ARGS+=(--time-limit "${TIME_LIMIT%ms}") ;;
*s) EXTRA_ARGS+=(--time-limit "$(( ${TIME_LIMIT%s} * 1000 ))") ;;
*m) EXTRA_ARGS+=(--time-limit "$(( ${TIME_LIMIT%m} * 60 * 1000 ))") ;;
*h) EXTRA_ARGS+=(--time-limit "$(( ${TIME_LIMIT%h} * 60 * 60 * 1000 ))") ;;
*) # Treat plain integer as seconds
EXTRA_ARGS+=(--time-limit "$(( TIME_LIMIT * 1000 ))") ;;
esac
fi
if [[ "${NO_OPEN:-}" = "1" ]]; then
EXTRA_ARGS+=(--no-open)
fi
if ((${#PROG_ARGS[@]})); then
cargo instruments \
-t "${TEMPLATE}" \
-p "${PKG}" \
--bin "${BIN}" \
--profile "${PROFILE}" \
"${EXTRA_ARGS[@]}" \
--open -- \
"${PROG_ARGS[@]}"
else
cargo instruments \
-t "${TEMPLATE}" \
-p "${PKG}" \
--bin "${BIN}" \
--profile "${PROFILE}" \
"${EXTRA_ARGS[@]}" \
--open
fi