mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Allow forking conversations locked by another app in the TUI (#47185)
## Why Conversations open in another app disable the composer. Let users continue in an editable fork while the original conversation remains owned by that app. ## What changed - Add an `f`/`F` shortcut and footer hint to the locked conversation view, preserving local drafts and queued prompts in the fork. - Show “Forking conversation…” while a fork is pending, block additional key input, and discard buffered input after forking from the locked view. - Replace the previous session summary after a fork with “Fork created. You can continue here.” - Keep the locked view and draft intact if the fork request fails. ## Testing Add tests for shortcut ownership and modifiers, repeated input, editable forks that preserve drafts and queued prompts without taking the source lease, and failure recovery. Add snapshots for fork progress, completion, and the updated footer. GitOrigin-RevId: 2ecdb0b7fc7e82d8295097faa567995d7a2f7d92
This commit is contained in:
@@ -31,6 +31,10 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
event: AppEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
// Release the shortcut's input guard even when a fork is rejected below.
|
||||
if matches!(event, AppEvent::ForkCurrentSession { .. }) {
|
||||
self.chat_widget.fork_in_progress = false;
|
||||
}
|
||||
if self.reconnect.offline
|
||||
&& !matches!(
|
||||
&event,
|
||||
@@ -450,16 +454,16 @@ impl App {
|
||||
return self.delete_current_thread(tui, app_server).await;
|
||||
}
|
||||
AppEvent::ForkCurrentSession { name } => {
|
||||
let from_locked_thread = self.chat_widget.is_external_writer_view();
|
||||
let source = if from_locked_thread {
|
||||
"locked_thread_shortcut"
|
||||
} else {
|
||||
"slash_command"
|
||||
};
|
||||
self.session_telemetry.counter(
|
||||
"codex.thread.fork",
|
||||
/*inc*/ 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.rollout_path().as_deref(),
|
||||
&[("source", source)],
|
||||
);
|
||||
self.chat_widget
|
||||
.add_plain_history_lines(vec!["/fork".magenta().into()]);
|
||||
@@ -470,6 +474,12 @@ impl App {
|
||||
);
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.chat_widget.fork_in_progress = true;
|
||||
// This handler awaits the fork outside the draw loop. Paint before waiting.
|
||||
let screen_size = tui.terminal.last_known_screen_size;
|
||||
self.handle_draw_pre_render(tui, screen_size)?;
|
||||
self.chat_widget.pre_draw_tick();
|
||||
self.render_chat_widget_frame(tui, screen_size)?;
|
||||
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
|
||||
.await;
|
||||
let mut fork_config = self.config.clone();
|
||||
@@ -492,6 +502,9 @@ impl App {
|
||||
selected_profile.as_ref(),
|
||||
).await {
|
||||
Ok(mut forked) => {
|
||||
let retained_input = from_locked_thread
|
||||
.then(|| self.chat_widget.capture_thread_input_state())
|
||||
.flatten();
|
||||
let name_error = if let Some(name) = name {
|
||||
match app_server
|
||||
.thread_set_name(forked.session.thread_id, name.clone())
|
||||
@@ -519,23 +532,15 @@ impl App {
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
// Keep local input without replacing the fork's running state.
|
||||
self.chat_widget.restore_reconnected_input(retained_input);
|
||||
if let Some(err) = name_error {
|
||||
self.chat_widget.add_error_message(err);
|
||||
}
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
if let Some(usage_line) = summary.usage_line {
|
||||
lines.push(usage_line.into());
|
||||
}
|
||||
if let Some(command) = summary.resume_hint {
|
||||
let spans = vec![
|
||||
"To continue this session, run ".into(),
|
||||
command.cyan(),
|
||||
];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
self.chat_widget.add_info_message(
|
||||
"Fork created. You can continue here.".to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
@@ -550,6 +555,13 @@ impl App {
|
||||
));
|
||||
}
|
||||
}
|
||||
if from_locked_thread {
|
||||
// Repeated locked-view shortcuts must not act on the resulting view.
|
||||
if let Err(err) = tui.discard_pending_input_before_interactive_screen() {
|
||||
tracing::warn!(%err, "failed to discard input after forking");
|
||||
}
|
||||
tui.schedule_screen_size_recheck(Duration::ZERO);
|
||||
}
|
||||
} else {
|
||||
self.chat_widget.add_error_message(
|
||||
"A thread must contain at least one turn before it can be forked."
|
||||
@@ -557,6 +569,7 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
self.chat_widget.fork_in_progress = false;
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
@@ -346,6 +346,9 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
key_event: KeyEvent,
|
||||
) {
|
||||
if self.chat_widget.fork_in_progress {
|
||||
return;
|
||||
}
|
||||
if self.chat_widget.is_external_writer_view()
|
||||
&& self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
@@ -374,6 +377,14 @@ impl App {
|
||||
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
|
||||
return;
|
||||
}
|
||||
if matches!(key_event.code, KeyCode::Char('f' | 'F'))
|
||||
&& (modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT)
|
||||
{
|
||||
self.chat_widget.fork_in_progress = true;
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ForkCurrentSession { name: None });
|
||||
return;
|
||||
}
|
||||
if matches!(key_event.code, KeyCode::Char('r' | 'R'))
|
||||
&& (modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT)
|
||||
&& let Some(thread_id) = self.current_displayed_thread_id()
|
||||
|
||||
@@ -29,6 +29,8 @@ mod buffered_replay;
|
||||
mod connector_policy;
|
||||
#[path = "tests/disconnect_tests.rs"]
|
||||
mod disconnect;
|
||||
#[path = "tests/external_writer_fork_tests.rs"]
|
||||
mod external_writer_fork_tests;
|
||||
#[path = "tests/fork_workspace_roots_tests.rs"]
|
||||
mod fork_workspace_roots_tests;
|
||||
#[path = "tests/fresh_sparkle_tests.rs"]
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Locked-thread fork shortcuts reuse the normal fork flow without taking the source lease.
|
||||
|
||||
use super::session_lifecycle_requests::recorded_params;
|
||||
use super::session_lifecycle_requests::start_recording_app_server;
|
||||
use super::*;
|
||||
use app_test_support::create_fake_rollout;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_fork_shortcut_respects_input_ownership() -> Result<()> {
|
||||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||||
app.enqueue_primary_thread_session(
|
||||
test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()),
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let fork_key = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE);
|
||||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(fork_key))
|
||||
.await?;
|
||||
// A navigation key flushes the composer's pending single-character paste burst.
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "f");
|
||||
assert!(
|
||||
!std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::ForkCurrentSession { .. }))
|
||||
);
|
||||
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
for (key, expected) in [
|
||||
(fork_key, true),
|
||||
(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::SHIFT), true),
|
||||
(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE), true),
|
||||
(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), false),
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
|
||||
false,
|
||||
),
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::SUPER),
|
||||
false,
|
||||
),
|
||||
(
|
||||
KeyEvent::new_with_kind(KeyCode::Char('f'), KeyModifiers::NONE, KeyEventKind::Repeat),
|
||||
false,
|
||||
),
|
||||
(
|
||||
KeyEvent::new_with_kind(
|
||||
KeyCode::Char('f'),
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Release,
|
||||
),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
while events.try_recv().is_ok() {}
|
||||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))
|
||||
.await?;
|
||||
let forks = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.filter(|event| matches!(event, AppEvent::ForkCurrentSession { name: None }))
|
||||
.count();
|
||||
assert_eq!(forks, usize::from(expected), "{key:?}");
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "f");
|
||||
if expected {
|
||||
for code in [KeyCode::Char('f'), KeyCode::Char('r')] {
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
assert!(events.try_recv().is_err());
|
||||
// The event handler clears the guard when it consumes the fork request.
|
||||
app.chat_widget.fork_in_progress = false;
|
||||
}
|
||||
}
|
||||
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
assert!(app.overlay.is_some());
|
||||
while events.try_recv().is_ok() {}
|
||||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(fork_key))
|
||||
.await?;
|
||||
assert!(
|
||||
!std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::ForkCurrentSession { .. }))
|
||||
);
|
||||
|
||||
app.overlay = None;
|
||||
app.chat_widget.show_selection_view(SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Keep this popup open".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..SelectionViewParams::picker()
|
||||
});
|
||||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(fork_key))
|
||||
.await?;
|
||||
assert!(
|
||||
!std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::ForkCurrentSession { .. }))
|
||||
);
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_fork_opens_editable_thread_without_taking_source_lease() -> Result<()> {
|
||||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||||
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
|
||||
let thread_id = ThreadId::from_string(
|
||||
&create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2026-01-01T00-00-00",
|
||||
"2026-01-01T00:00:00Z",
|
||||
"Saved user message",
|
||||
Some(app.config.model_provider_id.as_str()),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.expect("create source rollout"),
|
||||
)?;
|
||||
let mut owner = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
owner
|
||||
.resume_thread(
|
||||
&app.local_settings,
|
||||
app.config.clone(),
|
||||
thread_id,
|
||||
app.resume_model_settings(),
|
||||
)
|
||||
.await?;
|
||||
let (mut server, requests, proxy) = start_recording_app_server(
|
||||
&app.config,
|
||||
/*blocked_thread_list*/ None,
|
||||
/*failed_thread_name*/ None,
|
||||
)
|
||||
.await?;
|
||||
let (view, _notice) = server
|
||||
.read_thread_for_viewing(&app.config, &app.local_settings, thread_id)
|
||||
.await?;
|
||||
app.enqueue_primary_thread_session(view.session, view.turns)
|
||||
.await?;
|
||||
app.ensure_thread_channel(thread_id).mark_external_writer();
|
||||
app.chat_widget
|
||||
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||||
app.chat_widget.insert_str("Retained queued prompt");
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
app.chat_widget.queued_user_message_texts(),
|
||||
vec!["Retained queued prompt".to_string()]
|
||||
);
|
||||
app.chat_widget.insert_str("Retained draft");
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
let retained_input = app.chat_widget.capture_thread_input_state();
|
||||
while events.try_recv().is_ok() {}
|
||||
requests.lock().expect("request recorder lock").clear();
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
let event = events.try_recv()?;
|
||||
assert!(matches!(event, AppEvent::ForkCurrentSession { name: None }));
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
|
||||
assert_eq!(app.chat_widget.capture_thread_input_state(), retained_input);
|
||||
assert_ne!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
assert!(!app.chat_widget.is_external_writer_view());
|
||||
assert!(!app.chat_widget.fork_in_progress);
|
||||
let messages = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let text = lines_to_single_string(&cell.display_lines(/*width*/ 100));
|
||||
text.contains("Fork created.").then_some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
insta::assert_snapshot!("fork_completion", messages.join("\n"));
|
||||
assert_eq!(
|
||||
recorded_params(&requests, "thread/fork")
|
||||
.into_iter()
|
||||
.map(|params| params["threadId"].clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![serde_json::json!(thread_id.to_string())]
|
||||
);
|
||||
for method in ["thread/resume", "turn/start", "turn/interrupt"] {
|
||||
assert!(recorded_params(&requests, method).is_empty(), "{method}");
|
||||
}
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Paste("Editable fork".into()),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Retained draftEditable fork"
|
||||
);
|
||||
let error = server
|
||||
.resume_thread(
|
||||
&app.local_settings,
|
||||
app.config.clone(),
|
||||
thread_id,
|
||||
app.resume_model_settings(),
|
||||
)
|
||||
.await
|
||||
.expect_err("source still has its original writer");
|
||||
assert!(crate::app_server_session::is_active_writer_error(&error));
|
||||
owner.shutdown().await?;
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_fork_failure_keeps_the_locked_view_and_draft() -> Result<()> {
|
||||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.chat_widget
|
||||
.handle_thread_session(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
|
||||
app.chat_widget.insert_str("Retained draft");
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
while events.try_recv().is_ok() {}
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
let event = events.try_recv()?;
|
||||
assert!(matches!(event, AppEvent::ForkCurrentSession { name: None }));
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
assert!(app.chat_widget.is_external_writer_view());
|
||||
assert!(!app.chat_widget.fork_in_progress);
|
||||
// The input drain can consume a resize; the next draw must sample the backend again.
|
||||
tui.terminal.last_known_screen_size = Size::new(/*width*/ 120, /*height*/ 40);
|
||||
assert_eq!(
|
||||
tui.screen_size_for_event(&TuiEvent::Draw)?,
|
||||
tui.terminal.size()?,
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Retained draft"
|
||||
);
|
||||
let messages = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
Some(lines_to_single_string(&cell.display_lines(/*width*/ 100)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(messages.contains("Failed to fork current session through the app server:"));
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/app/tests/external_writer_fork_tests.rs
|
||||
assertion_line: 183
|
||||
expression: "messages.join(\"\\n\")"
|
||||
---
|
||||
• Fork created. You can continue here.
|
||||
@@ -718,6 +718,8 @@ pub(crate) struct ChatWidget {
|
||||
active_side_conversation: bool,
|
||||
blocks_direct_input: bool,
|
||||
external_writer_view: bool,
|
||||
/// Covers both queued and executing forks so repeated shortcuts cannot queue another one.
|
||||
pub(crate) fork_in_progress: bool,
|
||||
misalignment_policy_violation: Option<misalignment_policy::MisalignmentViolation>,
|
||||
normal_placeholder_text: String,
|
||||
side_placeholder_text: String,
|
||||
|
||||
@@ -226,6 +226,7 @@ impl ChatWidget {
|
||||
active_side_conversation: false,
|
||||
blocks_direct_input: false,
|
||||
external_writer_view: false,
|
||||
fork_in_progress: false,
|
||||
misalignment_policy_violation: None,
|
||||
normal_placeholder_text: placeholder,
|
||||
side_placeholder_text: side_placeholder,
|
||||
|
||||
@@ -88,7 +88,10 @@ impl ExternalWriterNotice {
|
||||
}
|
||||
|
||||
fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut items = vec![("r".to_string(), "retry".to_string())];
|
||||
let mut items = vec![
|
||||
("r".to_string(), "retry".to_string()),
|
||||
("f".to_string(), "fork".to_string()),
|
||||
];
|
||||
let escape = crate::key_hint::plain(KeyCode::Esc).display_label();
|
||||
let mut quit_keys = vec![
|
||||
crate::key_hint::ctrl(KeyCode::Char('c')).display_label(),
|
||||
@@ -209,7 +212,13 @@ impl ChatWidget {
|
||||
command_popup_placement: crate::bottom_pane::CommandPopupPlacement,
|
||||
composer_gap: Option<&'a crate::bottom_pane::ComposerGap>,
|
||||
) -> RenderableItem<'a> {
|
||||
if self.external_writer_view && !self.bottom_pane.has_active_view() {
|
||||
if self.fork_in_progress {
|
||||
RenderableItem::Owned(Box::new(
|
||||
Paragraph::new("Forking conversation…".dim()).inset(Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 2, /*bottom*/ 1, /*right*/ 2,
|
||||
)),
|
||||
))
|
||||
} else if self.external_writer_view && !self.bottom_pane.has_active_view() {
|
||||
RenderableItem::Owned(Box::new(ExternalWriterNotice {
|
||||
command_center_available: self.remote_connection.is_some(),
|
||||
transcript_hint: self.bottom_pane.transcript_shortcut_hint(),
|
||||
|
||||
@@ -265,6 +265,35 @@ async fn external_writer_view_shows_notice_instead_of_composer() {
|
||||
assert!(!widget.bottom_pane.composer_input_enabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_fork_progress_restores_the_previous_view() {
|
||||
let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await;
|
||||
for locked in [false, true] {
|
||||
widget.external_writer_view = locked;
|
||||
let previous = render_frame(&widget, /*width*/ 60);
|
||||
widget.fork_in_progress = true;
|
||||
let frame = render_frame(&widget, /*width*/ 60);
|
||||
let rows = frame
|
||||
.content
|
||||
.chunks(usize::from(frame.area.width))
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
insta::assert_snapshot!(
|
||||
"fork_pending",
|
||||
rows.iter()
|
||||
.map(|row| row.trim_end())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
widget.fork_in_progress = false;
|
||||
assert_eq!(render_frame(&widget, /*width*/ 60), previous);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_notice_uses_current_transcript_shortcut() {
|
||||
let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await;
|
||||
|
||||
+1
-3
@@ -2,9 +2,7 @@
|
||||
source: tui/src/chatwidget/rendering_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
|
||||
|
||||
🔒 This conversation is open in another app r to retry
|
||||
Close it there and press R to continue here.
|
||||
|
||||
r retry esc command center ctrl+c/q exit ctrl+t transcript
|
||||
r retry f fork esc command center ctrl+c/q exit ctrl+t transcript
|
||||
|
||||
+2
-4
@@ -2,10 +2,8 @@
|
||||
source: tui/src/chatwidget/rendering_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
|
||||
|
||||
🔒 This conversation is open in another app r to retry
|
||||
Close it there and press R to continue here.
|
||||
|
||||
r retry esc command center ctrl+c/q exit ctrl+t
|
||||
transcript
|
||||
r retry f fork esc command center ctrl+c/q exit
|
||||
ctrl+t transcript
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@ expression: "rows.iter().map(|row| row.trim_end()).collect::<Vec<_>>().join(\"\\
|
||||
🔒 This conversation is open in another app r to retry
|
||||
Close it there and press R to continue here.
|
||||
|
||||
r retry esc/ctrl+c/q exit ctrl+t transcript
|
||||
r retry f fork esc/ctrl+c/q exit ctrl+t transcript
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/chatwidget/rendering_tests.rs
|
||||
assertion_line: 281
|
||||
expression: "rows.join(\"\\n\")"
|
||||
---
|
||||
|
||||
|
||||
Forking conversation…
|
||||
+1
-1
@@ -5,4 +5,4 @@ expression: "render_bottom_popup(&app.chat_widget, 96)"
|
||||
🔒 This conversation is open in another app r to retry
|
||||
Close it there and press R to continue here.
|
||||
|
||||
r retry esc command center ctrl+c/q exit ctrl+t transcript
|
||||
r retry f fork esc command center ctrl+c/q exit ctrl+t transcript
|
||||
|
||||
Reference in New Issue
Block a user