Compare commits

..
1 Commits
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 e131aefbc0 fix(tui): every Shift+Tab step writes one Permissions receipt; Plan reads Plan, not Ask (CW-8150)
finish_mode_change compared the Work baseline pair, which does not move when
entering Plan, so that step wrote no receipt. It now records when the
effective dial label changes (plan counts as a label), and the dial step
defers receipts so leaving Plan writes one note, not two. The engine status
for a mode change says Plan (read only) instead of Ask . Plan.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:26:28 -08:00
4 changed files with 69 additions and 29 deletions

No files matched your search

+4 -4
View File
@@ -3183,11 +3183,11 @@ impl Engine {
// policy itself gone, which is the one word the notice exists
// to carry. Product words only (§19): Permissions, then
// Plan / Work — not "Policy" or the ACT tag.
"Permissions: {} · {}",
effective_approval.permission_chip_label(),
"Permissions: {}",
match mode {
AppMode::Plan => "Plan",
AppMode::Agent => "Work",
AppMode::Plan => "Plan (read only)".to_string(),
AppMode::Agent =>
format!("{} · Work", effective_approval.permission_chip_label()),
},
)))
.await;
+22 -25
View File
@@ -2213,6 +2213,7 @@ pub struct App {
pub yolo: bool,
/// One-shot YOLO→Act+Bypass migration notice for this session (#0.8.68 M6).
yolo_compat_notified: bool,
permission_receipt_deferred: bool,
/// One-shot notice for a settings file that still names the retired
/// Operate mode, so the mode change is explained instead of silent.
operate_retired_notified: bool,
@@ -3508,8 +3509,7 @@ impl App {
if previous_mode == mode && !self.yolo {
return false;
}
let permission_before = self.effective_permission_label();
let permission_pair_before = self.permission_pair();
let permission_before = self.permission_receipt_label();
self.mode = mode;
// Mode chip lives in the header — skip redundant status/toast copy.
@@ -3536,7 +3536,7 @@ impl App {
self.approval_mode = policy.approval_mode;
self.yolo = matches!(policy.approval_mode, ApprovalMode::Bypass);
self.finish_mode_change(previous_mode, &permission_before, permission_pair_before);
self.finish_mode_change(previous_mode, &permission_before);
true
}
@@ -3552,8 +3552,7 @@ impl App {
return false;
}
let previous_mode = self.mode;
let permission_before = self.effective_permission_label();
let permission_pair_before = self.permission_pair();
let permission_before = self.permission_receipt_label();
// Same baseline-refresh rule as a mode hop: the elevation must not
// bleed into the restored Agent surface.
if previous_mode.uses_agent_baseline() && !self.yolo {
@@ -3574,7 +3573,7 @@ impl App {
self.approval_mode = ApprovalMode::Bypass;
self.yolo = true;
self.notify_yolo_compat_once();
self.finish_mode_change(previous_mode, &permission_before, permission_pair_before);
self.finish_mode_change(previous_mode, &permission_before);
true
}
@@ -3609,12 +3608,7 @@ impl App {
/// id, workspace, model, and token total as every other event — it used
/// to omit `DEEPSEEK_SESSION_ID` entirely, which made mode transitions
/// uncorrelatable with the session they belonged to.
fn finish_mode_change(
&mut self,
previous_mode: AppMode,
permission_before: &str,
permission_pair_before: ApprovalMode,
) {
fn finish_mode_change(&mut self, previous_mode: AppMode, permission_before: &str) {
let context = self
.base_hook_context()
.with_mode(self.mode.hook_label())
@@ -3622,20 +3616,10 @@ impl App {
if let Err(error) = self.submit_hooks(HookEvent::ModeChange, context) {
self.surface_observer_hook_submission_failure(error);
}
if self.permission_pair() != permission_pair_before {
self.record_permission_change(permission_before);
}
self.record_permission_change(permission_before);
self.needs_redraw = true;
}
fn permission_pair(&self) -> ApprovalMode {
if self.mode == AppMode::Plan {
self.mode_prefs.agent_approval_mode
} else {
self.approval_mode
}
}
/// Apply a *user-facing* mode selection: change the live session mode and
/// persist it as the startup default.
///
@@ -3977,6 +3961,8 @@ impl App {
} else {
self.approval_mode
};
let permission_before = self.permission_receipt_label();
self.permission_receipt_deferred = true;
let mut step = DialStep::default();
if mode != self.mode {
step.mode_changed = self.select_mode(mode) == SettingSelection::Changed;
@@ -3996,6 +3982,8 @@ impl App {
step.mode_changed = self.select_mode(AppMode::Plan) == SettingSelection::Changed;
}
}
self.permission_receipt_deferred = false;
self.record_permission_change(&permission_before);
step
}
@@ -4236,7 +4224,7 @@ impl App {
/// an independently enabled trust baseline in other posture transitions.
/// Plan remains read-only.
pub fn set_agent_approval_posture(&mut self, next: ApprovalMode) {
let permission_before = self.effective_permission_label();
let permission_before = self.permission_receipt_label();
let trust_mode = if next == ApprovalMode::Bypass {
true
} else if self.mode_prefs.agent_approval_mode == ApprovalMode::Bypass {
@@ -4253,8 +4241,17 @@ impl App {
crate::tui::underwater::permission_label(self).into_owned()
}
fn permission_receipt_label(&self) -> String {
crate::tui::underwater::dial_chip_for(self.ui_locale, self.mode, self.approval_mode)
.0
.into_owned()
}
fn record_permission_change(&mut self, before: &str) {
let after = self.effective_permission_label();
if self.permission_receipt_deferred {
return;
}
let after = self.permission_receipt_label();
if after == before {
return;
}
+1
View File
@@ -1039,6 +1039,7 @@ impl App {
lifecycle_outbox,
yolo: yolo_compat,
yolo_compat_notified: false,
permission_receipt_deferred: false,
operate_retired_notified: false,
startup_defaults: Default::default(),
keybinding_migration_notified: false,
+42
View File
@@ -4020,6 +4020,48 @@ fn base_policy_for_mode_projects_the_mode_permission_table() {
assert_eq!(agent_min.approval_mode, ApprovalMode::Suggest);
}
#[test]
fn every_dial_step_writes_one_permissions_receipt() {
let _env_lock = lock_test_env();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
let mut options = test_options(false);
options.start_in_agent_mode = true;
options.config_path = Some(config_path);
let mut app = App::new(options, &Config::default());
app.set_agent_approval_posture(ApprovalMode::AcceptEdits);
app.history.clear();
app.yolo_compat_notified = true;
let mut receipts = Vec::new();
for _ in 0..5 {
let before = app.history.len();
assert!(app.step_permission_dial(false).changed());
let added: Vec<String> = app.history[before..]
.iter()
.filter_map(|cell| match cell {
HistoryCell::System { content } if content.starts_with("Permissions changed") => {
Some(content.clone())
}
_ => None,
})
.collect();
assert_eq!(added.len(), 1, "one receipt per press: {added:?}");
receipts.extend(added);
}
assert_eq!(
receipts,
[
"Permissions changed to plan",
"Permissions changed to auto",
"Permissions changed to Full Access",
"Permissions changed to ask",
"Permissions changed to accept edits",
]
);
}
#[test]
fn cycle_approval_scenario() {
// Scenario consolidation of: the permission dial walk, cycle_approval_posture_emits_rebinding_notice_once