feat(tui/reasoning): render expanded reasoning dim with a subtle left border; implement custom_render_with_skip for CollapsibleReasoningCell

This commit is contained in:
James Peter
2025-09-04 09:13:56 +10:00
parent d362d096e9
commit 165d52d2e6
15 changed files with 640 additions and 156 deletions
+4
View File
@@ -2,6 +2,7 @@ In this environment, you are running as `coder` and your name is Code. Code is a
Code is a fast, community-driven fork focused on key developer ergonomics: Browser control, multi-agent flows, live theming, and on-the-fly reasoning control - all while staying compatible with upstream.
# Changes
This version has a few key changes and additions.
@@ -12,6 +13,7 @@ You should provide fewer preamble messages and focus on completing the task as q
## Testing
With your additional browser tools you can validate web UI easily. For code that generates a web interface, always test with browser tools after changes and use your visual judgment to improve UX. You should always generate aesthetically pleasing interfaces with great UX.
# Tools
## Shell tools
@@ -66,11 +68,13 @@ agent_run {
}
agent_wait {"batch_id":"<batch_id>","return_all":true,"timeout_seconds": 600 } // Long timeout or you can do separate work and check back later.
# Warnings
- Do not create new branches or make changes to git unless requested.
- Before pushing, always run `git pull` to sync with remote. Prefer merge over rebase by default; avoid rebases as a first resort.
- If a rebase is explicitly required by maintainers, confirm first and proceed carefully; otherwise stick to pull/merge to prevent history churn and conflicts.
# Final output
You can include FULL markdown in any responses you make. These will be converted to beautiful output in the terminal.
+60
View File
@@ -123,6 +123,66 @@ pub struct Tui {
/// Whether to show reasoning content expanded by default (can be toggled with Ctrl+R/T)
#[serde(default)]
pub show_reasoning: bool,
/// Streaming/animation behavior for assistant/reasoning output
#[serde(default)]
pub stream: StreamConfig,
}
/// Streaming behavior configuration for the TUI.
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct StreamConfig {
/// Emit the Answer header immediately when a stream begins (before first newline).
#[serde(default)]
pub answer_header_immediate: bool,
/// Show an ellipsis placeholder in the Answer body while waiting for first text.
#[serde(default = "default_true")]
pub show_answer_ellipsis: bool,
/// Commit animation pacing in milliseconds (lines per CommitTick).
/// If unset, defaults to 50ms; in responsive profile, defaults to 30ms.
#[serde(default)]
pub commit_tick_ms: Option<u64>,
/// Soft-commit timeout (ms) when no newline arrives; commits partial content.
/// If unset, disabled; in responsive profile, defaults to 400ms.
#[serde(default)]
pub soft_commit_timeout_ms: Option<u64>,
/// Soft-commit when this many chars have streamed without a newline.
/// If unset, disabled; in responsive profile, defaults to 160 chars.
#[serde(default)]
pub soft_commit_chars: Option<usize>,
/// Relax list hold-back: allow list lines with content; only withhold bare markers.
#[serde(default)]
pub relax_list_holdback: bool,
/// Relax code hold-back: allow committing inside an open fenced code block
/// except the very last partial line.
#[serde(default)]
pub relax_code_holdback: bool,
/// Convenience switch enabling a snappier preset for the above values.
/// Explicit values above still take precedence if set.
#[serde(default)]
pub responsive: bool,
}
impl Default for StreamConfig {
fn default() -> Self {
Self {
answer_header_immediate: false,
show_answer_ellipsis: true,
commit_tick_ms: None,
soft_commit_timeout_ms: None,
soft_commit_chars: None,
relax_list_holdback: false,
relax_code_holdback: false,
responsive: false,
}
}
}
/// Theme configuration for the TUI
+208 -6
View File
@@ -3,7 +3,7 @@
use std::fs::File;
use std::fs::{self};
use std::io::Error as IoError;
use std::path::Path;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde::Serialize;
@@ -82,7 +82,7 @@ impl RolloutRecorder {
uuid: Uuid,
instructions: Option<String>,
) -> std::io::Result<Self> {
let LogFileInfo { file, session_id, timestamp } = create_log_file(config, uuid)?;
let LogFileInfo { file, session_id, timestamp, path } = create_log_file(config, uuid)?;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
@@ -92,15 +92,22 @@ impl RolloutRecorder {
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
let index_ctx = Some(IndexContext::new(
config.codex_home.clone(),
config.cwd.clone(),
path,
Some(config.model.clone()),
));
tokio::task::spawn(rollout_writer(
tokio::fs::File::from_std(file),
rx,
Some(SessionMeta { timestamp, id: session_id, instructions }),
index_ctx,
));
Ok(Self { tx })
}
pub async fn resume(path: &Path, _cwd: std::path::PathBuf) -> std::io::Result<(Self, SavedSession)> {
pub async fn resume(config: &Config, path: &Path) -> std::io::Result<(Self, SavedSession)> {
info!("Resuming rollout from {path:?}");
let text = tokio::fs::read_to_string(path).await?;
let mut lines = text.lines();
@@ -134,7 +141,18 @@ impl RolloutRecorder {
let file = std::fs::OpenOptions::new().append(true).read(true).open(path)?;
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
tokio::task::spawn(rollout_writer(tokio::fs::File::from_std(file), rx, None));
let index_ctx = Some(IndexContext::new(
config.codex_home.clone(),
config.cwd.clone(),
path.to_path_buf(),
Some(config.model.clone()),
));
tokio::task::spawn(rollout_writer(
tokio::fs::File::from_std(file),
rx,
None,
index_ctx,
));
let saved = SavedSession {
session: meta.clone(),
@@ -222,6 +240,7 @@ struct LogFileInfo {
file: File,
session_id: Uuid,
timestamp: OffsetDateTime,
path: PathBuf,
}
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
@@ -240,30 +259,79 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
let filename = format!("rollout-{date_str}-{session_id}.jsonl");
let path = dir.join(filename);
let file = std::fs::OpenOptions::new().append(true).create(true).open(&path)?;
Ok(LogFileInfo { file, session_id, timestamp })
Ok(LogFileInfo { file, session_id, timestamp, path })
}
async fn rollout_writer(
mut file: tokio::fs::File,
mut rx: mpsc::Receiver<RolloutCmd>,
mut meta: Option<SessionMeta>,
index_ctx: Option<IndexContext>,
) -> std::io::Result<()> {
if let Some(session_meta) = meta.take() {
let mut json = serde_json::to_string(&SessionMetaWithGit { meta: session_meta, git: None })?;
json.push('\n');
let _ = file.write_all(json.as_bytes()).await;
file.flush().await?;
// Write initial index line so the session appears under /resume once messages arrive
if let Some(ctx) = index_ctx.as_ref() {
let _ = append_dir_index_line(ctx, Some("0"), Some(&ctx.get_timestamp_now()), None).await;
}
}
while let Some(cmd) = rx.recv().await {
match cmd {
RolloutCmd::AddItems(items) => {
for item in items {
for item in &items {
let mut json = serde_json::to_string(&item)?;
json.push('\n');
let _ = file.write_all(json.as_bytes()).await;
}
file.flush().await?;
// Update the per-directory index with message deltas and optional last user snippet
if let Some(ctx) = index_ctx.as_ref() {
use codex_protocol::models::{ContentItem, ResponseItem};
let mut msg_count_delta: usize = 0;
let mut last_user_snippet: Option<String> = None;
for it in &items {
if let ResponseItem::Message { role, content, .. } = it {
if role == "user" || role == "assistant" {
msg_count_delta = msg_count_delta.saturating_add(1);
}
if role == "user" {
for c in content {
match c {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
if !text.trim().is_empty() {
// Keep a short, single-line snippet
let mut s = text.trim().replace('\n', " ");
const MAX: usize = 120;
if s.chars().count() > MAX {
s = s.chars().take(MAX).collect::<String>() + "…";
}
last_user_snippet = Some(s);
break;
}
}
_ => {}
}
}
}
}
}
if msg_count_delta > 0 || last_user_snippet.is_some() {
let ts = ctx.get_timestamp_now();
let _ = append_dir_index_line(ctx, None, Some(&ts), last_user_snippet.as_deref())
.await
.ok();
if msg_count_delta > 0 {
// Separate line to carry the count delta so aggregator can sum
let _ = append_dir_index_count_delta(ctx, msg_count_delta).await.ok();
}
}
}
}
RolloutCmd::UpdateState(state) => {
#[derive(Serialize)]
@@ -283,3 +351,137 @@ async fn rollout_writer(
Ok(())
}
// --- Fast per-directory index writing ---
struct IndexContext {
codex_home: PathBuf,
cwd: PathBuf,
session_path: PathBuf,
model: Option<String>,
}
impl IndexContext {
fn new(codex_home: PathBuf, cwd: PathBuf, session_path: PathBuf, model: Option<String>) -> Self {
Self { codex_home, cwd, session_path, model }
}
fn index_file_path(&self) -> PathBuf {
// Mirror tui::resume::discovery::super_sanitize_dir_index_path
let mut name = self.cwd.to_string_lossy().to_string();
name = name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if name.len() > 160 { name.truncate(160); }
let mut p = self.codex_home.clone();
p.push("sessions");
p.push("index");
p.push("by-dir");
p.push(format!("{}.jsonl", name));
p
}
fn get_timestamp_now(&self) -> String {
let now = OffsetDateTime::now_utc();
let fmt: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
now.format(fmt).unwrap_or_else(|e| format!("format error: {e}")).to_string()
}
fn git_branch(&self) -> Option<String> {
let head_path = self.cwd.join(".git/HEAD");
if let Ok(contents) = std::fs::read_to_string(&head_path) {
if let Some(rest) = contents.trim().strip_prefix("ref: ") {
if let Some(branch) = rest.trim().rsplit('/').next() {
return Some(branch.to_string());
}
}
}
None
}
}
#[derive(Serialize)]
struct DirIndexLine<'a> {
record_type: &'static str,
cwd: &'a str,
session_file: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
created_ts: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
modified_ts: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
message_count_delta: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
last_user_snippet: Option<&'a str>,
}
async fn append_dir_index_line(
ctx: &IndexContext,
created_ts: Option<&str>,
modified_ts: Option<&str>,
last_user_snippet: Option<&str>,
) -> std::io::Result<()> {
let index_path = ctx.index_file_path();
if let Some(parent) = index_path.parent() { tokio::fs::create_dir_all(parent).await.ok(); }
let mut f = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)
.await?;
let cwd_str = ctx.cwd.to_string_lossy();
let path_str = ctx.session_path.to_string_lossy();
let model_str = ctx.model.as_deref();
let branch = ctx.git_branch();
let line = DirIndexLine {
record_type: "dir_index",
cwd: &cwd_str,
session_file: &path_str,
created_ts,
modified_ts,
message_count_delta: None,
model: model_str,
branch: branch.as_deref(),
last_user_snippet,
};
let mut json = serde_json::to_string(&line)?;
json.push('\n');
let _ = f.write_all(json.as_bytes()).await;
f.flush().await
}
async fn append_dir_index_count_delta(ctx: &IndexContext, delta: usize) -> std::io::Result<()> {
let index_path = ctx.index_file_path();
if let Some(parent) = index_path.parent() { tokio::fs::create_dir_all(parent).await.ok(); }
let mut f = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)
.await?;
let cwd_str = ctx.cwd.to_string_lossy();
let path_str = ctx.session_path.to_string_lossy();
let ts = ctx.get_timestamp_now();
let model_str = ctx.model.as_deref();
let branch = ctx.git_branch();
let line = DirIndexLine {
record_type: "dir_index",
cwd: &cwd_str,
session_file: &path_str,
created_ts: None,
modified_ts: Some(&ts),
message_count_delta: Some(delta),
model: model_str,
branch: branch.as_deref(),
last_user_snippet: None,
};
let mut json = serde_json::to_string(&line)?;
json.push('\n');
let _ = f.write_all(json.as_bytes()).await;
f.flush().await
}
+70 -35
View File
@@ -99,6 +99,9 @@ pub(crate) struct App<'a> {
// Double‑Esc timing for backtrack/edit‑previous
last_esc_time: Option<Instant>,
/// If true, enable lightweight timing collection and report on exit.
timing_enabled: bool,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -111,6 +114,7 @@ pub(crate) struct ChatWidgetArgs {
enhanced_keys_supported: bool,
terminal_info: TerminalInfo,
show_order_overlay: bool,
enable_perf: bool,
}
impl App<'_> {
@@ -122,6 +126,7 @@ impl App<'_> {
debug: bool,
show_order_overlay: bool,
terminal_info: TerminalInfo,
enable_perf: bool,
) -> Self {
let conversation_manager = Arc::new(ConversationManager::new(AuthManager::shared(
config.codex_home.clone(),
@@ -145,6 +150,8 @@ impl App<'_> {
let app_event_tx = app_event_tx.clone();
let input_running_thread = input_running.clone();
std::thread::spawn(move || {
// Track recent typing to temporarily increase poll frequency for low latency.
let mut last_key_time = Instant::now();
loop {
if !input_running_thread.load(Ordering::Relaxed) { break; }
// This timeout is necessary to avoid holding the event lock
@@ -154,13 +161,16 @@ impl App<'_> {
// can't acquire it within 2 sec. Resizing the terminal
// crashes the app if the cursor position can't be read.
// Keep the timeout small to minimize input-to-echo latency.
// Poll slightly less aggressively to reduce wakeups while
// keeping input latency low. If no events are pending,
// sleep briefly to avoid a busy loop.
if let Ok(true) = crossterm::event::poll(Duration::from_millis(10)) {
// Dynamically adapt poll timeout: when the user is actively typing,
// use a very small timeout to minimize key->echo latency; otherwise
// back off to reduce CPU when idle.
let hot_typing = Instant::now().duration_since(last_key_time) <= Duration::from_millis(250);
let poll_timeout = if hot_typing { Duration::from_millis(2) } else { Duration::from_millis(10) };
if let Ok(true) = crossterm::event::poll(poll_timeout) {
if let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
last_key_time = Instant::now();
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
crossterm::event::Event::Resize(_, _) => {
@@ -191,9 +201,11 @@ impl App<'_> {
}
}
} else {
// Timeout expired, no `Event` is available; sleep briefly
// instead of yielding in a tight loop to reduce CPU at idle.
std::thread::sleep(Duration::from_millis(5));
// Timeout expired, no `Event` is available. If the user is typing
// keep the loop hot; otherwise sleep briefly to cut idle CPU.
if !hot_typing {
std::thread::sleep(Duration::from_millis(5));
}
}
}
});
@@ -211,6 +223,7 @@ impl App<'_> {
enhanced_keys_supported,
terminal_info: terminal_info.clone(),
show_order_overlay,
enable_perf,
};
AppState::Onboarding {
screen: OnboardingScreen::new(OnboardingScreenArgs {
@@ -233,6 +246,7 @@ impl App<'_> {
terminal_info.clone(),
show_order_overlay,
);
chat_widget.enable_perf(enable_perf);
// Check for initial animations after widget is created
chat_widget.check_for_initial_animations();
AppState::Chat {
@@ -261,6 +275,7 @@ impl App<'_> {
terminal_info,
clear_on_first_frame: true,
last_esc_time: None,
timing_enabled: enable_perf,
}
}
@@ -357,9 +372,16 @@ impl App<'_> {
{
let tx = self.app_event_tx.clone();
let running = self.commit_anim_running.clone();
let tick_ms: u64 = self
.config
.tui
.stream
.commit_tick_ms
.or(if self.config.tui.stream.responsive { Some(30) } else { None })
.unwrap_or(50);
thread::spawn(move || {
while running.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(50));
thread::sleep(Duration::from_millis(tick_ms));
tx.send(AppEvent::CommitTick);
}
});
@@ -621,17 +643,18 @@ impl App<'_> {
if let AppState::Chat { widget } = &mut self.app_state {
widget.new_conversation(self.enhanced_keys_supported);
} else {
// If we're not in chat state, create a new chat widget
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
));
self.app_state = AppState::Chat { widget: new_widget };
// If we're not in chat state, create a new chat widget
let mut new_widget = ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
);
new_widget.enable_perf(self.timing_enabled);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
@@ -787,7 +810,7 @@ impl App<'_> {
let mut cfg = self.config.clone();
cfg.experimental_resume = Some(path);
if let AppState::Chat { .. } = &self.app_state {
let new_widget = Box::new(ChatWidget::new(
let mut new_widget = ChatWidget::new(
cfg,
self.app_event_tx.clone(),
None,
@@ -795,8 +818,9 @@ impl App<'_> {
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
));
self.app_state = AppState::Chat { widget: new_widget };
);
new_widget.enable_perf(self.timing_enabled);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
@@ -898,18 +922,19 @@ impl App<'_> {
initial_prompt,
terminal_info,
show_order_overlay,
enable_perf,
}) => {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
config,
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
terminal_info,
show_order_overlay,
)),
}
let mut w = ChatWidget::new(
config,
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
terminal_info,
show_order_overlay,
);
w.enable_perf(enable_perf);
self.app_state = AppState::Chat { widget: Box::new(w) }
}
AppEvent::StartFileSearch(query) => {
if !query.is_empty() {
@@ -978,7 +1003,7 @@ impl App<'_> {
// Replace widget with a new one bound to the forked conversation
let session_conf = new_conv.0.session_configured.clone();
let conv = new_conv.0.conversation.clone();
let new_widget = ChatWidget::new_from_existing(
let mut new_widget = ChatWidget::new_from_existing(
cfg,
conv,
session_conf,
@@ -987,6 +1012,7 @@ impl App<'_> {
self.terminal_info.clone(),
self.show_order_overlay,
);
new_widget.enable_perf(self.timing_enabled);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
@@ -1025,7 +1051,7 @@ impl App<'_> {
loop {
if let Ok(ev) = self.app_event_rx_high.try_recv() { return Some(ev); }
if let Ok(ev) = self.app_event_rx_bulk.try_recv() { return Some(ev); }
match self.app_event_rx_high.recv_timeout(Duration::from_millis(20)) {
match self.app_event_rx_high.recv_timeout(Duration::from_millis(10)) {
Ok(ev) => return Some(ev),
Err(Timeout) => continue,
Err(Disconnected) => break,
@@ -1065,6 +1091,15 @@ impl App<'_> {
usage
}
/// Return a human-readable performance summary if timing was enabled.
pub(crate) fn perf_summary(&self) -> Option<String> {
if !self.timing_enabled { return None; }
match &self.app_state {
AppState::Chat { widget } => Some(widget.perf_summary()),
AppState::Onboarding { .. } => None,
}
}
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
if self.clear_on_first_frame || matches!(self.app_state, AppState::Onboarding { .. }) {
terminal.clear()?;
@@ -161,7 +161,8 @@ impl BottomPaneView<'_> for ListSelectionView {
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::light_blue()))
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()))
.title(self.title.clone())
.title_alignment(Alignment::Center);
let inner = block.inner(area);
@@ -224,14 +225,16 @@ impl BottomPaneView<'_> for ListSelectionView {
// Left pad footer by one column
let footer_area = Rect { x: inner.x.saturating_add(1), y: inner.y + inner.height - 1, width: inner.width.saturating_sub(1), height: 1 };
let line = Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("↑↓", Style::default().fg(crate::colors::function())),
Span::styled(" Navigate ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Select "),
Span::styled(" Select ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Cancel"),
Span::styled(" Cancel", Style::default().fg(crate::colors::text_dim())),
]);
Paragraph::new(line).render(footer_area, buf);
Paragraph::new(line)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.render(footer_area, buf);
}
}
}
@@ -135,13 +135,14 @@ impl<'a> BottomPaneView<'a> for ReasoningSelectionView {
}
fn render(&self, area: Rect, buf: &mut Buffer) {
// Clear the area first
// Clear the area and render a theme-aware box
Clear.render(area, buf);
// Create a centered box
// Bordered container that matches the current theme
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::light_blue()))
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()))
.title(" Select Reasoning Effort ")
.title_alignment(Alignment::Center);
@@ -151,7 +152,7 @@ impl<'a> BottomPaneView<'a> for ReasoningSelectionView {
// Build the content
let mut lines = vec![
Line::from(vec![
Span::raw("Current: "),
Span::styled("Current: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
format!("{}", self.current_effort),
Style::default()
@@ -167,7 +168,7 @@ impl<'a> BottomPaneView<'a> for ReasoningSelectionView {
let is_selected = effort == self.selected_effort;
let is_current = effort == self.current_effort;
let mut style = Style::default();
let mut style = Style::default().fg(crate::colors::text());
if is_selected {
style = style.bg(crate::colors::selection()).add_modifier(Modifier::BOLD);
}
@@ -201,7 +202,9 @@ impl<'a> BottomPaneView<'a> for ReasoningSelectionView {
width: inner_area.width.saturating_sub(1),
height: inner_area.height,
};
let paragraph = Paragraph::new(lines).alignment(Alignment::Left);
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
paragraph.render(padded, buf);
}
}
@@ -115,7 +115,8 @@ pub(crate) fn render_rows(
}
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.widths([Constraint::Percentage(100)]);
.widths([Constraint::Percentage(100)])
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
table.render(area, buf);
}
+2
View File
@@ -329,6 +329,8 @@ impl std::fmt::Display for StreamId { fn fmt(&self, f: &mut std::fmt::Formatter<
impl AsRef<str> for StreamId { fn as_ref(&self) -> &str { &self.0 } }
impl ChatWidget<'_> {
pub(crate) fn enable_perf(&mut self, enable: bool) { self.perf_state.enabled = enable; }
pub(crate) fn perf_summary(&self) -> String { self.perf_state.stats.borrow().summary() }
// Build an ordered key from model-provided OrderMeta.
// Layout: [63: ordered flag (0 = ordered, 1 = unordered)] [62..48: request_ordinal (16b)] [47..32: output_index (16b)] [31..0: sequence_number (32b)]
fn order_key_from_order_meta(order: Option<&codex_core::protocol::OrderMeta>) -> Option<OrderKey> {
+6
View File
@@ -70,6 +70,12 @@ pub struct Cli {
#[arg(long = "order", default_value_t = false)]
pub order: bool,
/// Enable lightweight in-app timing and print a summary report on exit.
/// This records render/measurement hotspots while the UI runs and writes a
/// short report to stderr when the program exits.
#[arg(long = "timing", default_value_t = false)]
pub timing: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
+73 -3
View File
@@ -3023,6 +3023,68 @@ impl HistoryCell for CollapsibleReasoningCell {
// No gutter icon for reasoning/thinking
None
}
fn has_custom_render(&self) -> bool { true }
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
// Collapsed path: simple paragraph (titles only), already dimmed by extract_section_titles
if self.collapsed.get() {
// Clear background
let bg_style = Style::default().bg(crate::colors::background()).fg(crate::colors::text());
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
let lines = self.display_lines_trimmed();
Paragraph::new(Text::from(lines))
.block(Block::default().style(Style::default().bg(crate::colors::background())))
.wrap(Wrap { trim: false })
.scroll((skip_rows, 0))
.style(Style::default().bg(crate::colors::background()))
.render(area, buf);
return;
}
// Expanded path: render full content with a subtle LEFT border and force dim color.
let dim = crate::colors::text_dim();
// Normalize and recolor all spans to dim so headings remain bold but not bright.
let mut lines = self.normalized_lines();
for line in &mut lines {
line.spans = line
.spans
.iter()
.map(|s| s.clone().style(Style::default().fg(dim)))
.collect();
// Also patch any line-level style fg so empty-span lines are dim
line.style = line.style.patch(Style::default().fg(dim));
}
let text = Text::from(trim_empty_lines(lines));
// Clear area
let bg = crate::colors::background();
let bg_style = Style::default().bg(bg).fg(dim);
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
// Left border like exec output: 1px border + 1px left padding
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(crate::colors::border_dim()).bg(bg))
.style(Style::default().bg(bg))
.padding(Padding { left: 1, right: 0, top: 0, bottom: 0 });
// Scroll across wrapped content considering reduced inner width (area.width - 2)
Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((skip_rows, 0))
.style(Style::default().bg(bg).fg(dim))
.render(area, buf);
}
}
// ==================== StreamingContentCell ====================
@@ -3031,6 +3093,8 @@ impl HistoryCell for CollapsibleReasoningCell {
pub(crate) struct StreamingContentCell {
pub(crate) id: Option<String>,
pub(crate) lines: Vec<Line<'static>>,
// Show an ellipsis on a new line while streaming is in progress
pub(crate) show_ellipsis: bool,
}
impl HistoryCell for StreamingContentCell {
@@ -3129,7 +3193,9 @@ impl HistoryCell for StreamingContentCell {
}
}
}
total.saturating_add(2)
let mut total = total.saturating_add(2);
if self.show_ellipsis { total = total.saturating_add(1); }
total
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
// Render with a 1-row top and bottom padding, all using the assistant bg tint.
@@ -3189,6 +3255,10 @@ impl HistoryCell for StreamingContentCell {
_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())]));
}
// Streaming-style top padding row
let mut remaining_skip = skip_rows;
@@ -4280,14 +4350,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 }
StreamingContentCell { id: None, lines, show_ellipsis: true }
}
pub(crate) fn new_streaming_content_with_id(
id: Option<String>,
lines: Vec<Line<'static>>,
) -> StreamingContentCell {
StreamingContentCell { id, lines }
StreamingContentCell { id, lines, show_ellipsis: true }
}
pub(crate) fn new_animated_welcome() -> AnimatedWelcomeCell {
+14
View File
@@ -344,6 +344,7 @@ fn run_ratatui_app(
images,
debug,
order,
timing,
..
} = cli;
let mut app = App::new(
@@ -354,14 +355,22 @@ fn run_ratatui_app(
debug,
order,
terminal_info,
timing,
);
let app_result = app.run(&mut terminal);
let usage = app.token_usage();
// Optionally print timing summary to stderr after restoring the terminal.
let timing_summary = app.perf_summary();
restore();
// Mark the end of the recorded session.
session_log::log_session_end();
if let Some(summary) = timing_summary {
print_timing_summary(&summary);
}
// ignore error when collecting usage – report underlying error instead
app_result.map(|_| usage)
}
@@ -378,6 +387,11 @@ fn restore() {
}
}
#[allow(clippy::print_stderr)]
fn print_timing_summary(summary: &str) {
eprintln!("\n== Timing Summary ==\n{}", summary);
}
/// Minimal login status indicator for onboarding flow.
#[derive(Debug, Clone, Copy)]
pub enum LoginStatus {
+71
View File
@@ -216,6 +216,58 @@ impl MarkdownStreamCollector {
out
}
/// Soft-commit: emit newly rendered lines since the last commit, allowing the
/// trailing (incomplete) line when appropriate to improve perceived latency.
///
/// - If `relax_code_holdback` is true and we're inside an unclosed fence,
/// allow committing but drop the very last partial line to avoid jitter.
/// - If `relax_list_holdback` is true, only withhold truly bare list markers
/// at the tail (e.g., "-", "- ", "*", "* ", or "1.").
pub fn commit_soft_lines(
&mut self,
config: &Config,
relax_list_holdback: bool,
relax_code_holdback: bool,
) -> Vec<Line<'static>> {
let source = unwrap_markdown_language_fence_if_enabled(self.buffer.clone());
let source = strip_empty_fenced_code_blocks(&source);
let mut rendered: Vec<Line<'static>> = Vec::new();
if self.bold_first_sentence {
markdown::append_markdown_with_bold_first(&source, &mut rendered, config);
} else {
markdown::append_markdown(&source, &mut rendered, config);
}
if self.committed_line_count >= rendered.len() {
return Vec::new();
}
let in_open_fence = is_inside_unclosed_fence(&source);
if in_open_fence && !relax_code_holdback {
return Vec::new();
}
let mut end = rendered.len();
if in_open_fence && relax_code_holdback {
end = end.saturating_sub(1); // avoid partial last code line
}
if relax_list_holdback && end > self.committed_line_count {
let last = &rendered[end - 1];
let mut s = String::new();
for sp in &last.spans { s.push_str(&sp.content); }
if is_bare_list_marker(&s) {
end = end.saturating_sub(1);
}
}
if end <= self.committed_line_count {
return Vec::new();
}
let out = rendered[self.committed_line_count..end].to_vec();
self.committed_line_count = end;
out
}
/// Finalize the stream: emit all remaining lines beyond the last commit.
/// If the buffer does not end with a newline, a temporary one is appended
/// for rendering. Optionally unwraps ```markdown language fences in
@@ -288,6 +340,25 @@ fn is_potentially_volatile_list_line(text: &str) -> bool {
false
}
#[inline]
fn is_bare_list_marker(text: &str) -> bool {
let t = text.trim();
if t == "-" || t == "-" || t == "*" || t == "*" { return true; }
if t == "-" || t == "- " || t == "*" || t == "* " { return true; }
// ordered like "1." possibly followed by a single space
let mut it = t.chars().peekable();
let mut saw_digit = false;
while let Some(&ch) = it.peek() {
if ch.is_ascii_digit() { saw_digit = true; it.next(); } else { break; }
}
if !saw_digit { return false; }
if it.peek() == Some(&'.') {
it.next();
return it.peek().is_none() || it.peek() == Some(&' ');
}
false
}
#[inline]
fn starts_with_list_marker(text: &str) -> bool {
let t = text.trim_start();
+5 -90
View File
@@ -1,5 +1,4 @@
use serde::Deserialize;
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
@@ -16,17 +15,7 @@ pub struct ResumeCandidate {
pub snippet: Option<String>,
}
#[derive(Deserialize, Default)]
struct MetaLine {
#[serde(default)]
id: Option<String>,
#[serde(default)]
timestamp: Option<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
model: Option<String>,
}
// No fallback scan: meta parsing for rollout headers no longer needed here.
/// Return rollout files under ~/.codex/sessions matching the provided cwd.
/// Reads only the first line of each file to avoid heavy IO.
@@ -34,26 +23,11 @@ pub fn list_sessions_for_cwd(cwd: &Path, codex_home: &Path) -> Vec<ResumeCandida
// First: try per-directory index
if let Some(mut v) = read_dir_index(codex_home, cwd) {
v.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
if !v.is_empty() {
return v.into_iter().take(200).collect();
}
// If the index file exists but is empty or has only irrelevant rows,
// fall through to on-disk scan to avoid hiding recent sessions.
return v.into_iter().take(200).collect();
}
// Fallback: scan rollouts
let mut out: Vec<ResumeCandidate> = Vec::new();
let mut sessions_dir = codex_home.to_path_buf();
sessions_dir.push("sessions");
let target = match cwd.canonicalize() { Ok(p) => p, Err(_) => cwd.to_path_buf() };
let paths = walk_sessions(&sessions_dir);
for path in paths {
if let Some(candidate) = read_first_line(&path).and_then(|v| build_candidate(&path, v, &target)) {
out.push(candidate);
}
}
out.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
out.into_iter().take(200).collect()
// No index found for this directory
Vec::new()
}
#[derive(Deserialize)]
@@ -134,63 +108,4 @@ fn super_sanitize_dir_index_path(codex_home: &Path, cwd: &Path) -> PathBuf {
p
}
fn walk_sessions(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if !root.exists() { return files; }
let years = match fs::read_dir(root) { Ok(r) => r, Err(_) => return files };
for y in years.flatten() {
if !y.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let months = match fs::read_dir(y.path()) { Ok(r) => r, Err(_) => continue };
for m in months.flatten() {
if !m.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let days = match fs::read_dir(m.path()) { Ok(r) => r, Err(_) => continue };
for d in days.flatten() {
if !d.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let entries = match fs::read_dir(d.path()) { Ok(r) => r, Err(_) => continue };
for e in entries.flatten() {
if let Some(name) = e.file_name().to_str() {
if name.starts_with("rollout-") && name.ends_with(".jsonl") {
files.push(e.path());
}
}
}
}
}
}
files
}
fn read_first_line(path: &Path) -> Option<Value> {
let f = fs::File::open(path).ok()?;
let mut reader = BufReader::new(f);
let mut first = String::new();
let _ = reader.read_line(&mut first).ok()?;
serde_json::from_str::<Value>(&first).ok()
}
fn build_candidate(path: &Path, v: Value, target_cwd: &Path) -> Option<ResumeCandidate> {
// The first line is SessionMetaWithGit – meta fields flattened, plus optional git.
let meta: MetaLine = serde_json::from_value(v.clone()).ok()?;
// Filter by cwd (abs).
if let Some(cwd_str) = meta.cwd.as_ref() {
let same_cwd = Path::new(cwd_str).canonicalize().ok().as_deref() == Some(target_cwd);
if !same_cwd { return None; }
} else {
// If cwd missing, skip (old file). Could include later with relaxed rules.
return None;
}
// Build fields for UI (title + subtitle)
let ts = meta.timestamp.unwrap_or_default();
let _model = meta.model.unwrap_or_default();
let id = meta.id.unwrap_or_default();
let subtitle = Some(truncate_middle(&id, 12));
Some(ResumeCandidate { path: path.to_path_buf(), subtitle, sort_key: ts.clone(), created_ts: Some(ts), modified_ts: None, message_count: 0, branch: None, snippet: None })
}
fn truncate_middle(s: &str, max: usize) -> String {
if s.len() <= max { return s.to_string(); }
let half = max / 2;
format!("{}…{}", &s[..half], &s[s.len()-half..])
}
// Removed fallback slow scan; the fast per-directory index is authoritative.
+99 -9
View File
@@ -204,12 +204,19 @@ impl StreamController {
if prev.is_some() {
self.header.reset_for_stream(kind);
}
// Emit header immediately for reasoning; for answers, defer to first commit.
if matches!(kind, StreamKind::Reasoning) {
// Emit header immediately for reasoning; for answers, optionally emit immediately.
if matches!(kind, StreamKind::Reasoning)
|| (matches!(kind, StreamKind::Answer) && self.config.tui.stream.answer_header_immediate)
{
let mut header_lines = Vec::new();
if self.emit_header_if_needed(kind, &mut header_lines) {
sink.insert_history(header_lines);
self.thinking_placeholder_shown = true;
// For answers, optionally insert an empty streaming cell with a hidden header so
// the UI can show a body placeholder (ellipsis) before the first text arrives.
if matches!(kind, StreamKind::Answer) && self.config.tui.stream.show_answer_ellipsis {
sink.insert_history_with_kind(self.current_stream_id.clone(), kind, vec![ratatui::text::Line::from("codex")]);
}
}
}
}
@@ -232,14 +239,17 @@ impl StreamController {
// Check header flag before borrowing state (used only to avoid double headers)
let _just_emitted_header = self.header.consume_header_flag();
let state = self.state_mut(kind);
// Record that at least one delta was received for this stream
if !delta.is_empty() {
state.has_seen_delta = true;
// Mutate collector and counters in a short scope to avoid long mutable borrows.
{
let state = self.state_mut(kind);
if !delta.is_empty() {
state.has_seen_delta = true;
}
state.collector.push_delta(delta);
state.tail_chars_since_commit = state.tail_chars_since_commit.saturating_add(delta.len());
}
state.collector.push_delta(delta);
if delta.contains('\n') {
let mut newly_completed = state.collector.commit_complete_lines(&cfg);
let mut newly_completed = self.state_mut(kind).collector.commit_complete_lines(&cfg);
// Reduce leading blanks to at most one across commits
if !newly_completed.is_empty() {
let mut skip_count = 0;
@@ -269,10 +279,52 @@ impl StreamController {
}
let count = styled.len();
tracing::debug!("stream.commit {:?} newly_completed_lines={}", kind, count);
state.enqueue(styled);
{
let state = self.state_mut(kind);
state.enqueue(styled);
state.last_commit_instant = Some(std::time::Instant::now());
state.tail_chars_since_commit = 0;
}
sink.start_commit_animation();
}
}
// Char-threshold soft commit (when no newline has arrived for a while)
if !delta.contains('\n') {
let threshold = self.config.tui.stream.soft_commit_chars
.or(if self.config.tui.stream.responsive { Some(160) } else { None });
if let Some(limit) = threshold {
let ready = { self.state(kind).tail_chars_since_commit >= limit };
if ready {
let relax_list = self.config.tui.stream.relax_list_holdback;
let relax_code = self.config.tui.stream.relax_code_holdback;
let cfg2 = self.config.clone();
let mut newly_completed = {
let state = self.state_mut(kind);
state.collector.commit_soft_lines(&cfg2, relax_list, relax_code)
};
if !newly_completed.is_empty() {
// Apply stream-specific color
let color = match kind {
StreamKind::Reasoning => Some(crate::colors::text_dim()),
StreamKind::Answer => Some(crate::colors::text_bright()),
};
let mut styled: Vec<Line<'static>> = Vec::with_capacity(newly_completed.len());
for mut line in newly_completed.drain(..) {
if let Some(c) = color { line.style = line.style.patch(ratatui::style::Style::default().fg(c)); }
styled.push(line);
}
{
let state = self.state_mut(kind);
state.enqueue(styled);
state.last_commit_instant = Some(std::time::Instant::now());
state.tail_chars_since_commit = 0;
}
sink.start_commit_animation();
}
}
}
}
}
/// Insert a reasoning section break and commit any newly completed lines.
@@ -433,6 +485,44 @@ impl StreamController {
let Some(kind) = self.current_stream else {
return false;
};
// Timeout-based soft commit: if no newline arrived and nothing is queued, force a soft commit.
let timeout_ms = self.config.tui.stream.soft_commit_timeout_ms
.or(if self.config.tui.stream.responsive { Some(400) } else { None });
if let Some(ms) = timeout_ms {
let queue_empty = self.state(kind).is_idle();
let overdue = self
.state(kind)
.last_commit_instant
.map(|t| t.elapsed() >= std::time::Duration::from_millis(ms))
.unwrap_or(false);
if queue_empty && overdue {
let relax_list = self.config.tui.stream.relax_list_holdback;
let relax_code = self.config.tui.stream.relax_code_holdback;
let cfg2 = self.config.clone();
let mut newly_completed = {
let state = self.state_mut(kind);
state.collector.commit_soft_lines(&cfg2, relax_list, relax_code)
};
if !newly_completed.is_empty() {
let color = match kind {
StreamKind::Reasoning => Some(crate::colors::text_dim()),
StreamKind::Answer => Some(crate::colors::text_bright()),
};
let mut styled: Vec<Line<'static>> = Vec::with_capacity(newly_completed.len());
for mut line in newly_completed.drain(..) {
if let Some(c) = color { line.style = line.style.patch(ratatui::style::Style::default().fg(c)); }
styled.push(line);
}
{
let state = self.state_mut(kind);
state.enqueue(styled);
state.last_commit_instant = Some(std::time::Instant::now());
state.tail_chars_since_commit = 0;
}
sink.start_commit_animation();
}
}
}
let step = {
let state = self.state_mut(kind);
state.step()
+8
View File
@@ -14,6 +14,8 @@ pub(crate) struct StreamState {
pub(crate) collector: MarkdownStreamCollector,
pub(crate) streamer: AnimatedLineStreamer,
pub(crate) has_seen_delta: bool,
pub(crate) last_commit_instant: Option<std::time::Instant>,
pub(crate) tail_chars_since_commit: usize,
}
impl StreamState {
@@ -22,6 +24,8 @@ impl StreamState {
collector: MarkdownStreamCollector::new(),
streamer: AnimatedLineStreamer::new(),
has_seen_delta: false,
last_commit_instant: None,
tail_chars_since_commit: 0,
}
}
@@ -35,6 +39,8 @@ impl StreamState {
collector,
streamer: AnimatedLineStreamer::new(),
has_seen_delta: false,
last_commit_instant: None,
tail_chars_since_commit: 0,
}
}
pub(crate) fn clear(&mut self) {
@@ -42,6 +48,8 @@ impl StreamState {
self.collector.clear();
self.streamer.clear();
self.has_seen_delta = false;
self.last_commit_instant = None;
self.tail_chars_since_commit = 0;
}
pub(crate) fn step(&mut self) -> crate::markdown_stream::StepResult {
self.streamer.step()