Compare commits

...

6 Commits

Author SHA1 Message Date
Yaroslav Volovich
816b339e27 Document /fork turn picker behavior 2026-02-25 19:29:30 +00:00
Yaroslav Volovich
139c8cc2c1 Compact /fork picker selected preview 2026-02-25 19:29:30 +00:00
Yaroslav Volovich
a6ab77bca2 Pad /fork picker turn labels 2026-02-25 19:29:30 +00:00
Yaroslav Volovich
81ef5ff08e Use natural numbering in /fork picker 2026-02-25 19:29:30 +00:00
Yaroslav Volovich
c621fcf2a6 Refine /fork turn picker previews 2026-02-25 19:29:30 +00:00
Yaroslav Volovich
fa8de1bde5 Add TUI /fork turn picker 2026-02-25 19:29:30 +00:00
4 changed files with 779 additions and 8 deletions

View File

@@ -1800,20 +1800,52 @@ impl App {
AppEvent::ForkCurrentSession => {
self.otel_manager
.counter("codex.thread.fork", 1, &[("source", "slash_command")]);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
if let Some(path) = self.chat_widget.rollout_path() {
// Fresh threads expose a precomputed path, but the file is
// materialized lazily on first user message.
if path.exists() {
let turns = match crate::fork_turn_picker::load_fork_turn_entries(&path)
.await
{
Ok(turns) => turns,
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to read current session turns for fork from {path_display}: {err}"
));
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
};
if turns.is_empty() {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
let Some(nth_user_message) =
crate::fork_turn_picker::run_fork_turn_picker(tui, turns).await?
else {
// Leaving alt-screen may blank the inline viewport; force a redraw.
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
};
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
// The picker returns the exact `nth_user_message` value expected by the
// fork RPC, including the existing `usize::MAX` sentinel for "fork from
// the latest surviving turn".
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone(), false)
.fork_thread(nth_user_message, self.config.clone(), path.clone(), false)
.await
{
Ok(forked) => {

View File

@@ -0,0 +1,713 @@
//! Rollback-aware UI for choosing which turn `/fork` should branch from.
//!
//! This module turns the current rollout log into a presentation-only list of surviving turns,
//! then renders an alternate-screen picker that mirrors the conversation as the user currently
//! sees it. Rollback events prune the derived list so abandoned branches never appear as fork
//! targets.
//!
//! The picker returns the `nth_user_message` value expected by the existing fork RPC. Selecting
//! the latest surviving turn intentionally maps to the RPC's `usize::MAX` sentinel so "fork from
//! the current head" keeps the same semantics as the pre-picker `/fork` flow.
use std::path::Path;
use crate::key_hint;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use codex_core::RolloutRecorder;
use codex_core::parse_turn_item;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::user_input::UserInput;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Borders;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio_stream::StreamExt;
#[derive(Debug, Clone, PartialEq, Eq)]
/// Display model for one forkable turn in the current conversation.
///
/// Each entry pairs the surviving user request with the assistant response that still belongs to
/// that request after replaying rollback events. The stored strings are normalized for preview
/// rendering and are not a lossless transcript of the underlying rollout items.
pub(crate) struct ForkTurnEntry {
/// Flattened preview of the user-authored request text for this turn.
pub(crate) user_request: String,
/// Flattened preview of the assistant reply, if one has been recorded for this turn.
pub(crate) model_response: Option<String>,
}
/// Loads the current conversation into rollback-aware picker entries.
///
/// The returned vector reflects only the turns that are still visible in the active branch of the
/// conversation. Callers should treat an empty result as "nothing can be forked yet" and skip
/// presenting the picker rather than rendering an empty selection screen.
///
/// # Errors
///
/// Returns an I/O error if the rollout history file cannot be read.
pub(crate) async fn load_fork_turn_entries(path: &Path) -> std::io::Result<Vec<ForkTurnEntry>> {
let history = RolloutRecorder::get_rollout_history(path).await?;
Ok(fork_turn_entries_from_rollout_items(
&history.get_rollout_items(),
))
}
/// Runs the interactive `/fork` turn picker on the terminal's alternate screen.
///
/// The returned `usize`, when present, is already translated into the `nth_user_message` value
/// expected by the fork RPC. `Ok(None)` means either there was nothing to show or the user backed
/// out; callers should not treat cancellation as a request to fork the latest turn by default,
/// because that would fork a thread the user explicitly declined to create.
///
/// The `Result` wrapper matches other picker entry points even though this implementation currently
/// completes without emitting an error of its own.
pub(crate) async fn run_fork_turn_picker(
tui: &mut Tui,
turns: Vec<ForkTurnEntry>,
) -> Result<Option<usize>> {
if turns.is_empty() {
return Ok(None);
}
let alt = AltScreenGuard::enter(tui);
let mut screen = ForkTurnPickerScreen::new(alt.tui.frame_requester(), turns);
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
let events = alt.tui.event_stream();
tokio::pin!(events);
while !screen.is_done() {
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
}
}
} else {
screen.cancel();
break;
}
}
Ok(screen.outcome())
}
/// Replays rollout items into the set of turns the picker is allowed to present.
///
/// User messages start new entries, assistant messages attach to the most recent entry, and
/// rollback events drop the newest derived turns so the picker mirrors the active branch instead
/// of the full historical event log.
fn fork_turn_entries_from_rollout_items(items: &[RolloutItem]) -> Vec<ForkTurnEntry> {
let mut turns: Vec<ForkTurnEntry> = Vec::new();
for item in items {
match item {
RolloutItem::ResponseItem(response_item) => match parse_turn_item(response_item) {
Some(TurnItem::UserMessage(user)) => {
turns.push(ForkTurnEntry {
user_request: user_turn_preview(&user),
model_response: None,
});
}
Some(TurnItem::AgentMessage(agent)) => {
let response = agent_turn_preview(&agent.content);
if !response.is_empty()
&& let Some(last_turn) = turns.last_mut()
{
last_turn.model_response = Some(response);
}
}
_ => {}
},
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
let dropped = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX);
let kept = turns.len().saturating_sub(dropped);
turns.truncate(kept);
}
_ => {}
}
}
turns
}
fn user_turn_preview(user: &UserMessageItem) -> String {
let mut image_count = 0usize;
let mut local_image_count = 0usize;
let mut skill_count = 0usize;
let mut mention_count = 0usize;
for item in &user.content {
match item {
UserInput::Text { .. } => {}
UserInput::Image { .. } => image_count += 1,
UserInput::LocalImage { .. } => local_image_count += 1,
UserInput::Skill { .. } => skill_count += 1,
UserInput::Mention { .. } => mention_count += 1,
_ => {}
}
}
let text = user
.message()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let mut suffix_parts = Vec::new();
let total_images = image_count + local_image_count;
if total_images > 0 {
let noun = if total_images == 1 { "image" } else { "images" };
suffix_parts.push(format!("{total_images} {noun}"));
}
if skill_count > 0 {
let noun = if skill_count == 1 { "skill" } else { "skills" };
suffix_parts.push(format!("{skill_count} {noun}"));
}
if mention_count > 0 {
let noun = if mention_count == 1 {
"mention"
} else {
"mentions"
};
suffix_parts.push(format!("{mention_count} {noun}"));
}
let suffix = if suffix_parts.is_empty() {
String::new()
} else {
format!(" [{}]", suffix_parts.join(", "))
};
if text.is_empty() {
if suffix.is_empty() {
"[empty input]".to_string()
} else {
format!("[non-text input]{suffix}")
}
} else {
format!("{text}{suffix}")
}
}
fn agent_turn_preview(content: &[AgentMessageContent]) -> String {
let text = content
.iter()
.map(|item| match item {
AgentMessageContent::Text { text } => text.as_str(),
})
.collect::<Vec<_>>()
.join("");
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Maps a visible picker selection onto the `nth_user_message` contract used by `fork_thread`.
///
/// The final visible turn maps to `usize::MAX`, which is the existing sentinel for "fork from the
/// current head". Returning `turn_count` instead would point one user message past the end of the
/// surviving conversation and rely on server-side bounds handling to recover.
fn nth_user_message_for_fork(selected_index: usize, turn_count: usize) -> usize {
if selected_index.saturating_add(1) >= turn_count {
usize::MAX
} else {
selected_index + 1
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ForkTurnPickerOutcome {
Cancelled,
Selected(usize),
}
struct ForkTurnPickerScreen {
request_frame: FrameRequester,
turns: Vec<ForkTurnEntry>,
selected: usize,
scroll_top: usize,
done: bool,
outcome: ForkTurnPickerOutcome,
}
impl ForkTurnPickerScreen {
fn new(request_frame: FrameRequester, turns: Vec<ForkTurnEntry>) -> Self {
let selected = turns.len().saturating_sub(1);
Self {
request_frame,
turns,
selected,
scroll_top: 0,
done: false,
outcome: ForkTurnPickerOutcome::Cancelled,
}
}
fn is_done(&self) -> bool {
self.done
}
fn outcome(&self) -> Option<usize> {
match self.outcome {
ForkTurnPickerOutcome::Cancelled => None,
ForkTurnPickerOutcome::Selected(nth_user_message) => Some(nth_user_message),
}
}
fn confirm(&mut self) {
self.outcome = ForkTurnPickerOutcome::Selected(nth_user_message_for_fork(
self.selected,
self.turns.len(),
));
self.done = true;
self.request_frame.schedule_frame();
}
fn cancel(&mut self) {
self.outcome = ForkTurnPickerOutcome::Cancelled;
self.done = true;
self.request_frame.schedule_frame();
}
fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if is_ctrl_exit_combo(key_event) {
self.cancel();
return;
}
if let KeyCode::Char(ch) = key_event.code
&& key_event.modifiers == KeyModifiers::NONE
&& ch.is_ascii_digit()
&& let Some(digit) = ch.to_digit(10)
&& digit != 0
{
self.select_display_number(usize::try_from(digit).unwrap_or(usize::MAX));
return;
}
match key_event.code {
KeyCode::Esc | KeyCode::Char('q') => self.cancel(),
KeyCode::Enter => self.confirm(),
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::Home | KeyCode::Char('g') => self.select_index(0),
KeyCode::End | KeyCode::Char('G') => {
self.select_index(self.turns.len().saturating_sub(1))
}
KeyCode::PageUp => self.page_move(-1),
KeyCode::PageDown => self.page_move(1),
_ => {}
}
}
fn move_selection(&mut self, delta: isize) {
let next = if delta < 0 {
self.selected.saturating_sub(delta.unsigned_abs())
} else {
self.selected
.saturating_add(usize::try_from(delta).unwrap_or(usize::MAX))
.min(self.turns.len().saturating_sub(1))
};
self.select_index(next);
}
fn page_move(&mut self, delta_pages: isize) {
let page = self.visible_list_entries();
let step = if page == 0 { 1 } else { page };
let step = isize::try_from(step).unwrap_or(isize::MAX);
self.move_selection(step.saturating_mul(delta_pages));
}
fn select_index(&mut self, idx: usize) {
if idx == self.selected {
return;
}
self.selected = idx.min(self.turns.len().saturating_sub(1));
self.ensure_selected_visible();
self.request_frame.schedule_frame();
}
fn select_display_number(&mut self, display_number: usize) {
if display_number == 0 || display_number > self.turns.len() {
return;
}
let idx = display_number.saturating_sub(1);
self.select_index(idx);
}
fn visible_list_entries(&self) -> usize {
4
}
fn ensure_selected_visible(&mut self) {
let entries = self.visible_list_entries().max(1);
if self.selected < self.scroll_top {
self.scroll_top = self.selected;
} else if self.selected >= self.scroll_top.saturating_add(entries) {
self.scroll_top = self.selected.saturating_add(1).saturating_sub(entries);
}
}
fn effective_scroll_top(&self, visible_entries: usize) -> usize {
let rows = visible_entries.max(1);
if self.selected < self.scroll_top {
self.selected
} else if self.selected >= self.scroll_top.saturating_add(rows) {
self.selected.saturating_add(1).saturating_sub(rows)
} else {
self.scroll_top
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(6),
Constraint::Length(9),
Constraint::Length(2),
])
.split(area);
let heading = vec![
Line::from(vec!["/fork".magenta(), " select a turn".into()]),
Line::from("Choose a turn from the current conversation to fork after."),
Line::from(format!(
"{} turns available. The newest turn is selected by default.",
self.turns.len()
))
.dim(),
];
Paragraph::new(heading)
.wrap(Wrap { trim: false })
.render(chunks[0], buf);
self.render_turn_list(chunks[1], buf);
self.render_selected_preview(chunks[2], buf);
let hints = Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" (or j/k) to choose, ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to fork, ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" to cancel, digits jump to row".dim(),
]);
Paragraph::new(hints)
.wrap(Wrap { trim: false })
.render(chunks[3], buf);
}
fn render_turn_list(&self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.borders(Borders::ALL)
.title("Turns (oldest to newest)");
let inner = block.inner(area);
block.render(area, buf);
if inner.is_empty() {
return;
}
let visible_rows = usize::from(inner.height);
let visible_entries = (visible_rows / 2).max(1);
let mut lines = Vec::with_capacity(visible_rows);
let scroll_top = self.effective_scroll_top(visible_entries);
let label_digits = self.max_display_label_digits();
let end = self
.effective_scroll_top(visible_entries)
.saturating_add(visible_entries)
.min(self.turns.len());
for idx in scroll_top..end {
let is_selected = idx == self.selected;
let display_number = self.display_turn_number(idx);
let label = format!("{display_number:>label_digits$}:");
let user_prefix = format!("{} {} ", if is_selected { "" } else { " " }, label);
let user_width = usize::from(inner.width).saturating_sub(user_prefix.len());
let user_text = if user_width == 0 {
String::new()
} else {
truncate_text(&self.turns[idx].user_request, user_width)
};
let user_line = if is_selected {
Line::from(vec![user_prefix.cyan(), user_text.into()])
} else {
Line::from(vec![user_prefix.dim(), user_text.into()])
};
lines.push(user_line);
if lines.len() >= visible_rows {
break;
}
let response_prefix = " ".repeat(4 + label.len());
let response_text = self.turns[idx]
.model_response
.as_deref()
.unwrap_or("[no model response recorded]");
let response_width = usize::from(inner.width).saturating_sub(response_prefix.len());
let response_text = if response_width == 0 {
String::new()
} else {
truncate_text(response_text, response_width)
};
lines.push(Line::from(vec![
response_prefix.into(),
response_text.dim(),
]));
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(inner, buf);
}
fn render_selected_preview(&self, area: Rect, buf: &mut Buffer) {
let display_number = self.display_turn_number(self.selected);
let is_latest = self.selected + 1 == self.turns.len();
let title = if is_latest {
format!("Selected turn: {display_number} (latest)")
} else {
format!("Selected turn: {display_number}")
};
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
block.render(area, buf);
if inner.is_empty() {
return;
}
let selected = &self.turns[self.selected];
let newer_turns = self
.turns
.len()
.saturating_sub(self.selected)
.saturating_sub(1);
let status_line = if newer_turns == 0 {
"Forking from the latest turn keeps the full current conversation.".to_string()
} else {
let suffix = if newer_turns == 1 { "" } else { "s" };
format!("Forking here omits {newer_turns} newer turn{suffix} from the new thread.")
};
let model_response = selected
.model_response
.as_deref()
.unwrap_or("[no model response recorded]");
let lines = vec![
Line::from(selected.user_request.clone()),
Line::from(""),
Line::from(model_response).dim(),
Line::from(""),
Line::from(status_line).dim(),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(inner, buf);
}
fn display_turn_number(&self, idx: usize) -> usize {
idx.saturating_add(1)
}
fn max_display_label_digits(&self) -> usize {
self.turns.len().max(1).to_string().len()
}
}
impl WidgetRef for &ForkTurnPickerScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
ForkTurnPickerScreen::render(self, area, buf);
}
}
// Render the picker on the terminal's alternate screen so cancel/confirm does
// not leave a large blank region in the main scrollback.
struct AltScreenGuard<'a> {
tui: &'a mut Tui,
}
impl<'a> AltScreenGuard<'a> {
fn enter(tui: &'a mut Tui) -> Self {
let _ = tui.enter_alt_screen();
Self { tui }
}
}
impl Drop for AltScreenGuard<'_> {
fn drop(&mut self) {
let _ = self.tui.leave_alt_screen();
}
}
fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool {
key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
}
#[cfg(test)]
mod tests {
use super::ForkTurnEntry;
use super::ForkTurnPickerScreen;
use super::fork_turn_entries_from_rollout_items;
use super::nth_user_message_for_fork;
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::ThreadRolledBackEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
fn user_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
fn assistant_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
#[test]
fn extracts_effective_user_turns_and_applies_rollbacks() {
let items = vec![
RolloutItem::ResponseItem(user_msg("first request")),
RolloutItem::ResponseItem(assistant_msg("first answer")),
RolloutItem::ResponseItem(user_msg("second request")),
RolloutItem::ResponseItem(assistant_msg("second answer")),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
num_turns: 1,
})),
RolloutItem::ResponseItem(user_msg("replacement request")),
RolloutItem::ResponseItem(assistant_msg("replacement answer")),
];
let turns = fork_turn_entries_from_rollout_items(&items);
assert_eq!(
turns,
vec![
ForkTurnEntry {
user_request: "first request".to_string(),
model_response: Some("first answer".to_string()),
},
ForkTurnEntry {
user_request: "replacement request".to_string(),
model_response: Some("replacement answer".to_string()),
},
]
);
}
#[test]
fn nth_user_message_mapping_keeps_selected_turn_in_fork() {
assert_eq!(nth_user_message_for_fork(0, 3), 1);
assert_eq!(nth_user_message_for_fork(1, 3), 2);
assert_eq!(nth_user_message_for_fork(2, 3), usize::MAX);
}
#[test]
fn picker_snapshot() {
let mut screen = ForkTurnPickerScreen::new(
FrameRequester::test_dummy(),
vec![
ForkTurnEntry {
user_request: "Initial bug report with stack trace and repro steps".to_string(),
model_response: Some(
"Short answer: not via the current app-server protocol.".to_string(),
),
},
ForkTurnEntry {
user_request:
"Please also handle macOS path edge cases when filenames contain spaces"
.to_string(),
model_response: Some(
"Acknowledged. I will cover macOS path handling and spaces.".to_string(),
),
},
ForkTurnEntry {
user_request: "One more thing: include tests for rollback + fork interaction"
.to_string(),
model_response: Some(
"I added coverage for rollback semantics in the turn extraction helper."
.to_string(),
),
},
],
);
screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let backend = VT100Backend::new(76, 22);
let mut terminal = Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(Rect::new(0, 0, 76, 22));
{
let mut frame = terminal.get_frame();
frame.render_widget_ref(&screen, frame.area());
}
terminal.flush().expect("flush");
assert_snapshot!("fork_turn_picker", terminal.backend());
}
}

View File

@@ -76,6 +76,7 @@ mod exec_cell;
mod exec_command;
mod external_editor;
mod file_search;
mod fork_turn_picker;
mod frames;
mod get_git_diff;
mod history_cell;

View File

@@ -0,0 +1,25 @@
---
source: tui/src/fork_turn_picker.rs
expression: terminal.backend()
---
/fork select a turn
Choose a turn from the current conversation to fork after.
3 turns available. The newest turn is selected by default.
┌Turns (oldest to newest)──────────────────────────────────────────────────┐
│ 1: Initial bug report with stack trace and repro steps │
│ Short answer: not via the current app-server protocol. │
│› 2: Please also handle macOS path edge cases when filenames contain ... │
│ Acknowledged. I will cover macOS path handling and spaces. │
│ 3: One more thing: include tests for rollback + fork interaction │
│ I added coverage for rollback semantics in the turn extraction he...│
└──────────────────────────────────────────────────────────────────────────┘
┌Selected turn: 2──────────────────────────────────────────────────────────┐
│Please also handle macOS path edge cases when filenames contain spaces │
│ │
│Acknowledged. I will cover macOS path handling and spaces. │
│ │
│Forking here omits 1 newer turn from the new thread. │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
Use ↑/↓ (or j/k) to choose, enter to fork, esc to cancel, digits jump to row