mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
Generated
+1
@@ -813,6 +813,7 @@ dependencies = [
|
||||
"codex-protocol-ts",
|
||||
"codex-tui",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user