mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
Show read-only conversations when resume encounters an active writer (#43253)
## Why Resuming a conversation that is open in another app fails with an active-writer error. Allow users to inspect its transcript and retry after closing it in the other app. ## What changed - Fall back to a read-only history snapshot on active-writer conflicts during startup and session selection. - Replace the composer with an explanatory notice, `R` to retry, exit shortcuts, and the configured transcript shortcut. - Block input and freeze active command and MCP animations while viewing the snapshot. Preserve initial prompts and existing drafts for a successful retry. - Keep the subscription when retrying the same thread and preserve read-only state across thread switches and reconnects. ## Testing Add regression tests for conflict detection, history viewing, draft preservation, same-thread retry without unsubscribing, exit shortcuts, and notice and frozen-tool rendering. GitOrigin-RevId: fa864cc73143cad9139c22e33b7958569eec20cf
This commit is contained in:
@@ -233,6 +233,43 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
key_event: KeyEvent,
|
||||
) {
|
||||
if self.chat_widget.is_external_writer_view()
|
||||
&& self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& key_event.kind == KeyEventKind::Press
|
||||
{
|
||||
let modifiers = key_event.modifiers;
|
||||
let quit = match key_event.code {
|
||||
KeyCode::Esc => modifiers == KeyModifiers::NONE,
|
||||
KeyCode::Char('q' | 'Q') => {
|
||||
modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT
|
||||
}
|
||||
KeyCode::Char('c' | 'C') => {
|
||||
modifiers == KeyModifiers::CONTROL
|
||||
|| modifiers == (KeyModifiers::CONTROL | KeyModifiers::SHIFT)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if quit {
|
||||
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
|
||||
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()
|
||||
{
|
||||
let target = SessionTarget {
|
||||
path: self
|
||||
.primary_session_configured
|
||||
.as_ref()
|
||||
.and_then(|session| session.rollout_path.clone()),
|
||||
thread_id,
|
||||
history_mode: None,
|
||||
};
|
||||
let _ = self.resume_target_session(tui, app_server, target).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
|
||||
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
|
||||
// agent-switch shortcuts when the composer is empty so we never steal the expected
|
||||
@@ -376,6 +413,13 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.chat_widget.is_external_writer_view()
|
||||
&& self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if self.should_handle_unavailable_thread_key(key_event) {
|
||||
self.chat_widget.handle_disconnected_key(key_event);
|
||||
return;
|
||||
|
||||
@@ -158,7 +158,7 @@ impl App {
|
||||
&& self
|
||||
.thread_event_channels
|
||||
.get(&id)
|
||||
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ReplayOnly)
|
||||
.is_some_and(|channel| channel.attachment() != ThreadEventAttachment::Live)
|
||||
}
|
||||
|
||||
pub(super) fn recover_transport_error(&mut self, error: &color_eyre::Report) -> bool {
|
||||
@@ -284,7 +284,11 @@ impl App {
|
||||
// so their late requests cannot leak into recovery. Background threads attach on selection.
|
||||
for channel in self.thread_event_channels.values_mut() {
|
||||
let mut replacement = ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY);
|
||||
replacement.mark_replay_only();
|
||||
if channel.attachment() == ThreadEventAttachment::ExternalWriter {
|
||||
replacement.mark_external_writer();
|
||||
} else {
|
||||
replacement.mark_replay_only();
|
||||
}
|
||||
let mut store = std::mem::replace(
|
||||
&mut *channel.store.lock().await,
|
||||
ThreadEventStore::new(THREAD_EVENT_CHANNEL_CAPACITY),
|
||||
@@ -349,7 +353,7 @@ impl App {
|
||||
)?;
|
||||
self.config = self.chat_widget.config_ref().clone();
|
||||
self.refresh_pending_thread_approvals().await;
|
||||
if self.thread_unavailable(id) {
|
||||
if self.thread_unavailable(id) && !self.chat_widget.is_external_writer_view() {
|
||||
self.agent_navigation.mark_stopped(id);
|
||||
self.chat_widget.pause_unavailable_thread();
|
||||
self.chat_widget.add_info_message("This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue.".into(), /*hint*/ None);
|
||||
|
||||
@@ -13,6 +13,7 @@ use super::app_server_event_targets::server_request_thread_id;
|
||||
use super::*;
|
||||
use crate::app_server_session::source_agent_path;
|
||||
use crate::app_server_session::thread_blocks_direct_input;
|
||||
use crate::chatwidget::ThreadInputStateRestoreMode;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -366,6 +367,10 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<bool> {
|
||||
let was_external_writer = self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter);
|
||||
if self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
@@ -459,7 +464,11 @@ impl App {
|
||||
};
|
||||
let channel = self.ensure_thread_channel(thread_id);
|
||||
if !live_attached {
|
||||
channel.mark_replay_only();
|
||||
if was_external_writer {
|
||||
channel.mark_external_writer();
|
||||
} else {
|
||||
channel.mark_replay_only();
|
||||
}
|
||||
}
|
||||
let mut store = channel.store.lock().await;
|
||||
store.set_session(session, turns);
|
||||
@@ -627,7 +636,12 @@ impl App {
|
||||
self.schedule_recap_check(thread_id, now);
|
||||
|
||||
self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?;
|
||||
if is_replay_only {
|
||||
if is_replay_only
|
||||
&& self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.is_none_or(|channel| channel.attachment() != ThreadEventAttachment::ExternalWriter)
|
||||
{
|
||||
self.chat_widget.pause_unavailable_thread();
|
||||
let message = if attached_replay_only {
|
||||
format!(
|
||||
@@ -661,11 +675,21 @@ impl App {
|
||||
.set_task_mentions_enabled(app_server.task_tools_available(thread_id));
|
||||
self.chat_widget
|
||||
.note_rendered_width(tui.terminal.last_known_screen_size.width);
|
||||
let external_writer = self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter);
|
||||
if external_writer {
|
||||
self.chat_widget.show_external_writer_thread();
|
||||
}
|
||||
if self.agent_navigation.is_parent_owned(thread_id) {
|
||||
self.chat_widget.set_parent_owned_thread();
|
||||
}
|
||||
self.reset_for_thread_switch(tui)?;
|
||||
self.replay_thread_snapshot(snapshot, resume_restored_queue);
|
||||
if external_writer {
|
||||
self.chat_widget.show_external_writer_thread();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1133,69 +1157,106 @@ impl App {
|
||||
if let Some(history_mode) = target_session.history_mode {
|
||||
app_server.remember_thread_history_mode(target_session.thread_id, history_mode);
|
||||
}
|
||||
match app_server
|
||||
let resumed = app_server
|
||||
.resume_thread(
|
||||
&local_settings,
|
||||
resume_config.clone(),
|
||||
target_session.thread_id,
|
||||
self.resume_model_settings(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resumed) => {
|
||||
let resumed_thread_id = resumed.session.thread_id;
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
self.local_settings = local_settings;
|
||||
self.config = resume_config;
|
||||
tui.set_notification_settings(
|
||||
self.local_settings.tui.notification_settings.method,
|
||||
self.local_settings.tui.notification_settings.condition,
|
||||
);
|
||||
self.file_search
|
||||
.update_search_dir(self.config.cwd.to_path_buf());
|
||||
match self
|
||||
.replace_chat_widget_with_app_server_thread(
|
||||
tui,
|
||||
resumed,
|
||||
ThreadAttachPresentation::SessionLineage,
|
||||
/*initial_user_message*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.backfill_loaded_subagent_threads(app_server).await;
|
||||
self.replay_agents_overview_requests(app_server, resumed_thread_id)
|
||||
.await;
|
||||
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.maybe_prompt_resume_paused_goal_after_resume(
|
||||
app_server,
|
||||
resumed_thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.add_session_picker_error(format!(
|
||||
"Failed to attach to resumed app-server thread: {err}"
|
||||
));
|
||||
}
|
||||
.await;
|
||||
let (resumed, read_only) = match resumed {
|
||||
Ok(resumed) => (resumed, false),
|
||||
Err(err) if crate::app_server_session::is_active_writer_error(&err) => match app_server
|
||||
.read_thread_for_viewing(&resume_config, &local_settings, target_session.thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(thread) => (thread, true),
|
||||
Err(read_err) => {
|
||||
self.add_session_picker_error(format!(
|
||||
"Failed to view thread open elsewhere: {read_err}"
|
||||
));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
let path_display = target_session.display_label();
|
||||
self.add_session_picker_error(format!(
|
||||
"Failed to resume session from {path_display}: {err}"
|
||||
));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
};
|
||||
let resumed_thread_id = resumed.session.thread_id;
|
||||
let retained_input = (self.chat_widget.is_external_writer_view()
|
||||
&& self.chat_widget.thread_id() == Some(resumed_thread_id))
|
||||
.then(|| self.chat_widget.capture_thread_input_state())
|
||||
.flatten();
|
||||
if self.chat_widget.thread_id() == Some(resumed_thread_id) {
|
||||
// The successful resume has already subscribed this connection to the same thread.
|
||||
self.shutdown_side_threads(app_server).await;
|
||||
} else {
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
}
|
||||
self.local_settings = local_settings;
|
||||
self.config = resume_config;
|
||||
tui.set_notification_settings(
|
||||
self.local_settings.tui.notification_settings.method,
|
||||
self.local_settings.tui.notification_settings.condition,
|
||||
);
|
||||
self.file_search
|
||||
.update_search_dir(self.config.cwd.to_path_buf());
|
||||
match self
|
||||
.replace_chat_widget_with_app_server_thread(
|
||||
tui,
|
||||
resumed,
|
||||
ThreadAttachPresentation::SessionLineage,
|
||||
/*initial_user_message*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Some(input) = retained_input {
|
||||
self.chat_widget.restore_thread_input_state(
|
||||
Some(input),
|
||||
ThreadInputStateRestoreMode {
|
||||
preserve_in_flight_turn: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
if read_only {
|
||||
self.ensure_thread_channel(resumed_thread_id)
|
||||
.mark_external_writer();
|
||||
self.chat_widget.show_external_writer_thread();
|
||||
}
|
||||
self.backfill_loaded_subagent_threads(app_server).await;
|
||||
if !read_only {
|
||||
self.replay_agents_overview_requests(app_server, resumed_thread_id)
|
||||
.await;
|
||||
}
|
||||
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);
|
||||
}
|
||||
if !read_only {
|
||||
self.maybe_prompt_resume_paused_goal_after_resume(
|
||||
app_server,
|
||||
resumed_thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.add_session_picker_error(format!(
|
||||
"Failed to attach to resumed app-server thread: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ impl App {
|
||||
);
|
||||
let start_in_agents_overview =
|
||||
matches!(&session_selection, SessionSelection::AgentsOverview);
|
||||
let mut read_only_thread = false;
|
||||
let (mut chat_widget, initial_started_thread) = match session_selection {
|
||||
SessionSelection::StartFresh
|
||||
| SessionSelection::Exit
|
||||
@@ -450,24 +451,50 @@ impl App {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resumed)) => Ok(resumed),
|
||||
Ok(Err(err)) if crate::app_server_session::is_active_writer_error(&err) => {
|
||||
read_only_thread = true;
|
||||
match startup_draft
|
||||
.run_until(
|
||||
tui,
|
||||
app_server.read_thread_for_viewing(
|
||||
&config,
|
||||
&local_settings,
|
||||
target_session.thread_id,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||
}
|
||||
}
|
||||
Ok(resumed) => resumed,
|
||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||
};
|
||||
let action = SessionStartAction::Resume(model_settings);
|
||||
let Some(resumed) = complete_session_start(
|
||||
&mut app_server,
|
||||
&config,
|
||||
&target_session,
|
||||
action,
|
||||
resumed,
|
||||
async || {
|
||||
startup_draft.flush_pending_events(tui).await?;
|
||||
run_unarchive_prompt(tui, target_session.thread_id, action).await
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(cancel_session_start(app_server).await);
|
||||
let resumed = if read_only_thread {
|
||||
match resumed {
|
||||
Ok(resumed) => resumed,
|
||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||
}
|
||||
} else {
|
||||
let action = SessionStartAction::Resume(model_settings);
|
||||
let Some(resumed) = complete_session_start(
|
||||
&mut app_server,
|
||||
&config,
|
||||
&target_session,
|
||||
action,
|
||||
resumed,
|
||||
async || {
|
||||
startup_draft.flush_pending_events(tui).await?;
|
||||
run_unarchive_prompt(tui, target_session.thread_id, action).await
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(cancel_session_start(app_server).await);
|
||||
};
|
||||
resumed
|
||||
};
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
local_settings: local_settings.clone(),
|
||||
@@ -691,6 +718,9 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
if started.blocks_direct_input {
|
||||
app.mark_primary_thread_parent_owned(thread_id);
|
||||
}
|
||||
if read_only_thread {
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
}
|
||||
match startup_draft
|
||||
.run_until(
|
||||
tui,
|
||||
@@ -701,7 +731,12 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
Ok(result) => result?,
|
||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||
}
|
||||
if should_prompt_for_paused_goal_after_startup_resume
|
||||
if read_only_thread {
|
||||
app.ensure_thread_channel(thread_id).mark_external_writer();
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
}
|
||||
if !read_only_thread
|
||||
&& should_prompt_for_paused_goal_after_startup_resume
|
||||
&& let Err(err) = startup_draft
|
||||
.run_until(
|
||||
tui,
|
||||
|
||||
@@ -568,11 +568,7 @@ async fn resolved_buffered_approval_does_not_become_actionable_after_drain() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let initial_prompt = "follow-up after replay".to_string();
|
||||
fn set_test_initial_prompt(app: &mut App, initial_prompt: String) {
|
||||
let config = app.config.clone();
|
||||
let model = get_model_offline_for_tests(config.model.as_deref());
|
||||
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
|
||||
@@ -583,7 +579,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
|
||||
app_event_tx: app.app_event_tx.clone(),
|
||||
workspace_command_runner: None,
|
||||
initial_user_message: create_initial_user_message(
|
||||
Some(initial_prompt.clone()),
|
||||
Some(initial_prompt),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
),
|
||||
@@ -602,6 +598,14 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
|
||||
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
|
||||
session_telemetry: app.session_telemetry.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let initial_prompt = "follow-up after replay".to_string();
|
||||
set_test_initial_prompt(&mut app, initial_prompt.clone());
|
||||
|
||||
app.enqueue_primary_thread_session(
|
||||
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
||||
|
||||
@@ -1,6 +1,95 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_view_quits_with_escape_ctrl_c_or_q() -> Result<()> {
|
||||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
while events.try_recv().is_ok() {}
|
||||
|
||||
app.keymap.app.open_external_editor = vec![crate::key_hint::ctrl(KeyCode::Char('g'))];
|
||||
app.handle_key_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
app.chat_widget.external_editor_state(),
|
||||
ExternalEditorState::Closed
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.try_recv()
|
||||
.is_ok_and(|event| matches!(event, AppEvent::LaunchExternalEditor))
|
||||
);
|
||||
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
|
||||
] {
|
||||
app.handle_key_event(&mut tui, &mut app_server, key).await;
|
||||
assert!(matches!(
|
||||
events.try_recv(),
|
||||
Ok(AppEvent::Exit(ExitMode::Immediate))
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_view_preserves_draft_from_keys_and_paste() -> Result<()> {
|
||||
let (mut app, _events, _operations) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.enqueue_primary_thread_session(
|
||||
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
app.app_server_target = AppServerTarget::Remote {
|
||||
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: "wss://example.com/".to_string(),
|
||||
auth_token: None,
|
||||
},
|
||||
};
|
||||
app.ensure_thread_channel(thread_id).mark_external_writer();
|
||||
app.chat_widget.insert_str("Retained draft");
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
|
||||
for offline in [false, true] {
|
||||
app.reconnect.offline = offline;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Paste("pasted".into()))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Retained draft"
|
||||
);
|
||||
}
|
||||
|
||||
app.reconnect.offline = false;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
|
||||
)
|
||||
.await?;
|
||||
assert!(app.overlay.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daemon_disconnect_exit_summary_includes_reconnect_and_stop_instructions() -> Result<()> {
|
||||
let (mut app, _, _) = make_test_app_with_channels().await;
|
||||
|
||||
@@ -41,6 +41,63 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
pub(super) type RecordedRequests = Arc<Mutex<Vec<JSONRPCRequest>>>;
|
||||
pub(super) type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle<Result<()>>);
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_thread_retry_keeps_subscription_and_restores_draft() -> Result<()> {
|
||||
let (mut app, codex_home) = make_history_test_app().await?;
|
||||
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 rollout"),
|
||||
)?;
|
||||
let path = Some(rollout_path(
|
||||
codex_home.path(),
|
||||
"2026-01-01T00-00-00",
|
||||
&thread_id.to_string(),
|
||||
));
|
||||
let (mut app_server, requests, proxy) = start_recording_app_server(
|
||||
&app.config,
|
||||
/*blocked_thread_list*/ None,
|
||||
/*failed_thread_name*/ None,
|
||||
)
|
||||
.await?;
|
||||
app.enqueue_primary_thread_session(
|
||||
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
app.ensure_thread_channel(thread_id).mark_external_writer();
|
||||
app.chat_widget.insert_str("Retained draft");
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf());
|
||||
requests.lock().expect("request recorder lock").clear();
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.resume_target_session(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
crate::resume_picker::SessionTarget {
|
||||
path,
|
||||
thread_id,
|
||||
history_mode: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(recorded_params(&requests, "thread/resume").len(), 1);
|
||||
assert!(recorded_params(&requests, "thread/unsubscribe").is_empty());
|
||||
assert!(!app.chat_widget.is_external_writer_view());
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Retained draft"
|
||||
);
|
||||
proxy.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum HistoryCapabilities {
|
||||
Current,
|
||||
|
||||
@@ -1366,3 +1366,71 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread(
|
||||
assert!(!ignored);
|
||||
assert!(app.transcript_cells.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignore_same_thread_resume_allows_retrying_read_only_view() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||||
app.chat_widget.handle_thread_session(session.clone());
|
||||
let mut channel = ThreadEventChannel::new_with_session(
|
||||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||||
session.clone(),
|
||||
Vec::new(),
|
||||
);
|
||||
channel.mark_external_writer();
|
||||
app.thread_event_channels.insert(thread_id, channel);
|
||||
app.activate_thread_channel(thread_id).await;
|
||||
|
||||
assert!(
|
||||
!app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget {
|
||||
path: Some(test_path_buf("/tmp/project")),
|
||||
thread_id,
|
||||
history_mode: None,
|
||||
})
|
||||
);
|
||||
let app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.render_thread_snapshot(
|
||||
&mut tui,
|
||||
&app_server,
|
||||
thread_id,
|
||||
ThreadEventSnapshot {
|
||||
session: Some(session),
|
||||
turns: vec![test_turn("running", TurnStatus::InProgress, Vec::new())],
|
||||
events: Vec::new(),
|
||||
input_state: None,
|
||||
},
|
||||
/*resume_restored_queue*/ false,
|
||||
)?;
|
||||
assert!(app.chat_widget.is_external_writer_view());
|
||||
assert!(!app.chat_widget.is_task_running_for_test());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_startup_keeps_initial_prompt_as_draft() -> Result<()> {
|
||||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let prompt = "Continue after the other app closes";
|
||||
set_test_initial_prompt(&mut app, prompt.to_string());
|
||||
app.chat_widget.show_external_writer_thread();
|
||||
app.enqueue_primary_thread_session(
|
||||
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), prompt);
|
||||
assert!(!std::iter::from_fn(|| events.try_recv().ok()).any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
AppEvent::SubmitThreadOp {
|
||||
op: Op::UserTurn { .. },
|
||||
..
|
||||
} | AppEvent::CodexOp(Op::UserTurn { .. })
|
||||
)
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ pub(super) struct FeedbackThreadEvent {
|
||||
pub(super) enum ThreadEventAttachment {
|
||||
Live,
|
||||
ReplayOnly,
|
||||
ExternalWriter,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -386,6 +387,10 @@ impl ThreadEventChannel {
|
||||
self.attachment = ThreadEventAttachment::ReplayOnly;
|
||||
}
|
||||
|
||||
pub(super) fn mark_external_writer(&mut self) {
|
||||
self.attachment = ThreadEventAttachment::ExternalWriter;
|
||||
}
|
||||
|
||||
pub(super) fn attachment(&self) -> ThreadEventAttachment {
|
||||
self.attachment
|
||||
}
|
||||
|
||||
@@ -16,10 +16,7 @@ use codex_app_server_protocol::WarningNotification;
|
||||
|
||||
impl App {
|
||||
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
|
||||
let side_thread_ids: Vec<ThreadId> = self.side_threads.keys().copied().collect();
|
||||
for side_thread_id in side_thread_ids {
|
||||
self.discard_side_thread(app_server, side_thread_id).await;
|
||||
}
|
||||
self.shutdown_side_threads(app_server).await;
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
|
||||
tracing::warn!("failed to unsubscribe thread {thread_id}: {err}");
|
||||
@@ -28,6 +25,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn shutdown_side_threads(&mut self, app_server: &mut AppServerSession) {
|
||||
let side_thread_ids: Vec<ThreadId> = self.side_threads.keys().copied().collect();
|
||||
for side_thread_id in side_thread_ids {
|
||||
self.discard_side_thread(app_server, side_thread_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn abort_thread_event_listener(&mut self, thread_id: ThreadId) {
|
||||
if let Some(handle) = self.thread_event_listener_tasks.remove(&thread_id) {
|
||||
handle.abort();
|
||||
@@ -175,7 +179,12 @@ impl App {
|
||||
&mut self,
|
||||
target_session: &crate::resume_picker::SessionTarget,
|
||||
) -> bool {
|
||||
if self.active_thread_id != Some(target_session.thread_id) {
|
||||
if self.active_thread_id != Some(target_session.thread_id)
|
||||
|| self
|
||||
.thread_event_channels
|
||||
.get(&target_session.thread_id)
|
||||
.is_some_and(|channel| channel.attachment() != ThreadEventAttachment::Live)
|
||||
{
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -445,7 +454,7 @@ impl App {
|
||||
) -> Result<()> {
|
||||
if self.thread_unavailable(thread_id) {
|
||||
self.chat_widget.add_error_message(
|
||||
"This conversation is unavailable; no operation was sent.".into(),
|
||||
"This conversation is read-only or unavailable; no operation was sent.".into(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -349,6 +349,11 @@ pub(crate) struct AppServerStartedThread {
|
||||
pub(crate) task_tools_available: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn is_active_writer_error(err: &color_eyre::eyre::Report) -> bool {
|
||||
err.chain()
|
||||
.any(|cause| cause.to_string().contains("already has an active writer"))
|
||||
}
|
||||
|
||||
pub(crate) fn source_agent_path(source: &SessionSource) -> Option<String> {
|
||||
match source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => {
|
||||
|
||||
@@ -9,8 +9,10 @@ use super::bootstrap_request_error;
|
||||
use super::is_history_pagination_unsupported;
|
||||
use super::started_thread_from_resume_response;
|
||||
use super::thread_resume_params_from_config;
|
||||
use super::thread_session_state_from_thread_response;
|
||||
use crate::legacy_core::config::Config;
|
||||
use codex_app_server_client::TypedRequestError;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ThreadHistoryMode;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
@@ -19,6 +21,53 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use color_eyre::eyre::Result;
|
||||
|
||||
impl AppServerSession {
|
||||
/// Read a conflicting thread without taking its writer lease. This is a snapshot, not an
|
||||
/// attachment: the caller must not route turns through this session.
|
||||
pub(crate) async fn read_thread_for_viewing(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
local_settings: &crate::local_settings::LocalSettings,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<AppServerStartedThread> {
|
||||
let mut thread = self.thread_read(thread_id, /*include_turns*/ false).await?;
|
||||
self.hydrate_initial_thread_history(
|
||||
&mut thread,
|
||||
/*turn_cursor*/ None,
|
||||
/*item_cursor*/ None,
|
||||
Some(config),
|
||||
Some(local_settings),
|
||||
HistoryHydrationScope::Initial,
|
||||
)
|
||||
.await?;
|
||||
let session = thread_session_state_from_thread_response(
|
||||
&thread.id,
|
||||
thread.forked_from_id.clone(),
|
||||
thread.name.clone(),
|
||||
thread.path.clone(),
|
||||
thread.model.clone().unwrap_or_default(),
|
||||
thread.model_provider.clone(),
|
||||
config.service_tier.clone(),
|
||||
AskForApproval::from(config.permissions.approval_policy.value()),
|
||||
config.approvals_reviewer,
|
||||
config.permissions.permission_profile().clone(),
|
||||
config.permissions.active_permission_profile(),
|
||||
thread.cwd.clone(),
|
||||
config.workspace_roots.clone(),
|
||||
Vec::new(),
|
||||
thread.reasoning_effort,
|
||||
config.personality,
|
||||
local_settings,
|
||||
)
|
||||
.await
|
||||
.map_err(color_eyre::eyre::Report::msg)?;
|
||||
Ok(AppServerStartedThread {
|
||||
session,
|
||||
turns: thread.turns,
|
||||
blocks_direct_input: false,
|
||||
task_tools_available: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn with_local_codex_home(mut self, codex_home: &AbsolutePathBuf) -> Self {
|
||||
self.task_tool_capabilities_dir = (!self.uses_embedded_app_server())
|
||||
.then(|| codex_home.join("tui-thread-reference-capabilities"));
|
||||
|
||||
@@ -19,6 +19,49 @@ async fn build_config(temp_dir: &TempDir) -> Config {
|
||||
.expect("config should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn viewing_thread_reads_history_without_resuming_it() -> Result<()> {
|
||||
let codex_home = tempfile::tempdir()?;
|
||||
let config = build_config(&codex_home).await;
|
||||
let thread_id = ThreadId::from_string(
|
||||
&create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved user message",
|
||||
Some(config.model_provider_id.as_str()),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.expect("create source rollout"),
|
||||
)?;
|
||||
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
||||
let next_request_id = app_server.next_request_id;
|
||||
let viewed = app_server
|
||||
.read_thread_for_viewing(
|
||||
&config,
|
||||
&crate::local_settings::LocalSettings::from(&config),
|
||||
thread_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(viewed.session.thread_id, thread_id);
|
||||
assert!(!viewed.turns.is_empty());
|
||||
assert!(!viewed.blocks_direct_input);
|
||||
assert!(app_server.next_request_id > next_request_id);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_active_writer_failures_offer_read_only_view() {
|
||||
let conflict = color_eyre::eyre::eyre!("thread already has an active writer (code -32600)");
|
||||
let unrelated = color_eyre::eyre::eyre!("thread not found (code -32600)");
|
||||
assert!(crate::app_server_session::is_active_writer_error(&conflict));
|
||||
assert!(!crate::app_server_session::is_active_writer_error(
|
||||
&unrelated
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_resume_preserves_history_mode_after_picker_server_replacement() -> Result<()> {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
@@ -870,7 +870,7 @@ pub(crate) fn footer_hint_items_width(items: &[(String, String)]) -> u16 {
|
||||
footer_hint_items_line(items).width() as u16
|
||||
}
|
||||
|
||||
fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> {
|
||||
pub(crate) fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> {
|
||||
let mut spans = Vec::with_capacity(items.len() * 4);
|
||||
for (idx, (key, label)) in items.iter().enumerate() {
|
||||
spans.push(" ".into());
|
||||
|
||||
@@ -475,6 +475,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn transcript_shortcut_hint(&self) -> Option<crate::key_hint::ShortcutHint> {
|
||||
self.keymap
|
||||
.primary_hint(KeymapContext::Global, "open_transcript")
|
||||
}
|
||||
|
||||
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
|
||||
pub(crate) fn drain_pending_submission_state(&mut self) {
|
||||
let _ = self.take_recent_submission_images_with_placeholders();
|
||||
|
||||
@@ -690,6 +690,7 @@ pub(crate) struct ChatWidget {
|
||||
thread_rename_block_message: Option<String>,
|
||||
active_side_conversation: bool,
|
||||
blocks_direct_input: bool,
|
||||
external_writer_view: bool,
|
||||
misalignment_policy_violation: Option<misalignment_policy::MisalignmentViolation>,
|
||||
normal_placeholder_text: String,
|
||||
side_placeholder_text: String,
|
||||
@@ -1746,6 +1747,25 @@ impl ChatWidget {
|
||||
self.bottom_pane.clear_esc_backtrack_hint();
|
||||
}
|
||||
|
||||
pub(crate) fn show_external_writer_thread(&mut self) {
|
||||
self.blocks_direct_input = true;
|
||||
self.external_writer_view = true;
|
||||
self.pause_unavailable_thread();
|
||||
if let Some(cell) = self.transcript.active_cell.as_mut() {
|
||||
if let Some(exec) = cell.as_any_mut().downcast_mut::<ExecCell>() {
|
||||
exec.freeze_snapshot();
|
||||
} else if let Some(tool) = cell.as_any_mut().downcast_mut::<McpToolCallCell>() {
|
||||
tool.freeze_snapshot();
|
||||
}
|
||||
}
|
||||
self.bottom_pane
|
||||
.set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None);
|
||||
}
|
||||
|
||||
pub(crate) fn is_external_writer_view(&self) -> bool {
|
||||
self.external_writer_view
|
||||
}
|
||||
|
||||
fn refresh_skills_for_current_cwd(&mut self, force_reload: bool) {
|
||||
self.submit_op(AppCommand::list_skills(
|
||||
vec![self.config.cwd.to_path_buf()],
|
||||
@@ -1768,7 +1788,12 @@ impl ChatWidget {
|
||||
AppCommand::UserTurn { .. } | AppCommand::Review { .. } | AppCommand::Compact
|
||||
)
|
||||
{
|
||||
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
|
||||
self.add_error_message(if self.external_writer_view {
|
||||
"This thread is open elsewhere. Close it there and retry resume to continue."
|
||||
.to_string()
|
||||
} else {
|
||||
PARENT_OWNED_INPUT_MESSAGE.to_string()
|
||||
});
|
||||
return false;
|
||||
}
|
||||
self.prepare_local_op_submission(&op);
|
||||
|
||||
@@ -217,6 +217,7 @@ impl ChatWidget {
|
||||
thread_rename_block_message: None,
|
||||
active_side_conversation: false,
|
||||
blocks_direct_input: false,
|
||||
external_writer_view: false,
|
||||
misalignment_policy_violation: None,
|
||||
normal_placeholder_text: placeholder,
|
||||
side_placeholder_text: side_placeholder,
|
||||
|
||||
@@ -265,7 +265,12 @@ impl ChatWidget {
|
||||
mut collaboration_mode: CollaborationModeMask,
|
||||
) {
|
||||
if self.blocks_direct_input {
|
||||
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
|
||||
self.add_error_message(if self.external_writer_view {
|
||||
"This thread is open elsewhere. Close it there and retry resume to continue."
|
||||
.to_string()
|
||||
} else {
|
||||
PARENT_OWNED_INPUT_MESSAGE.to_string()
|
||||
});
|
||||
return;
|
||||
}
|
||||
if collaboration_mode.mode == Some(ModeKind::Plan)
|
||||
|
||||
@@ -271,7 +271,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn can_launch_external_editor(&self) -> bool {
|
||||
self.bottom_pane.can_launch_external_editor()
|
||||
!self.external_writer_view && self.bottom_pane.can_launch_external_editor()
|
||||
}
|
||||
|
||||
pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool {
|
||||
@@ -512,6 +512,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
if self.external_writer_view {
|
||||
return;
|
||||
}
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
|
||||
if self.external_writer_view {
|
||||
return;
|
||||
}
|
||||
if self.handle_question_key(key) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,121 @@
|
||||
use super::transcript::ActiveCellLayoutCache;
|
||||
use super::transcript::ActiveCellLayoutCacheKey;
|
||||
use super::*;
|
||||
use crate::render::RectExt;
|
||||
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use ratatui::style::Styled as _;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use std::cell::Cell;
|
||||
|
||||
struct ExternalWriterNotice {
|
||||
transcript_hint: Option<crate::key_hint::ShortcutHint>,
|
||||
}
|
||||
|
||||
impl Renderable for ExternalWriterNotice {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let content_width = area.width.saturating_sub(/*rhs*/ 4);
|
||||
let card_lines = self.card_lines(content_width);
|
||||
let card_height = (card_lines.len() as u16).saturating_add(/*rhs*/ 2);
|
||||
let card = Rect::new(area.x, area.y, area.width, card_height.min(area.height));
|
||||
Widget::render(
|
||||
Block::default().style(crate::style::user_message_style()),
|
||||
card,
|
||||
buf,
|
||||
);
|
||||
let content = card.inset(Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 2, /*bottom*/ 1, /*right*/ 2,
|
||||
));
|
||||
Renderable::render(&Paragraph::new(card_lines), content, buf);
|
||||
let footer_y = card.bottom();
|
||||
if footer_y < area.bottom() {
|
||||
let footer = Rect::new(
|
||||
area.x.saturating_add(/*rhs*/ 2),
|
||||
footer_y,
|
||||
area.width.saturating_sub(/*rhs*/ 2),
|
||||
area.bottom().saturating_sub(footer_y),
|
||||
);
|
||||
Renderable::render(
|
||||
&Paragraph::new(self.footer_lines(footer.width)),
|
||||
footer,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
(self.card_lines(width.saturating_sub(/*rhs*/ 4)).len() as u16)
|
||||
.saturating_add(/*rhs*/ 2)
|
||||
.saturating_add(self.footer_lines(width.saturating_sub(/*rhs*/ 2)).len() as u16)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalWriterNotice {
|
||||
fn card_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let title: Line<'static> = vec![
|
||||
"🔒".into(),
|
||||
" ".into(),
|
||||
"This conversation is open in another app".bold(),
|
||||
]
|
||||
.into();
|
||||
let retry: Line<'static> = vec![
|
||||
Span::styled("R", crate::style::accent_style()),
|
||||
" to Retry".into(),
|
||||
]
|
||||
.into();
|
||||
let mut lines = word_wrap_lines(&[title], usize::from(width));
|
||||
if lines.len() == 1 && lines[0].width() + retry.width() + 2 <= usize::from(width) {
|
||||
let gap = usize::from(width) - lines[0].width() - retry.width();
|
||||
lines[0].spans.push(" ".repeat(gap).into());
|
||||
lines[0].spans.extend(retry.spans);
|
||||
} else {
|
||||
lines.push(retry);
|
||||
}
|
||||
lines.extend(word_wrap_lines(
|
||||
&[Line::from(
|
||||
"Close it there and press R to continue here.".dim(),
|
||||
)],
|
||||
RtOptions::new(usize::from(width))
|
||||
.initial_indent(" ".into())
|
||||
.subsequent_indent(" ".into()),
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut items = vec![
|
||||
("r".to_string(), "retry".to_string()),
|
||||
(
|
||||
format!(
|
||||
"{}/{}/{}",
|
||||
crate::key_hint::plain(KeyCode::Esc).display_label(),
|
||||
crate::key_hint::ctrl(KeyCode::Char('c')).display_label(),
|
||||
crate::key_hint::plain(KeyCode::Char('q')).display_label(),
|
||||
)
|
||||
.replace(" + ", "+"),
|
||||
"exit".to_string(),
|
||||
),
|
||||
];
|
||||
if let Some(hint) = self.transcript_hint {
|
||||
items.push((
|
||||
hint.display_label().replace(" + ", "+"),
|
||||
"transcript".to_string(),
|
||||
));
|
||||
}
|
||||
let mut spans = vec![" ".set_style(crate::style::footer_hint_label_style())];
|
||||
for (idx, (key, label)) in items.into_iter().enumerate() {
|
||||
if idx > 0 {
|
||||
spans.push(" ".set_style(crate::style::footer_hint_label_style()));
|
||||
}
|
||||
spans.push(key.set_style(crate::style::footer_hint_key_style()));
|
||||
spans.push(format!(" {label}").set_style(crate::style::footer_hint_label_style()));
|
||||
}
|
||||
word_wrap_lines(&[Line::from(spans)], usize::from(width))
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn as_renderable(&self) -> RenderableItem<'_> {
|
||||
if self
|
||||
@@ -68,13 +180,19 @@ impl ChatWidget {
|
||||
})),
|
||||
);
|
||||
}
|
||||
flex.push(
|
||||
/*flex*/ 0,
|
||||
let bottom = if self.external_writer_view && !self.bottom_pane.has_active_view() {
|
||||
RenderableItem::Owned(Box::new(ExternalWriterNotice {
|
||||
transcript_hint: self.bottom_pane.transcript_shortcut_hint(),
|
||||
}))
|
||||
} else {
|
||||
self.bottom_pane
|
||||
.as_renderable_with_composer_right_reserve(active_cell_right_reserve)
|
||||
.inset(Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
||||
)),
|
||||
};
|
||||
flex.push(
|
||||
/*flex*/ 0,
|
||||
bottom.inset(Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
||||
)),
|
||||
);
|
||||
RenderableItem::Owned(Box::new(flex))
|
||||
}
|
||||
|
||||
@@ -75,6 +75,60 @@ fn contains_text(buffer: &Buffer, text: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_view_shows_notice_instead_of_composer() {
|
||||
let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await;
|
||||
widget.show_external_writer_thread();
|
||||
|
||||
let frame = crate::terminal_palette::with_test_default_colors(
|
||||
crate::terminal_probe::DefaultColors {
|
||||
fg: (230, 230, 230),
|
||||
bg: (20, 20, 20),
|
||||
},
|
||||
|| render_frame(&widget, /*width*/ 60),
|
||||
);
|
||||
let rows: Vec<String> = frame
|
||||
.content
|
||||
.chunks(usize::from(frame.area.width))
|
||||
.map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect())
|
||||
.collect();
|
||||
insta::assert_snapshot!(
|
||||
rows.iter()
|
||||
.map(|row| row.trim_end())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
assert_ne!(frame[(0, 1)].bg, ratatui::style::Color::Reset);
|
||||
assert_eq!(frame[(0, 1)].bg, frame[(59, 4)].bg);
|
||||
assert!(
|
||||
!frame[(3, 5)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.intersects(ratatui::style::Modifier::DIM | ratatui::style::Modifier::BOLD)
|
||||
);
|
||||
assert!(
|
||||
frame[(5, 5)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::DIM)
|
||||
);
|
||||
assert!(!widget.bottom_pane.composer_input_enabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_notice_uses_current_transcript_shortcut() {
|
||||
let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await;
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
keymap.app.open_transcript = vec![crate::key_hint::ctrl(KeyCode::Char('k'))];
|
||||
widget.bottom_pane.set_keymap_bindings(&keymap);
|
||||
widget.show_external_writer_thread();
|
||||
|
||||
assert!(contains_text(
|
||||
&render_frame(&widget, /*width*/ 80),
|
||||
"ctrl+k transcript"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_transcript_preserves_clipped_markdown_hyperlinks() {
|
||||
let cell = history_cell::AgentMarkdownCell::new(
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/chatwidget/rendering_tests.rs
|
||||
expression: "rows.iter().map(|row| row.trim_end()).collect::<Vec<_>>().join(\"\\n\")"
|
||||
---
|
||||
|
||||
|
||||
🔒 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
|
||||
@@ -1,6 +1,51 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_writer_snapshot_freezes_active_command_and_mcp_rows() {
|
||||
let mut rendered = Vec::new();
|
||||
for active_mcp in [false, true] {
|
||||
let (mut chat, _events, _operations) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.on_task_started();
|
||||
if active_mcp {
|
||||
chat.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call(
|
||||
"mcp-running".to_string(),
|
||||
McpInvocation {
|
||||
server: "server".to_string(),
|
||||
tool: "tool".to_string(),
|
||||
arguments: None,
|
||||
},
|
||||
/*animations_enabled*/ true,
|
||||
)));
|
||||
} else {
|
||||
begin_exec(&mut chat, "call-running", "sleep 5");
|
||||
}
|
||||
|
||||
chat.show_external_writer_thread();
|
||||
|
||||
assert!(!chat.is_task_running_for_test());
|
||||
let cell = chat
|
||||
.transcript
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.expect("active tool row");
|
||||
rendered.push(lines_to_single_string(&cell.display_lines(/*width*/ 80)));
|
||||
if active_mcp {
|
||||
assert!(cell.transcript_animation_tick().is_none());
|
||||
} else {
|
||||
assert!(
|
||||
!cell
|
||||
.as_any()
|
||||
.downcast_ref::<ExecCell>()
|
||||
.expect("active command row")
|
||||
.animations_enabled()
|
||||
);
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!(rendered.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_command_completion_preserves_tracking_without_duplicate_starts() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/exec_flow.rs
|
||||
expression: "rendered.join(\"\\n---\\n\")"
|
||||
---
|
||||
• Running sleep 5
|
||||
|
||||
---
|
||||
• Calling server.tool()
|
||||
@@ -179,6 +179,10 @@ impl ExecCell {
|
||||
self.animations_enabled
|
||||
}
|
||||
|
||||
pub(crate) fn freeze_snapshot(&mut self) {
|
||||
self.animations_enabled = false;
|
||||
}
|
||||
|
||||
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
|
||||
self.calls.iter()
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@ impl McpToolCallCell {
|
||||
self.result = Some(Err("interrupted".to_string()));
|
||||
}
|
||||
|
||||
pub(crate) fn freeze_snapshot(&mut self) {
|
||||
self.animations_enabled = false;
|
||||
}
|
||||
|
||||
fn result_kind(&self) -> McpResultKind {
|
||||
if is_node_repl_backed_server(&self.invocation.server) && self.invocation.tool == "js" {
|
||||
McpResultKind::NodeRepl
|
||||
|
||||
@@ -21,6 +21,8 @@ use crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd a
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::session_resume::resolve_session_thread_id;
|
||||
use crate::status::format_directory_display;
|
||||
use crate::style::footer_hint_key_style;
|
||||
use crate::style::footer_hint_label_style;
|
||||
use crate::terminal_palette::best_color;
|
||||
use crate::terminal_palette::default_bg;
|
||||
use crate::text_formatting::truncate_text;
|
||||
@@ -2630,22 +2632,6 @@ fn fit_footer_hint_refs(
|
||||
Some(spans.into())
|
||||
}
|
||||
|
||||
fn footer_hint_key_style() -> Style {
|
||||
if default_bg().is_some_and(is_light) {
|
||||
Style::default().fg(Color::Black)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_hint_label_style() -> Style {
|
||||
if default_bg().is_some_and(is_light) {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default().dim()
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_hints_width(
|
||||
hints: &[&PickerFooterHint],
|
||||
mode: FooterHintLabelMode,
|
||||
|
||||
@@ -61,6 +61,22 @@ pub(crate) fn accent_style() -> Style {
|
||||
accent_style_for(default_bg())
|
||||
}
|
||||
|
||||
pub(crate) fn footer_hint_key_style() -> Style {
|
||||
if default_bg().is_some_and(is_light) {
|
||||
Style::default().fg(Color::Black)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn footer_hint_label_style() -> Style {
|
||||
if default_bg().is_some_and(is_light) {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default().dim()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the style for a user-authored message using the provided terminal background.
|
||||
pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
|
||||
match terminal_bg {
|
||||
|
||||
Reference in New Issue
Block a user