mirror of
https://github.com/openai/codex.git
synced 2026-10-21 12:10:06 +00:00
Allow reviewing and continuing misalignment-paused chats (#42410)
## Why Misalignment policy failures pause the current chat. When the server provides findings and a continuation request, users should be able to inspect them and explicitly decide whether to proceed. ## What changed - Add a `Review findings` flow that shows the detailed explanation and quoted continuation request in an overlay. - Require explicit acknowledgement before starting a continuation turn, while preserving the active approval and sandbox settings. - Keep ordinary input blocked and reject stale, replaced, invalid, or duplicate continuation attempts. - Label the overlay shortcut as `q close` to match its behavior. ## Testing - Add TUI tests covering findings review, explicit confirmation, successful and failed continuation, stale findings, duplicate events, and invalid continuation data. GitOrigin-RevId: 36a738adf272f95db92eaee5712fb00061d0fa52
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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()) => {}
|
||||
|
||||
@@ -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<MisalignmentReview>,
|
||||
) {
|
||||
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<MisalignmentReview>,
|
||||
) {
|
||||
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::<TurnStartResponse>(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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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(())
|
||||
}
|
||||
+12
@@ -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
|
||||
+21
@@ -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 "
|
||||
" "
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -224,6 +224,9 @@ pub(crate) struct AgentsOverviewThreadRefresh {
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, IntoStaticStr)]
|
||||
pub(crate) enum AppEvent {
|
||||
ReviewMisalignment(Arc<crate::chatwidget::MisalignmentReview>),
|
||||
ContinueMisalignment(Arc<crate::chatwidget::MisalignmentReview>),
|
||||
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.
|
||||
|
||||
@@ -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<String>,
|
||||
active_side_conversation: bool,
|
||||
blocks_direct_input: bool,
|
||||
misalignment_policy_violation: bool,
|
||||
misalignment_policy_violation: Option<misalignment_policy::MisalignmentViolation>,
|
||||
normal_placeholder_text: String,
|
||||
side_placeholder_text: String,
|
||||
forked_from: Option<ThreadId>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,7 +113,7 @@ impl ChatWidget {
|
||||
history_record: UserMessageHistoryRecord,
|
||||
shell_escape_policy: ShellEscapePolicy,
|
||||
) -> (bool, Option<AppCommand>) {
|
||||
if self.misalignment_policy_violation {
|
||||
if self.has_misalignment_policy_violation() {
|
||||
return (false, None);
|
||||
}
|
||||
if self.input_queue.rate_limit_recovery_pending {
|
||||
|
||||
@@ -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<String>,
|
||||
review: Option<Arc<MisalignmentReview>>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
details: Option<MisalignmentErrorDetails>,
|
||||
) {
|
||||
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<MisalignmentReview>) -> 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<MisalignmentReview>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,7 +11,7 @@ impl ChatWidget {
|
||||
request: ServerRequest,
|
||||
replay_kind: Option<ReplayKind>,
|
||||
) {
|
||||
if self.misalignment_policy_violation {
|
||||
if self.has_misalignment_policy_violation() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -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
|
||||
@@ -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<AppEvent>,
|
||||
) -> Arc<MisalignmentReview> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -874,7 +874,7 @@ impl TranscriptOverlay {
|
||||
|
||||
let mut pairs: Vec<(Vec<ShortcutHint>, &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<ShortcutHint>, &str)> = vec![(
|
||||
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
|
||||
"to quit",
|
||||
"close",
|
||||
)];
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@ expression: term.backend()
|
||||
"~ "
|
||||
"───────────────────────────────── 100% ─"
|
||||
" ↑/↓ to scroll pgup/pgdn to page hom"
|
||||
" q to quit "
|
||||
" q close "
|
||||
" "
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@ expression: term.backend()
|
||||
"rendered within a narrow"
|
||||
"─────────────────── 0% ─"
|
||||
" ↑/↓ to scroll pgup/pg"
|
||||
" q to quit "
|
||||
" q close "
|
||||
" "
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
" "
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
" "
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
" "
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user