返回 CodeWhale
redaction_gate.rs
根目录 / crates / tui / src / tui / redaction_gate.rs
1 //! Startup gate for the `[redaction] model_bound = "disabled"` opt-out.
2 //!
3 //! Setting `[redaction] model_bound = "disabled"` in `config.toml` only
4 //! records a request. Lowering the model-bound masking boundary is a security
5 //! decision, so the interactive TUI shows this full-screen gate on the next
6 //! launch and only applies the opt-out after the user confirms it here (see
7 //! [`codewhale_config::redaction`] for the effective-mode contract).
8 //!
9 //! The gate follows the onboarding visual grammar — one Underwater surface,
10 //! one bottom action rail — but it is **not** an onboarding step: returning
11 //! users see it, and answering it never touches the `.onboarded` marker. The
12 //! three actions mirror the workspace-trust screen's explicit-key discipline:
13 //! Enter never confirms by reflex, and each choice advertises its own key.
14
15 use ratatui::{
16 Frame,
17 layout::Rect,
18 style::Style,
19 text::{Line, Span},
20 widgets::Paragraph,
21 };
22
23 use crate::tui::app::{App, RedactionGateNotice, StatusToastKind};
24 use crate::tui::onboarding::wrap_words;
25 use crate::tui::shell_key_routing::{ShellBindingId, binding};
26 use crate::tui::views::{ActionHint, render_modal_footer, render_underwater_surface};
27 use codewhale_localization::MessageId;
28 use codewhale_palette as palette;
29
30 /// Whether the startup gate must ask before the current config's
31 /// `[redaction] model_bound` request can take effect.
32 pub fn confirmation_required(config: &crate::config::Config) -> bool {
33 codewhale_config::redaction::confirmation_required(
34 config.model_bound_redaction(),
35 config.loaded_config_path.as_deref(),
36 )
37 }
38
39 /// Render the gate. Callers (the frame compositor) invoke this only while
40 /// `app.redaction_gate` is set. The gate has two stages: the first stage
41 /// explains the opt-out and its risk; pressing the confirm key moves to the
42 /// second, final-confirmation stage (`app.redaction_gate_confirming`), which
43 /// repeats the red warning and requires a second explicit confirm before the
44 /// opt-out is recorded.
45 pub fn render(f: &mut Frame, area: Rect, app: &App) {
46 let title = if app.redaction_gate_confirming {
47 app.tr(MessageId::RedactionGateConfirmTitle).into_owned()
48 } else {
49 app.tr(MessageId::RedactionGateTitle).into_owned()
50 };
51 let mut hints = action_hints(app);
52 hints.push(ActionHint::new(
53 binding(ShellBindingId::RedactionGateScroll).footer_chord,
54 app.tr(MessageId::SetupActionScrollBody).to_string(),
55 ));
56 let buf = f.buffer_mut();
57 let inner = render_underwater_surface(area, buf, &title);
58 let content = render_modal_footer(inner, buf, &hints);
59 let lines = screen_lines(app, usize::from(content.width), usize::from(content.height));
60 if lines.is_empty() {
61 return;
62 }
63 let body = center_vertically(content, lines.len());
64 f.render_widget(Paragraph::new(lines), body);
65 }
66
67 fn center_vertically(area: Rect, rows: usize) -> Rect {
68 let pad = (area
69 .height
70 .saturating_sub(u16::try_from(rows).unwrap_or(area.height)))
71 / 2;
72 Rect {
73 y: area.y.saturating_add(pad),
74 height: area.height.saturating_sub(pad),
75 ..area
76 }
77 }
78
79 fn action_hints(app: &App) -> Vec<ActionHint> {
80 [
81 (
82 ShellBindingId::RedactionGateConfirm,
83 MessageId::RedactionGateActionConfirm,
84 ),
85 (
86 ShellBindingId::RedactionGateKeepOrBack,
87 if app.redaction_gate_confirming {
88 MessageId::RedactionGateActionBack
89 } else {
90 MessageId::RedactionGateActionKeep
91 },
92 ),
93 (
94 ShellBindingId::RedactionGateQuit,
95 MessageId::RedactionGateActionQuit,
96 ),
97 ]
98 .into_iter()
99 .map(|(id, label)| ActionHint::new(binding(id).footer_chord, app.tr(label).to_string()))
100 .collect()
101 }
102
103 fn screen_lines(app: &App, width: usize, height: usize) -> Vec<Line<'static>> {
104 let mut out = Vec::new();
105 let now = std::time::Instant::now();
106 if let Some(toast) = [
107 RedactionGateNotice::WriteFailure,
108 RedactionGateNotice::EnterGuidance,
109 ]
110 .into_iter()
111 .find_map(|notice| {
112 app.status_toasts.iter().rev().find(|toast| {
113 toast.kind == StatusToastKind::RedactionGate(notice) && !toast.is_expired(now)
114 })
115 }) {
116 for line in wrap_words(&toast.text, width) {
117 out.push(Line::from(Span::styled(
118 line,
119 Style::default().fg(toast.level.ink().color(&app.ui_theme)),
120 )));
121 }
122 out.push(Line::from(""));
123 }
124 // Put the consequence first even when the rest needs scrolling.
125 wrap_body_danger(&mut out, app, MessageId::RedactionGateDangerNotice, width);
126 out.push(Line::from(""));
127 if app.redaction_gate_confirming {
128 wrap_body(
129 &mut out,
130 app,
131 MessageId::RedactionGateConfirmQuestion,
132 width,
133 );
134 } else {
135 wrap_body(&mut out, app, MessageId::RedactionGateQuestion, width);
136 out.push(Line::from(""));
137 wrap_body_muted(&mut out, app, MessageId::RedactionGateRisk, width);
138 wrap_body_muted(&mut out, app, MessageId::RedactionGateEffect, width);
139 wrap_body_muted(&mut out, app, MessageId::RedactionGateRollbackHint, width);
140 }
141 let scroll = app
142 .redaction_gate_scroll
143 .get()
144 .min(out.len().saturating_sub(height));
145 app.redaction_gate_scroll.set(scroll);
146 out.into_iter().skip(scroll).take(height).collect()
147 }
148
149 /// Body sentence in the primary lane.
150 fn wrap_body(lines: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) {
151 let text = app.tr(id);
152 for segment in wrap_words(&text, width) {
153 lines.push(Line::from(Span::styled(
154 segment,
155 Style::default().fg(palette::TEXT_PRIMARY),
156 )));
157 }
158 }
159
160 /// The red, bold warning shown on both gate stages. Wrap on display width
161 /// exactly like the other lanes so no locale clips mid-word.
162 fn wrap_body_danger(lines: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) {
163 let text = app.tr(id);
164 for segment in wrap_words(&text, width) {
165 lines.push(Line::from(Span::styled(
166 segment,
167 Style::default()
168 .fg(palette::STATUS_ERROR)
169 .add_modifier(ratatui::style::Modifier::BOLD),
170 )));
171 }
172 }
173
174 /// Supporting hint in the muted lane.
175 fn wrap_body_muted(lines: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) {
176 let text = app.tr(id);
177 for segment in wrap_words(&text, width) {
178 lines.push(Line::from(Span::styled(
179 segment,
180 Style::default().fg(palette::TEXT_MUTED),
181 )));
182 }
183 }
184
185 /// Persist the confirmation and return the written receipt path. Called after
186 /// the user picks the explicit "confirm" action.
187 pub fn record_confirmation(config: &crate::config::Config) -> anyhow::Result<std::path::PathBuf> {
188 let path = config
189 .loaded_config_path
190 .as_deref()
191 .ok_or_else(|| anyhow::anyhow!("No loaded config file can receive this confirmation"))?;
192 codewhale_config::redaction::record_model_bound_disabled_confirmation(path)
193 .map_err(anyhow::Error::from)
194 }
195
196 // The "keep masking" answer persists nothing and rewrites no file: the
197 // current launch stays on the safe default, and because the config field
198 // still requests `"disabled"`, the gate asks again on the next launch until
199 // the user confirms or edits the field back to `"enabled"`. The event loop
200 // implements this inline (it only clears the gate flag); this module-level
201 // contract comment is where the semantics live.
202
203 #[cfg(test)]
204 mod tests {
205 use super::*;
206 use crate::config::Config;
207 use crate::tui::app::TuiOptions;
208 use crate::tui::views::action_footer_lines;
209 use std::path::PathBuf;
210
211 fn app_fixture() -> App {
212 let options = TuiOptions {
213 model: "test-model".to_string(),
214 ..crate::test_support::test_tui_options(PathBuf::from("workspace-fixture"))
215 };
216 let mut app = App::new(options, &Config::default());
217 app.ui_locale = codewhale_localization::Locale::En;
218 app.redaction_gate = true;
219 app
220 }
221
222 #[test]
223 fn gate_names_the_boundary_and_the_three_explicit_actions() {
224 let app = app_fixture();
225 let body = screen_lines(&app, 70, 24)
226 .into_iter()
227 .flat_map(|line| line.spans.into_iter().map(|span| span.content.to_string()))
228 .collect::<Vec<_>>()
229 .join("\n");
230 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
231 assert!(flat.contains("model-bound"), "{body}");
232 assert!(flat.contains("API keys"), "{body}");
233
234 let rail = action_hints(&app)
235 .iter()
236 .flat_map(|hint| action_footer_lines(std::slice::from_ref(hint), 60))
237 .flat_map(|line| {
238 line.spans
239 .into_iter()
240 .map(|span| span.content.to_string())
241 .collect::<Vec<_>>()
242 })
243 .collect::<Vec<_>>()
244 .join(" ");
245 for expected in ["1/Y", "2/U", "3/N"] {
246 assert!(
247 rail.contains(expected),
248 "missing {expected} in rail: {rail}"
249 );
250 }
251 assert!(rail.contains("confirm"), "{rail}");
252 assert!(rail.contains("keep"), "{rail}");
253 assert!(rail.contains("quit"), "{rail}");
254 }
255
256 #[test]
257 fn gate_renders_without_panicking_on_short_screens() {
258 // The gate must survive very narrow terminals without clipping the
259 // question (see the trust screen's narrow-terminal discipline).
260 for width in [40usize, 60, 80, 120] {
261 for locale in [
262 codewhale_localization::Locale::En,
263 codewhale_localization::Locale::ZhHans,
264 ] {
265 let mut app = app_fixture();
266 app.ui_locale = locale;
267 let _ = screen_lines(&app, width, 24);
268 // Both stages must survive the same narrow lanes.
269 app.redaction_gate_confirming = true;
270 let _ = screen_lines(&app, width, 24);
271 }
272 }
273 }
274
275 #[test]
276 fn gate_scroll_reaches_every_body_line_with_actions_visible_in_all_locales() {
277 use ratatui::{
278 Terminal,
279 backend::TestBackend,
280 buffer::{Buffer, CellWidth},
281 };
282 use std::collections::HashSet;
283 let visible_row = |buffer: &Buffer, area: Rect, y| {
284 let mut row = String::new();
285 let mut x = area.x;
286 while x < area.right() {
287 let cell = &buffer[(x, y)];
288 row.push_str(cell.symbol());
289 // TestBackend retains hidden cells beneath wide characters
290 // between draws. Read the terminal-visible graphemes only.
291 x += cell.cell_width().max(1);
292 }
293 row
294 };
295 let compact = |text: &str| {
296 text.chars()
297 .filter(|ch| !ch.is_whitespace())
298 .collect::<String>()
299 };
300 for &(width, height) in &[(40, 12), (60, 16), (80, 24)] {
301 for &locale in codewhale_localization::Locale::shipped() {
302 for confirming in [false, true] {
303 let mut app = app_fixture();
304 app.ui_locale = locale;
305 app.redaction_gate_confirming = confirming;
306 let area = Rect::new(0, 0, width, height);
307 let mut buffer = Buffer::empty(area);
308 let inner = render_underwater_surface(area, &mut buffer, "");
309 let mut hints = action_hints(&app);
310 hints.push(ActionHint::new(
311 "↑/↓",
312 app.tr(MessageId::SetupActionScrollBody).to_string(),
313 ));
314 let content = render_modal_footer(inner, &mut buffer, &hints);
315 assert!(
316 content.height > 0,
317 "no reading space at {width}x{height}, {locale:?}"
318 );
319 let expected = screen_lines(&app, content.width as usize, usize::MAX);
320 let mut seen = HashSet::new();
321 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
322 for scroll in 0..=expected.len() {
323 app.redaction_gate_scroll.set(scroll);
324 let rendered = terminal.draw(|frame| render(frame, area, &app)).unwrap();
325 let buffer = rendered.buffer;
326 let mut whole = String::new();
327 for y in 0..height {
328 let row = visible_row(buffer, area, y);
329 whole.push_str(&row);
330 }
331 for key in ["1/Y", "2/U", "3/N", "↑/↓"] {
332 assert!(
333 whole.contains(key),
334 "missing action {key} at {width}x{height}, {locale:?}"
335 );
336 }
337 for y in content.y..content.y + content.height {
338 let row = visible_row(buffer, content, y);
339 seen.insert(compact(&row));
340 }
341 }
342 for line in expected {
343 let text = line
344 .spans
345 .iter()
346 .map(|span| span.content.as_ref())
347 .collect::<String>();
348 assert!(
349 unicode_width::UnicodeWidthStr::width(text.as_str())
350 <= usize::from(content.width),
351 "overflow at {width}x{height}, {locale:?}: {text}"
352 );
353 assert!(
354 seen.contains(&compact(&text)),
355 "unreachable or clipped body at {width}x{height}, {locale:?}, stage {confirming}: {text}"
356 );
357 }
358 }
359 }
360 }
361 }
362
363 #[test]
364 fn gate_notice_survives_unrelated_status_refresh_and_receipt_failure_keeps_masking() {
365 let temp = tempfile::tempdir().unwrap();
366 let path = temp.path().join("selected.toml");
367 std::fs::write(&path, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap();
368 let config = Config::load(Some(path.clone()), None).unwrap();
369 // An existing directory makes the write fail on every supported OS.
370 std::fs::create_dir(codewhale_config::redaction::model_bound_state_path(&path)).unwrap();
371 assert!(record_confirmation(&config).is_err());
372 assert!(confirmation_required(&config));
373 let mut app = app_fixture();
374 let notice = app.tr(MessageId::RedactionGateSaveFailed).into_owned();
375 app.push_status_toast_record(
376 crate::tui::app::StatusToast::new(
377 notice.clone(),
378 crate::tui::app::StatusToastLevel::Error,
379 None,
380 )
381 .for_redaction_gate(RedactionGateNotice::WriteFailure),
382 );
383 let hint = app.tr(MessageId::RedactionGateEnterHint).into_owned();
384 app.push_status_toast_record(
385 crate::tui::app::StatusToast::new(
386 hint.clone(),
387 crate::tui::app::StatusToastLevel::Info,
388 Some(12_000),
389 )
390 .for_redaction_gate(RedactionGateNotice::EnterGuidance),
391 );
392 app.push_status_toast(
393 "Unrelated runtime error",
394 crate::tui::app::StatusToastLevel::Error,
395 None,
396 );
397 app.status_message = Some("Unrelated runtime status".to_string());
398 let lines = screen_lines(&app, 38, 6)
399 .into_iter()
400 .map(|line| line.to_string())
401 .collect::<Vec<_>>()
402 .join(" ");
403 assert!(lines.contains("Could not save confirmation"));
404 assert!(!lines.contains("Unrelated runtime status"));
405 assert!(!lines.contains("Unrelated runtime error"));
406 assert!(!lines.contains(&hint));
407 assert_eq!(
408 screen_lines(&app, 38, 6)[0].spans[0].style.fg,
409 Some(app.ui_theme.error_fg)
410 );
411 assert!(app.redaction_gate);
412 assert_eq!(
413 codewhale_config::redaction::effective_masking(
414 config.model_bound_redaction(),
415 config.loaded_config_path.as_deref()
416 ),
417 codewhale_config::redaction::ModelBoundMasking::Enabled
418 );
419 app.retire_redaction_gate_notice(RedactionGateNotice::EnterGuidance);
420 assert!(screen_lines(&app, 100, 24)[0].to_string().contains(&notice));
421 app.retire_redaction_gate_notice(RedactionGateNotice::WriteFailure);
422 let remaining = screen_lines(&app, 100, 24)
423 .into_iter()
424 .map(|line| line.to_string())
425 .collect::<Vec<_>>()
426 .join(" ");
427 assert!(!remaining.contains(&notice));
428 assert!(!remaining.contains("Unrelated runtime error"));
429 assert_eq!(app.status_toasts.len(), 1);
430 assert_eq!(app.status_toasts[0].text, "Unrelated runtime error");
431 }
432
433 /// The red warning is part of both stages, and the second stage swaps the
434 /// "keep" action for a "back" action: you can only move forward with an
435 /// explicit second confirm.
436 #[test]
437 fn both_stages_show_the_danger_warning_and_second_stage_offers_back() {
438 let first = app_fixture();
439 let first_body = screen_lines(&first, 70, 24)
440 .into_iter()
441 .flat_map(|line| line.spans.into_iter().map(|span| span.content.to_string()))
442 .collect::<Vec<_>>()
443 .join("\n");
444 assert!(first_body.contains("Caution"), "{first_body}");
445
446 let mut confirming = app_fixture();
447 confirming.redaction_gate_confirming = true;
448 let confirm_body = screen_lines(&confirming, 70, 24)
449 .into_iter()
450 .flat_map(|line| line.spans.into_iter().map(|span| span.content.to_string()))
451 .collect::<Vec<_>>()
452 .join("\n");
453 assert!(confirm_body.contains("really sure"), "{confirm_body}");
454 assert!(confirm_body.contains("Caution"), "{confirm_body}");
455
456 let rail = action_hints(&confirming)
457 .iter()
458 .flat_map(|hint| action_footer_lines(std::slice::from_ref(hint), 60))
459 .flat_map(|line| {
460 line.spans
461 .into_iter()
462 .map(|span| span.content.to_string())
463 .collect::<Vec<_>>()
464 })
465 .collect::<Vec<_>>()
466 .join(" ");
467 assert!(rail.contains("back"), "{rail}");
468 assert!(
469 !rail.contains("keep"),
470 "second stage must not offer keep: {rail}"
471 );
472 assert!(rail.contains("quit"), "{rail}");
473 }
474 }
475
475 lines RUST