diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index eb6d181a3f..9881c0e03b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -223,6 +223,7 @@ mod history_pagination; mod history_ui; mod input; mod loaded_threads; +mod misalignment_policy; mod pending_interactive_replay; mod permission_shortcuts; mod pets; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 17fb55e6a0..310eb462e1 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -62,6 +62,13 @@ impl App { } match event { + AppEvent::ReviewMisalignment(review) => { + self.open_misalignment_review(tui, review); + } + AppEvent::ContinueMisalignment(review) => { + self.continue_misalignment(app_server, review).await; + } + AppEvent::CloseMisalignmentReview => self.chat_widget.show_misalignment_policy_precaution(), AppEvent::SkillsListLoaded { ref cwd, .. } | AppEvent::PluginMentionsLoaded { ref cwd, .. } if cwds_differ(cwd, self.config.cwd.as_path()) => {} diff --git a/codex-rs/tui/src/app/misalignment_policy.rs b/codex-rs/tui/src/app/misalignment_policy.rs new file mode 100644 index 0000000000..68e4dbed08 --- /dev/null +++ b/codex-rs/tui/src/app/misalignment_policy.rs @@ -0,0 +1,183 @@ +//! Continue only the displayed, acknowledged misalignment failure. Submission stays outside +//! the ordinary input queue, which must remain blocked until the server accepts the new turn. + +use super::*; +use crate::app_server_session::turn_permissions_overrides; +use crate::chatwidget::MisalignmentReview; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; + +impl App { + pub(super) fn open_misalignment_review( + &mut self, + tui: &mut tui::Tui, + review: Arc, + ) { + if !self.chat_widget.is_current_misalignment_review(&review) { + return; + } + self.chat_widget + .show_misalignment_review_confirmation(Arc::clone(&review)); + let mut lines = Vec::new(); + if let Some(message) = review.continuation_message() { + lines.push(Line::from("Continuation request (quoted)").bold()); + // Keep the submitted text ahead of potentially long findings, without interpreting it. + lines.push(Line::from(format!("{message:?}"))); + lines.push(Line::default()); + } + crate::markdown::append_markdown( + review + .details + .detailed_explanation + .as_deref() + .unwrap_or_default(), + /*width*/ None, + Some(self.chat_widget.config_ref().cwd.as_path()), + &mut lines, + ); + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_static_with_lines( + lines, + "What we detected".to_string(), + self.keymap.pager.clone(), + )); + tui.frame_requester().schedule_frame(); + } + + pub(super) async fn continue_misalignment( + &mut self, + app_server: &mut AppServerSession, + review: Arc, + ) { + if self.chat_widget.is_current_misalignment_review(&review) { + self.chat_widget.show_misalignment_policy_precaution(); + } + // The event store can be newer than the widget while UI actions are queued. + if let Some(channel) = self.thread_event_channels.get(&review.thread_id) { + let store = channel.store.lock().await; + if store.active_turn_id().is_some() { + return; + } + let latest = store.buffer.iter().rev().find_map(|event| { + let ThreadBufferedEvent::Notification(notification) = event else { + return None; + }; + let (turn_id, error) = match notification.as_ref() { + ServerNotification::TurnStarted(_) => return Some(false), + ServerNotification::TurnCompleted(n) if n.turn.status == TurnStatus::Failed => { + (&n.turn.id, n.turn.error.as_ref()) + } + ServerNotification::TurnCompleted(_) => return Some(false), + ServerNotification::Error(n) + if !n.will_retry + && (n.turn_id == review.turn_id + || n.error.codex_error_info + == Some( + AppServerCodexErrorInfo::MisalignmentPolicyViolation, + )) => + { + (&n.turn_id, Some(&n.error)) + } + _ => return None, + }; + let Some(error) = error else { + return Some(false); + }; + if turn_id != &review.turn_id + || error.codex_error_info + != Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) + { + return Some(false); + } + // Empty duplicates do not supersede the latest supplied findings. + error + .misalignment + .as_ref() + .map(|details| details == &review.details) + }); + if latest == Some(false) { + return; + } + } + if self.current_displayed_thread_id() != Some(review.thread_id) + || self + .active_thread_rx + .as_ref() + .is_some_and(|rx| !rx.is_empty()) + || !self.chat_widget.is_current_misalignment_review(&review) + { + return; + } + let Some(message) = review.continuation_message() else { + return; + }; + let config = self.chat_widget.config_ref(); + let permissions_override = Self::turn_permissions_override_from_config( + config, + config.permissions.active_permission_profile().as_ref(), + self.runtime_permission_profile_override + .as_ref() + .and_then(RuntimePermissionProfileOverride::turn_permission_profile), + ); + let Ok((sandbox_policy, permissions)) = + turn_permissions_overrides(permissions_override, config.cwd.as_path()) + else { + self.chat_widget.add_error_message( + "Couldn’t continue this chat. Review its latest status before trying again." + .to_string(), + ); + return; + }; + let result = app_server + .request_handle() + .request_typed::(ClientRequest::TurnStart { + request_id: app_server.next_request_id(), + params: TurnStartParams { + thread_id: review.thread_id.to_string(), + cwd: Some(config.cwd.to_path_buf()), + runtime_workspace_roots: Some( + config.permissions.user_visible_workspace_roots().to_vec(), + ), + approval_policy: Some(config.permissions.approval_policy.value().into()), + approvals_reviewer: Some(config.approvals_reviewer.into()), + sandbox_policy, + permissions, + input: vec![UserInput::Text { + text: message.to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: Some(HashMap::from([( + "misalignment_override".to_string(), + serde_json::json!({"timestamp": chrono::Utc::now().timestamp_millis()}) + .to_string(), + )])), + ..Default::default() + }, + }) + .await; + match result { + Ok(response) => { + let store = &self.ensure_thread_channel(review.thread_id).store; + store.lock().await.active_turn_id = Some(response.turn.id.clone()); + self.chat_widget.handle_server_notification( + ServerNotification::TurnStarted( + codex_app_server_protocol::TurnStartedNotification { + thread_id: review.thread_id.to_string(), + turn: response.turn, + }, + ), + /*replay_kind*/ None, + ); + } + Err(_) => { + // An RPC diagnostic can contain the submitted steer. Keep the review error generic. + self.chat_widget.add_error_message( + "Couldn’t continue this chat. Review its latest status before trying again." + .to_string(), + ); + } + } + } +} diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 7a94472643..bc3c4a12f1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -24,6 +24,8 @@ mod key_chords; mod luna_reserve_recovery_tests; #[path = "tests/mcp_startup.rs"] mod mcp_startup; +#[path = "tests/misalignment_policy_tests.rs"] +mod misalignment_policy; mod model_catalog; #[path = "tests/patch_approval_tests.rs"] mod patch_approval_tests; diff --git a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs new file mode 100644 index 0000000000..48c7bfaf39 --- /dev/null +++ b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs @@ -0,0 +1,209 @@ +use super::session_lifecycle_requests::recorded_params; +use super::session_lifecycle_requests::start_recording_app_server; +use super::*; +use codex_app_server_protocol::MisalignmentErrorDetails; +use codex_app_server_protocol::MisalignmentSteer; +use pretty_assertions::assert_eq; + +fn policy_error() -> AppServerTurnError { + AppServerTurnError { + message: "Chat paused".into(), + codex_error_info: Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation), + additional_details: None, + misalignment: Some(MisalignmentErrorDetails { + error_type: None, + detailed_explanation: Some( + "The proposed action exceeded the request.\n\n".repeat(1_500), + ), + steer: Some(MisalignmentSteer { + message: "Continue **only** within the requested scope.\nDo not edit files.".into(), + }), + }), + } +} + +fn error_notification( + thread_id: ThreadId, + turn_id: &str, + error: AppServerTurnError, +) -> ServerNotification { + ServerNotification::Error(codex_app_server_protocol::ErrorNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.into(), + error, + will_retry: false, + }) +} + +#[tokio::test] +async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> { + for reject in [false, true] { + let (mut app, mut rx, _) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let mut session = if reject { + test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()) + } else { + server.start_thread(&app.config).await?.session + }; + let thread_id = session.thread_id; + // Resend local restrictions restored after reconnect, rather than inheriting server settings. + session.approval_policy = AskForApproval::OnRequest; + session.permission_profile = PermissionProfile::read_only(); + session.active_permission_profile = None; + app.active_thread_id = Some(thread_id); + app.chat_widget.handle_thread_session(session); + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + ); + app.chat_widget.handle_server_notification( + error_notification(thread_id, "failed-turn", policy_error()), + /*replay_kind*/ None, + ); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + let review = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::ReviewMisalignment(review) => Some(review), + _ => None, + }) + .expect("review action"); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event( + &mut tui, + &mut server, + AppEvent::ReviewMisalignment(Arc::clone(&review)), + ) + .await?; + let Some(Overlay::Static(overlay)) = app.overlay.as_mut() else { + panic!("findings overlay") + }; + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 38, /*height*/ 16, + ))?; + terminal.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))?; + insta::assert_snapshot!("misalignment_findings_38", terminal.backend()); + app.overlay = None; + assert!(recorded_params(&requests, "turn/start").is_empty()); + let mut withdrawn = policy_error(); + withdrawn + .misalignment + .as_mut() + .unwrap() + .detailed_explanation = None; + // Reject both a newer turn and withdrawn findings followed by an empty duplicate. + for notifications in [ + vec![turn_completed_notification( + thread_id, + "newer-turn", + TurnStatus::Completed, + )], + vec![ + error_notification(thread_id, &review.turn_id, withdrawn.clone()), + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + error: Some(AppServerTurnError { + misalignment: None, + ..withdrawn + }), + ..test_turn(&review.turn_id, TurnStatus::Failed, Vec::new()) + }, + }), + ], + ] { + for notification in notifications { + app.enqueue_thread_notification(thread_id, notification) + .await?; + } + app.continue_misalignment(&mut server, Arc::clone(&review)) + .await; + assert!(recorded_params(&requests, "turn/start").is_empty()); + app.thread_event_channels.remove(&thread_id); + } + + if reject { + app.continue_misalignment(&mut server, Arc::clone(&review)) + .await; + assert!(app.chat_widget.has_misalignment_policy_violation()); + let errors = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!( + "misalignment_continue_failed", + format!( + "{errors}\n{}", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + ); + server.shutdown().await?; + proxy.await??; + continue; + } + // A settings error is not a new turn and must not silently disable Continue. + app.enqueue_thread_notification( + thread_id, + error_notification( + thread_id, + "settings-update", + AppServerTurnError { + codex_error_info: Some(AppServerCodexErrorInfo::BadRequest), + ..policy_error() + }, + ), + ) + .await?; + app.continue_misalignment(&mut server, Arc::clone(&review)) + .await; + app.continue_misalignment(&mut server, Arc::clone(&review)) + .await; + assert!(!app.chat_widget.has_misalignment_policy_violation()); + let params = recorded_params(&requests, "turn/start"); + let [params] = params.as_slice() else { + panic!("expected one turn/start: {params:?}") + }; + let mut params: codex_app_server_protocol::TurnStartParams = + serde_json::from_value(params.clone())?; + let metadata = params.responsesapi_client_metadata.take().unwrap(); + let override_value: serde_json::Value = + serde_json::from_str(&metadata["misalignment_override"])?; + let timestamp = override_value["timestamp"].as_u64().unwrap(); + assert_eq!(metadata.len(), 1); + assert_eq!(override_value, serde_json::json!({"timestamp": timestamp})); + let config = app.chat_widget.config_ref(); + assert_eq!( + params, + codex_app_server_protocol::TurnStartParams { + thread_id: thread_id.to_string(), + cwd: Some(config.cwd.to_path_buf()), + runtime_workspace_roots: Some( + config.permissions.user_visible_workspace_roots().to_vec() + ), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(config.approvals_reviewer.into()), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false + }), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Continue **only** within the requested scope.\nDo not edit files." + .to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + } + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap new file mode 100644 index 0000000000..54a988af48 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/tests/misalignment_policy_tests.rs +expression: "format!(\"{errors}\\n{}\", render_bottom_popup(&app.chat_widget, 80))" +--- +■ Couldn’t continue this chat. Review its latest status before trying again. + Chat paused as a precaution + We couldn’t confirm the agent was interpreting your instructions correctly. + Review what we detected before deciding to continue. + +› 1. Review findings + 2. New chat + 3. Resume another chat diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap new file mode 100644 index 0000000000..853ddd2089 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/app/tests/misalignment_policy_tests.rs +assertion_line: 75 +expression: terminal.backend() +--- +"/ What we detected/ / / / / / / / / / " +"Continuation request (quoted) " +""Continue **only** within the " +"requested scope.\nDo not edit files." " +" " +"The proposed action exceeded the " +"request. " +" " +"The proposed action exceeded the " +"request. " +" " +"The proposed action exceeded the " +"───────────────────────────────── 0% ─" +" ↑/↓ to scroll pgup/pgdn to page h" +" q close " +" " diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap index 8a310ff6bc..857ca84bd8 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap @@ -12,4 +12,4 @@ expression: text ───────────────────────────────────────────────────────────────────────────────────────────── 100% ─ ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 38c415981b..6ab7269de3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -224,6 +224,9 @@ pub(crate) struct AgentsOverviewThreadRefresh { #[allow(clippy::large_enum_variant)] #[derive(Debug, IntoStaticStr)] pub(crate) enum AppEvent { + ReviewMisalignment(Arc), + ContinueMisalignment(Arc), + CloseMisalignmentReview, /// Open the daemon-wide overview of recent and locally retained root sessions. OpenAgentsOverview, /// Update the daemon-wide overview after a background thread listing finishes. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8737e6f1b6..710be1b8d0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -365,6 +365,7 @@ mod keymap_picker; mod mcp_startup; use self::mcp_startup::McpStartupStatus; mod misalignment_policy; +pub(crate) use misalignment_policy::MisalignmentReview; mod pets; mod session_flow; mod session_header; @@ -691,7 +692,7 @@ pub(crate) struct ChatWidget { thread_rename_block_message: Option, active_side_conversation: bool, blocks_direct_input: bool, - misalignment_policy_violation: bool, + misalignment_policy_violation: Option, normal_placeholder_text: String, side_placeholder_text: String, forked_from: Option, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index fcdf83a9e2..81e927f5bd 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -214,7 +214,7 @@ impl ChatWidget { thread_rename_block_message: None, active_side_conversation: false, blocks_direct_input: false, - misalignment_policy_violation: false, + misalignment_policy_violation: None, normal_placeholder_text: placeholder, side_placeholder_text: side_placeholder, forked_from: None, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 74d872d6c0..471dc8644e 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -110,7 +110,7 @@ impl ChatWidget { action: QueuedInputAction, pending_pastes: Vec<(String, String)>, ) { - if self.misalignment_policy_violation { + if self.has_misalignment_policy_violation() { return; } let should_run_now = self.is_session_configured() @@ -140,7 +140,7 @@ impl ChatWidget { /// If idle and there are queued inputs, submit exactly one to start the next turn. pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { if !self.is_session_configured() - || self.misalignment_policy_violation + || self.has_misalignment_policy_violation() || self.input_queue.suppress_queue_autosend || self.input_queue.rate_limit_recovery_pending || self.input_queue.recovered_queue diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 8a4133e72c..424f21a544 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -113,7 +113,7 @@ impl ChatWidget { history_record: UserMessageHistoryRecord, shell_escape_policy: ShellEscapePolicy, ) -> (bool, Option) { - if self.misalignment_policy_violation { + if self.has_misalignment_policy_violation() { return (false, None); } if self.input_queue.rate_limit_recovery_pending { diff --git a/codex-rs/tui/src/chatwidget/misalignment_policy.rs b/codex-rs/tui/src/chatwidget/misalignment_policy.rs index 143ad59ac9..fcba6416b9 100644 --- a/codex-rs/tui/src/chatwidget/misalignment_policy.rs +++ b/codex-rs/tui/src/chatwidget/misalignment_policy.rs @@ -1,17 +1,43 @@ -//! Terminal precaution UI for misalignment policy violations. +//! Block ordinary input after a misalignment failure. Live findings can be reviewed and +//! explicitly acknowledged; review identity prevents stale UI actions from authorizing a turn. use super::*; +use codex_app_server_protocol::MisalignmentErrorDetails; + +const PRECAUTION_VIEW: &str = "misalignment_precaution"; + +#[derive(Debug)] +pub(crate) struct MisalignmentReview { + pub(crate) thread_id: ThreadId, + pub(crate) turn_id: String, + pub(crate) details: MisalignmentErrorDetails, +} + +impl MisalignmentReview { + pub(crate) fn continuation_message(&self) -> Option<&str> { + self.details + .steer + .as_ref() + .map(|steer| steer.message.as_str()) + .filter(|message| !message.trim().is_empty() && message.len() <= 1024) + } +} + +pub(super) struct MisalignmentViolation { + turn_id: Option, + review: Option>, +} const MISALIGNMENT_POLICY_TITLE: &str = "Chat stopped as a precaution"; const MISALIGNMENT_POLICY_DESCRIPTION: &str = "We couldn’t confirm the agent was acting safely and following your instructions. To continue working, start or resume another chat."; impl ChatWidget { pub(crate) fn has_misalignment_policy_violation(&self) -> bool { - self.misalignment_policy_violation + self.misalignment_policy_violation.is_some() } pub(crate) fn rejects_misalignment_policy_op(&self, op: &AppCommand) -> bool { - self.misalignment_policy_violation + self.misalignment_policy_violation.is_some() && !matches!( op, AppCommand::Interrupt @@ -24,11 +50,46 @@ impl ChatWidget { } pub(crate) fn on_misalignment_policy_violation(&mut self) { - if self.misalignment_policy_violation { + self.on_misalignment_error(/*turn_id*/ None, /*details*/ None); + } + + pub(super) fn on_misalignment_error( + &mut self, + turn_id: Option, + details: Option, + ) { + if let (Some(turn_id), Some(latest)) = (&turn_id, &self.turn_lifecycle.last_turn_id) + && turn_id != latest + { return; } - - self.misalignment_policy_violation = true; + // Bound memory-only findings and newly submitted model input. Never truncate a steer. + let missing_details = details.is_none(); + let details = details.filter(|details| { + details + .detailed_explanation + .as_ref() + .is_some_and(|text| !text.trim().is_empty() && text.len() <= 64 * 1024) + }); + if let Some(current) = &self.misalignment_policy_violation + // A parent-thread precaution cannot become a continuable side-thread warning. + && (current.turn_id.is_none() + || (current.turn_id == turn_id + && (missing_details + || current.review.as_ref().map(|review| &review.details) == details.as_ref()))) + { + return; + } + let review = self.thread_id.zip(turn_id.clone()).zip(details).map( + |((thread_id, turn_id), details)| { + Arc::new(MisalignmentReview { + thread_id, + turn_id, + details, + }) + }, + ); + self.misalignment_policy_violation = Some(MisalignmentViolation { turn_id, review }); self.input_queue.clear(); self.finalize_turn(); self.refresh_pending_input_preview(); @@ -44,6 +105,10 @@ impl ChatWidget { } pub(crate) fn show_misalignment_policy_precaution(&mut self) { + if !self.has_misalignment_policy_violation() { + return; + } + self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW); let mut items = vec![ SelectionItem { name: "New chat".to_string(), @@ -57,6 +122,23 @@ impl ChatWidget { ..Default::default() }, ]; + let review = self + .misalignment_policy_violation + .as_ref() + .and_then(|violation| violation.review.clone()); + if let Some(review) = &review { + let review = Arc::clone(review); + items.insert( + 0, + SelectionItem { + name: "Review findings".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ReviewMisalignment(Arc::clone(&review))); + })], + ..Default::default() + }, + ); + } if self.remote_connection.is_some() { items.insert( 1, @@ -68,10 +150,13 @@ impl ChatWidget { ); } self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(PRECAUTION_VIEW), header: Box::new( Paragraph::new(vec![ - Line::from(MISALIGNMENT_POLICY_TITLE).bold(), - Line::from(MISALIGNMENT_POLICY_DESCRIPTION).dim(), + Line::from(if review.is_some() { "Chat paused as a precaution" } else { MISALIGNMENT_POLICY_TITLE }).bold(), + Line::from(if review.is_some() { + "We couldn’t confirm the agent was interpreting your instructions correctly. Review what we detected before deciding to continue." + } else { MISALIGNMENT_POLICY_DESCRIPTION }).dim(), ]) .wrap(Wrap { trim: false }), ), @@ -80,4 +165,62 @@ impl ChatWidget { ..Default::default() }); } + + pub(crate) fn is_current_misalignment_review(&self, review: &Arc) -> bool { + let current = self + .misalignment_policy_violation + .as_ref() + .and_then(|violation| violation.review.as_ref()); + let turn_id = self.turn_lifecycle.last_turn_id.as_ref(); + self.thread_id == Some(review.thread_id) + && turn_id.is_none_or(|id| id == &review.turn_id) + && current.is_some_and(|current| Arc::ptr_eq(current, review)) + } + + pub(crate) fn show_misalignment_review_confirmation( + &mut self, + review: Arc, + ) { + if !self.is_current_misalignment_review(&review) { + return; + } + let can_continue = review.continuation_message().is_some(); + self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW); + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(PRECAUTION_VIEW), + title: Some("Chat paused as a precaution".to_string()), + items: vec![ + SelectionItem { + name: "Acknowledge findings and continue".to_string(), + is_disabled: !can_continue, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ContinueMisalignment(Arc::clone(&review))); + })], + require_explicit_confirmation: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Back".to_string(), + actions: vec![Box::new(|tx| tx.send(AppEvent::CloseMisalignmentReview))], + ..Default::default() + }, + ], + allow_cancel: false, + ..Default::default() + }); + } + + pub(crate) fn clear_misalignment_for_new_turn(&mut self, turn_id: &str) { + if self + .misalignment_policy_violation + .as_ref() + .is_some_and(|violation| violation.turn_id.as_ref().is_some_and(|id| id != turn_id)) + { + self.misalignment_policy_violation = None; + self.bottom_pane.dismiss_view_by_id(PRECAUTION_VIEW); + self.bottom_pane + .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); + } + } } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index f512275180..14e70f8ffa 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -60,6 +60,9 @@ impl ChatWidget { self.on_thread_settings_updated(notification); } ServerNotification::TurnStarted(notification) => { + if replay_kind.is_none() { + self.clear_misalignment_for_new_turn(¬ification.turn.id); + } self.turn_lifecycle.last_turn_id = Some(notification.turn.id); self.last_non_retry_error = None; if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { @@ -136,10 +139,20 @@ impl ChatWidget { notification.turn_id.clone(), notification.error.message.clone(), )); - self.handle_non_retry_error( - notification.error.message, - notification.error.codex_error_info, - ); + if !from_replay + && notification.error.codex_error_info + == Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) + { + self.on_misalignment_error( + Some(notification.turn_id), + notification.error.misalignment, + ); + } else { + self.handle_non_retry_error( + notification.error.message, + notification.error.codex_error_info, + ); + } } } ServerNotification::SkillsChanged(_) => { @@ -330,7 +343,13 @@ impl ChatWidget { } TurnStatus::Failed => { if let Some(error) = notification.turn.error { - if self.last_non_retry_error.as_ref() + if replay_kind.is_none() + && error.codex_error_info + == Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) + { + self.on_misalignment_error(Some(notification.turn.id), error.misalignment); + self.last_non_retry_error = None; + } else if self.last_non_retry_error.as_ref() == Some(&(notification.turn.id.clone(), error.message.clone())) { self.last_non_retry_error = None; diff --git a/codex-rs/tui/src/chatwidget/protocol_requests.rs b/codex-rs/tui/src/chatwidget/protocol_requests.rs index 36b47f6c62..0d7ef630be 100644 --- a/codex-rs/tui/src/chatwidget/protocol_requests.rs +++ b/codex-rs/tui/src/chatwidget/protocol_requests.rs @@ -11,7 +11,7 @@ impl ChatWidget { request: ServerRequest, replay_kind: Option, ) { - if self.misalignment_policy_violation { + if self.has_misalignment_policy_violation() { return; } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_review_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_review_narrow.snap new file mode 100644 index 0000000000..c34d997f51 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__misalignment_review_narrow.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/misalignment_policy_tests.rs +assertion_line: 157 +expression: "render_bottom_popup(&chat, 38)" +--- + Chat paused as a precaution + +› 1. Acknowledge findings and + continue + 2. Back diff --git a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs index ad942cc668..908af55709 100644 --- a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs @@ -123,3 +123,91 @@ async fn misalignment_policy_failure_stops_the_thread_and_renders_once() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenAgentsOverview)); assert!(chat.bottom_pane.has_active_view()); } + +fn review_details() -> codex_app_server_protocol::MisalignmentErrorDetails { + codex_app_server_protocol::MisalignmentErrorDetails { + error_type: Some("new_category".to_string()), + detailed_explanation: Some( + "The agent proposed a change outside your instructions.".to_string(), + ), + steer: Some(codex_app_server_protocol::MisalignmentSteer { + message: "Continue with the requested change only.".to_string(), + }), + } +} + +fn select_review( + chat: &mut ChatWidget, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Arc { + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::ReviewMisalignment(review) => Some(review), + _ => None, + }) + .expect("review action") +} + +#[tokio::test] +async fn misalignment_review_requires_current_findings() { + let (mut chat, mut rx, _) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + handle_turn_started(&mut chat, "turn-1"); + chat.on_misalignment_error(Some("turn-1".to_string()), Some(review_details())); + let review = select_review(&mut chat, &mut rx); + chat.show_misalignment_review_confirmation(Arc::clone(&review)); + assert_chatwidget_snapshot!( + "misalignment_review_narrow", + render_bottom_popup(&chat, /*width*/ 38) + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::ContinueMisalignment(current)) if Arc::ptr_eq(¤t, &review)) + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!(rx.try_recv().is_err()); + + // A duplicate terminal event must not reset an in-progress review. + chat.on_misalignment_error(Some("turn-1".to_string()), Some(review_details())); + chat.on_misalignment_error(Some("turn-1".to_string()), /*details*/ None); + assert!(chat.is_current_misalignment_review(&review)); + let mut changed = review_details(); + changed.detailed_explanation = Some("Updated findings".to_string()); + chat.on_misalignment_error(Some("turn-1".to_string()), Some(changed)); + assert!(!chat.is_current_misalignment_review(&review)); + + // Present-but-invalid findings withdraw the old authorization. + for explanation in [None, Some(" ".to_string()), Some("a".repeat(65537))] { + chat.on_misalignment_error(Some("turn-1".to_string()), Some(review_details())); + let replacement = select_review(&mut chat, &mut rx); + let mut details = review_details(); + details.detailed_explanation = explanation; + chat.on_misalignment_error(Some("turn-1".to_string()), Some(details)); + assert!(!chat.is_current_misalignment_review(&replacement)); + } + + for steer in [None, Some(" ".to_string()), Some("a".repeat(1025))] { + let mut details = review_details(); + details.steer = + steer.map(|message| codex_app_server_protocol::MisalignmentSteer { message }); + chat.on_misalignment_error(Some("turn-1".to_string()), Some(details)); + let review = select_review(&mut chat, &mut rx); + chat.show_misalignment_review_confirmation(Arc::clone(&review)); + assert!(review.continuation_message().is_none()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!( + !std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::ContinueMisalignment(_))) + ); + assert!(!chat.bottom_pane.composer_input_enabled()); + } + handle_turn_started(&mut chat, "turn-2"); + chat.on_misalignment_error(Some("turn-1".to_string()), Some(review_details())); + assert!(!chat.has_misalignment_policy_violation()); + assert!(chat.bottom_pane.composer_input_enabled()); + // A queued Back action cannot reopen the precaution after a new turn starts. + chat.show_misalignment_policy_precaution(); + assert!(!chat.bottom_pane.has_active_view()); +} diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3a52f951d4..9b6c763e3e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -874,7 +874,7 @@ impl TranscriptOverlay { let mut pairs: Vec<(Vec, &str)> = vec![( first_or_empty(&self.view.keymap, "close", &self.view.keymap.close), - "to quit", + "close", )]; if self.highlight_cell.is_some() { pairs.push(( @@ -994,7 +994,7 @@ impl StaticOverlay { render_navigation_hints(line1, buf, &self.view.keymap); let pairs: Vec<(Vec, &str)> = vec![( first_or_empty(&self.view.keymap, "close", &self.view.keymap.close), - "to quit", + "close", )]; render_key_hints(line2, buf, &pairs); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_bottom.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_bottom.snap index a31d4e23cf..6fe82ff124 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_bottom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_bottom.snap @@ -14,7 +14,7 @@ Buffer { " 1 -removed ", "───────────────────────────────────────── 100% ─", " ↑/↓ to scroll pgup/pgdn to page home/end to", - " q to quit ", + " q close ", " ", ], styles: [ @@ -34,6 +34,6 @@ Buffer { x: 4, y: 6, fg: Red, bg: Reset, underline: Reset, modifier: NONE, x: 12, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 10, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_top.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_top.snap index 51f0a48a86..8f87674d9f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_top.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__patch_approval_tests__patch_approval_pager_top.snap @@ -14,7 +14,7 @@ Buffer { "m-updated.txt (+1 -1) ", "─────────────────────────────────────────── 0% ─", " ↑/↓ to scroll pgup/pgdn to page home/end to", - " q to quit ", + " q close ", " ", ], styles: [ @@ -35,6 +35,6 @@ Buffer { x: 18, y: 6, fg: Red, bg: Reset, underline: Reset, modifier: NONE, x: 20, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 10, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__transcript_overlay_visualization_becomes_available.snap b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__transcript_overlay_visualization_becomes_available.snap index fce2ba5a08..4c4ed08743 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__transcript_overlay_visualization_becomes_available.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__transcript_overlay_visualization_becomes_available.snap @@ -13,7 +13,7 @@ before: ~ ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─ ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev after: @@ -27,4 +27,4 @@ after: ~ ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─ ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap index bc7818aff9..65ebd42c51 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap @@ -10,5 +10,5 @@ expression: term.backend() "~ " "───────────────────────────────── 100% ─" " ↑/↓ to scroll pgup/pgdn to page hom" -" q to quit " +" q close " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap index 8bbcc488c2..b71c911ea5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap @@ -8,5 +8,5 @@ expression: term.backend() "rendered within a narrow" "─────────────────── 0% ─" " ↑/↓ to scroll pgup/pg" -" q to quit " +" q close " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap index 1f086e81e4..24ce94e912 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap @@ -12,4 +12,4 @@ expression: snapshot 2 +world ─────────────────────────────────────────────────────────────────────────── 0% ─ ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap index a30b67d208..9ed198f554 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap @@ -10,4 +10,4 @@ last ~ ───────────────────────────────── 100% ─ ↑/↓ to scroll pgup/pgdn to page hom - q to quit esc to edit prev + q close esc to edit prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap index 8ef139bb02..84cb5dbcec 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap @@ -14,5 +14,5 @@ expression: term.backend() "tail " "───────────────────────────────── 100% ─" " ↑/↓ to scroll pgup/pgdn to page hom" -" q to quit esc to edit prev " +" q close esc to edit prev " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap index 075e70bccb..b8106e6cae 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap @@ -11,7 +11,7 @@ recent transcript ~ ──────────────────────────────────────────────────────────────────────── ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev --- partial --- / T R A N S C R I P T / / / / / / / partial history | PgUp for earlier @@ -22,7 +22,7 @@ recent transcript ~ ──────────────────────────────────────────────────────────────────────── ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev --- failed --- / T R A N S C R I P T / / / / / / / history unavailable | PgUp to retry @@ -33,7 +33,7 @@ recent transcript ~ ──────────────────────────────────────────────────────────────────────── ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev --- complete --- / T R A N S C R I P T / / / / / / / / / / / / / / / / start of history @@ -44,4 +44,4 @@ recent transcript ~ ───────────────────────────────────────────────────────────────── 100% ─ ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap index 05ea902460..b823027c57 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap @@ -10,5 +10,5 @@ expression: term.backend() "~ " "───────────────────────────────── 100% ─" " ↑/↓ to scroll pgup/pgdn to page hom" -" q to quit esc to edit prev " +" q close esc to edit prev " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap index 61fcf7d2ea..024964cea0 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap @@ -10,5 +10,5 @@ expression: term.backend() "gamma " "───────────────────────────────── 100% ─" " ↑/↓ to scroll pgup/pgdn to page hom" -" q to quit esc to edit prev " +" q close esc to edit prev " " " diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap index e2f12c8436..9dce4bfbba 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__transcript_overlay_status_rate_limit_refresh.snap @@ -31,7 +31,7 @@ before: ~ ───────────────────────────────────────────────────────────────────────── 100% ─ ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev after: @@ -63,4 +63,4 @@ next message ~ ───────────────────────────────────────────────────────────────────────── 100% ─ ↑/↓ to scroll pgup/pgdn to page home/end to jump - q to quit esc to edit prev + q close esc to edit prev