mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/reasoning): render expanded reasoning dim with a subtle left border; implement custom_render_with_skip for CollapsibleReasoningCell
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user