Diff wrapping: add one extra space to continuation hang indent\n\n- Continuation lines now reserve sign column + trailing space before content\n- Aligns wrapped rows exactly under the first-line content

This commit is contained in:
James Peter
2025-08-25 11:01:04 +10:00
parent 83a02326df
commit bee040acdc
14 changed files with 282 additions and 4 deletions
+12 -2
View File
@@ -1508,9 +1508,9 @@ async fn submission_loop(
// Patch restored state into the newly created session.
if let Some(sess_arc) = &sess {
if restored_items.is_some() {
if let Some(items) = &restored_items {
let mut st = sess_arc.state.lock().unwrap();
st.history.record_items(restored_items.unwrap().iter());
st.history.record_items(items.iter());
}
}
@@ -1534,6 +1534,16 @@ async fn submission_loop(
error!("failed to send event: {e:?}");
}
}
// If we resumed from a rollout, replay the prior transcript into the UI.
if let Some(items) = restored_items {
let event = Event {
id: sub.id.clone(),
msg: EventMsg::ReplayHistory(crate::protocol::ReplayHistoryEvent { items }),
};
if let Err(e) = tx_event.send(event).await {
warn!("failed to send ReplayHistory event: {e}");
}
}
// Initialize agent manager after SessionConfigured is sent
if !agent_manager_initialized {
+13
View File
@@ -463,6 +463,11 @@ pub enum EventMsg {
/// Notification that the agent is shutting down.
ShutdownComplete,
/// Replay a previously recorded transcript into the UI.
/// Used after resuming from a rollout file so the user sees the full
/// history for that session without re-executing any actions.
ReplayHistory(ReplayHistoryEvent),
}
// Individual event payload types matching each `EventMsg` variant.
@@ -519,6 +524,14 @@ pub struct FinalOutput {
pub token_usage: TokenUsage,
}
/// Payload for `ReplayHistory` containing prior `ResponseItem`s.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ReplayHistoryEvent {
/// Items to render in order. Front-ends should render these as static
/// history without triggering any tool execution.
pub items: Vec<codex_protocol::models::ResponseItem>,
}
impl From<TokenUsage> for FinalOutput {
fn from(token_usage: TokenUsage) -> Self {
Self { token_usage }
+5
View File
@@ -31,6 +31,10 @@ pub struct SessionMeta {
pub id: Uuid,
pub timestamp: String,
pub instructions: Option<String>,
/// Absolute working directory for the session. Optional for back-compat
/// with older rollout files that did not record this information.
#[serde(default)]
pub cwd: Option<String>,
}
#[derive(Serialize)]
@@ -114,6 +118,7 @@ impl RolloutRecorder {
timestamp,
id: session_id,
instructions,
cwd: Some(config.cwd.to_string_lossy().to_string()),
}),
cwd,
));
@@ -530,6 +530,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::GetHistoryEntryResponse(_) => {
// Currently ignored in exec output.
}
EventMsg::ReplayHistory(_) => {
// Replay is a TUI concern; ignore in headless output
}
EventMsg::BrowserScreenshotUpdate(_) => {
// Currently ignored in exec output.
}
@@ -274,6 +274,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::WebSearchBegin(_)
| EventMsg::WebSearchComplete(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::ReplayHistory(_)
| EventMsg::PlanUpdate(_)
| EventMsg::BrowserScreenshotUpdate(_)
| EventMsg::AgentStatusUpdate(_)
+22
View File
@@ -433,6 +433,11 @@ impl App<'_> {
};
match command {
SlashCommand::Resume => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_resume_picker();
}
}
SlashCommand::New => {
// Clear the current conversation and start fresh
if let AppState::Chat { widget } = &mut self.app_state {
@@ -598,6 +603,23 @@ impl App<'_> {
}
}
}
AppEvent::ResumeFrom(path) => {
// Replace the current chat widget with a new one configured to resume
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(
cfg,
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
AppEvent::PrepareAgents => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.prepare_agents();
+3
View File
@@ -103,4 +103,7 @@ pub(crate) enum AppEvent {
/// Chrome launch option selected by user
ChromeLaunchOptionSelected(ChromeLaunchOption, Option<u16>),
/// Start a new chat session by resuming from the given rollout file
ResumeFrom(std::path::PathBuf),
}
+29
View File
@@ -25,6 +25,7 @@ mod reasoning_selection_view;
mod scroll_state;
mod selection_popup_common;
pub mod list_selection_view;
use list_selection_view::{ListSelectionView, SelectionItem, SelectionAction};
mod textarea;
mod theme_selection_view;
mod verbosity_selection_view;
@@ -389,6 +390,34 @@ impl BottomPane<'_> {
self.request_redraw()
}
/// Show the resume selection UI given a list of (rows, path) tuples.
pub fn show_resume_selection(
&mut self,
title: String,
subtitle: Option<String>,
entries: Vec<(String, Option<String>, std::path::PathBuf)>,
) {
let items: Vec<SelectionItem> = entries
.into_iter()
.map(|(name, description, path)| SelectionItem {
name,
description,
is_current: false,
actions: vec![Box::new(move |tx: &AppEventSender| tx.send(AppEvent::ResumeFrom(path.clone()))) as SelectionAction],
})
.collect();
let view = ListSelectionView::new(
title,
subtitle,
Some("Enter: Select Esc: Cancel".to_string()),
items,
self.app_event_tx.clone(),
);
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw()
}
/// Height (terminal rows) required by the current bottom pane.
pub(crate) fn request_redraw(&self) {
self.app_event_tx.send(AppEvent::RequestRedraw)
+77
View File
@@ -70,6 +70,7 @@ use crate::history_cell;
use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use codex_protocol::models::{ContentItem, ResponseItem};
use crate::history_cell::PatchEventType;
use crate::live_wrap::RowBuilder;
use crate::user_approval_widget::ApprovalRequest;
@@ -458,6 +459,75 @@ impl ChatWidget<'_> {
HistoryCellType::Loading => "Loading".to_string(),
}
}
pub(crate) fn show_resume_picker(&mut self) {
// Discover candidates
let cwd = self.config.cwd.clone();
let codex_home = self.config.codex_home.clone();
let candidates = crate::resume::discovery::list_sessions_for_cwd(&cwd, &codex_home);
if candidates.is_empty() {
self.add_to_history(crate::history_cell::new_background_event(
"No past sessions found for this folder".to_string(),
));
return;
}
// Convert to simple rows
let items: Vec<(String, Option<String>, std::path::PathBuf)> = candidates
.into_iter()
.map(|c| (c.title, c.subtitle, c.path))
.collect();
let title = "Resume Session".to_string();
let subtitle = Some(format!("{}", cwd.display()));
self.bottom_pane.show_resume_selection(title, subtitle, items);
}
/// Render a single recorded ResponseItem into history without executing tools
fn render_replay_item(&mut self, item: ResponseItem) {
match item {
ResponseItem::Message { role, content, .. } => {
let mut text = String::new();
for c in content {
match c {
ContentItem::OutputText { text: t }
| ContentItem::InputText { text: t } => {
if !text.is_empty() { text.push('\n'); }
text.push_str(&t);
}
_ => {}
}
}
if role == "user" {
self.add_to_history(crate::history_cell::new_user_prompt(text));
} else {
// Build a PlainHistoryCell with Assistant kind; header line hidden by renderer
use crate::history_cell::{PlainHistoryCell, HistoryCellType};
let mut lines = Vec::new();
lines.push(ratatui::text::Line::from("assistant"));
for l in text.lines() { lines.push(ratatui::text::Line::from(l.to_string())); }
self.add_to_history(PlainHistoryCell { lines, kind: HistoryCellType::Assistant });
}
}
ResponseItem::Reasoning { summary, .. } => {
for s in summary {
if let codex_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } = s {
// Reasoning cell – use the existing reasoning output styling
let sink = crate::streaming::controller::AppEventHistorySink(self.app_event_tx.clone());
self.current_stream_kind = Some(crate::streaming::StreamKind::Reasoning);
let _ = self.stream.apply_final_reasoning(&text, &sink);
// finalize immediately for static replay
self.stream.finalize(crate::streaming::StreamKind::Reasoning, true, &sink);
}
}
}
ResponseItem::FunctionCallOutput { output, .. } => {
// Show as a background event for now
self.add_to_history(crate::history_cell::new_background_event(output.content));
}
_ => {
// Ignore other item kinds for replay (tool calls, etc.)
}
}
}
/// Trigger fade on the welcome cell when the composer expands (e.g., slash popup).
pub(crate) fn on_composer_expanded(&mut self) {
for cell in &self.history_cells {
@@ -2263,6 +2333,13 @@ impl ChatWidget<'_> {
self.last_assistant_message = Some(message);
self.mark_needs_redraw();
}
EventMsg::ReplayHistory(ev) => {
// Render prior transcript items statically without executing tools
for item in ev.items {
self.render_replay_item(item);
}
self.request_redraw();
}
EventMsg::WebSearchComplete(ev) => {
// Replace the specific running web search cell with a completed one
if let Some((idx, maybe_query)) = self.running_web_search.remove(&ev.call_id) {
+5 -2
View File
@@ -354,7 +354,10 @@ fn push_wrapped_diff_line_with_width(
// at a UTF-8 character boundary so this row's chunk fits exactly.
// First line includes a visible sign plus a trailing space after it.
// Continuation lines include only the hanging space (no sign).
let base_prefix = if first { prefix_cols + 2 } else { prefix_cols + 1 + continuation_indent };
// First line reserves 1 col for the sign ('+'/'-') and 1 space after it.
// Continuation lines must reserve BOTH columns as well (sign column + its trailing space)
// before applying the hanging indent equal to the content's leading spaces.
let base_prefix = if first { prefix_cols + 2 } else { prefix_cols + 2 + continuation_indent };
let available_content_cols = term_cols
.saturating_sub(base_prefix)
.max(1);
@@ -401,7 +404,7 @@ fn push_wrapped_diff_line_with_width(
} else {
// Continuation lines keep a space for the sign column so content aligns
let hang_prefix = format!(
"{indent}{}{} {}",
"{indent}{}{} {}",
" ".repeat(ln_str.len()),
" ".repeat(gap_after_ln),
" ".repeat(continuation_indent)
+1
View File
@@ -48,6 +48,7 @@ mod render;
mod session_log;
mod shimmer;
mod slash_command;
mod resume;
mod streaming;
mod terminal_info;
// mod text_block; // Orphaned after trait-based HistoryCell migration
+107
View File
@@ -0,0 +1,107 @@
use serde::Deserialize;
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
/// One candidate session for the picker
pub struct ResumeCandidate {
pub path: PathBuf,
pub title: String,
pub subtitle: Option<String>,
pub sort_key: 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>,
}
/// Return rollout files under ~/.codex/sessions matching the provided cwd.
/// Reads only the first line of each file to avoid heavy IO.
pub fn list_sessions_for_cwd(cwd: &Path, codex_home: &Path) -> Vec<ResumeCandidate> {
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);
}
}
// Sort by timestamp descending (string compare works for RFC3339-like format)
out.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
out.into_iter().take(200).collect()
}
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 title = format!("{} • {}", ts, model);
let subtitle = Some(truncate_middle(&id, 12));
Some(ResumeCandidate { path: path.to_path_buf(), title, subtitle, sort_key: ts })
}
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..])
}
+2
View File
@@ -0,0 +1,2 @@
pub mod discovery;
+2
View File
@@ -25,6 +25,7 @@ pub enum SlashCommand {
Verbosity,
Prompts,
Perf,
Resume,
// Prompt-expanding commands
Plan,
Solve,
@@ -41,6 +42,7 @@ impl SlashCommand {
match self {
SlashCommand::Chrome => "connect to Chrome",
SlashCommand::Browser => "open internal browser",
SlashCommand::Resume => "resume a past session for this folder",
SlashCommand::Plan => "create a comprehensive plan (multiple agents)",
SlashCommand::Solve => "solve a challenging problem (multiple agents)",
SlashCommand::Code => "perform a coding task (multiple agents)",