mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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..])
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod discovery;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user