返回 CodeWhale
trust_directory.rs
根目录 / crates / tui / src / tui / onboarding / trust_directory.rs
1 //! Workspace trust prompt for onboarding.
2 //!
3 //! One decision: trust the instructions and files in this folder, or
4 //! continue without trust. The explicit 1/Y · 2/U · 3/N keys stay — trusting
5 //! a workspace is a security boundary and must never happen by reflex.
6
7 use ratatui::style::{Modifier, Style};
8 use ratatui::text::{Line, Span};
9
10 use crate::tui::app::App;
11 use codewhale_localization::MessageId;
12 use codewhale_palette as palette;
13
14 /// Wrap a path-bearing line at `/` boundaries so a deep workspace never
15 /// hard-splits mid-component under ratatui's whitespace-only `Wrap`.
16 /// Continuation lines are indented to read as one location.
17 fn wrap_on_path_separators(text: &str, width: usize) -> Vec<String> {
18 let width = width.max(8);
19 let mut out: Vec<String> = Vec::new();
20 let mut current = String::new();
21 let mut chunk = String::new();
22 let flush = |current: &mut String, chunk: &mut String, out: &mut Vec<String>| {
23 if chunk.is_empty() {
24 return;
25 }
26 let candidate_len = current.chars().count() + chunk.chars().count();
27 if candidate_len > width && !current.is_empty() {
28 out.push(std::mem::take(current));
29 current.push_str(" ");
30 }
31 current.push_str(chunk);
32 chunk.clear();
33 };
34 for ch in text.chars() {
35 chunk.push(ch);
36 if ch == '/' {
37 flush(&mut current, &mut chunk, &mut out);
38 }
39 }
40 flush(&mut current, &mut chunk, &mut out);
41 if !current.is_empty() {
42 out.push(current);
43 }
44 if out.is_empty() {
45 vec![String::new()]
46 } else {
47 out
48 }
49 }
50
51 pub fn lines(app: &App, content_width: usize) -> Vec<Line<'static>> {
52 let mut lines = Vec::new();
53 lines.push(Line::from(Span::styled(
54 app.tr(MessageId::OnboardTrustTitle).to_string(),
55 Style::default()
56 .fg(palette::WHALE_ACTION)
57 .add_modifier(Modifier::BOLD),
58 )));
59 lines.push(Line::from(""));
60 // Prose on this screen wraps like prose on every other onboarding screen.
61 // It used to be pushed as one unwrapped line, so at 40 columns the trust
62 // question rendered as "Should Codewhale work with the instruc" — severed
63 // mid-word, with nothing marking the cut. Asking someone to grant
64 // filesystem trust while the question itself is truncated is the worst
65 // place in the product for this to happen.
66 for segment in super::wrap_words(
67 app.tr(MessageId::OnboardTrustQuestion).as_ref(),
68 content_width,
69 ) {
70 lines.push(Line::from(Span::styled(
71 segment,
72 Style::default().fg(palette::TEXT_PRIMARY),
73 )));
74 }
75 let location = format!(
76 "{}{}",
77 app.tr(MessageId::OnboardTrustLocationPrefix),
78 crate::utils::display_path(&app.workspace)
79 );
80 for segment in wrap_on_path_separators(&location, content_width) {
81 lines.push(Line::from(Span::styled(
82 segment,
83 Style::default().fg(palette::TEXT_MUTED),
84 )));
85 }
86 lines.push(Line::from(""));
87 for id in [
88 MessageId::OnboardTrustRiskHint,
89 MessageId::OnboardTrustEffectHint,
90 ] {
91 for segment in super::wrap_words(app.tr(id).as_ref(), content_width) {
92 lines.push(Line::from(Span::styled(
93 segment,
94 Style::default().fg(palette::TEXT_MUTED),
95 )));
96 }
97 }
98 if let Some(message) = app.status_message.as_deref() {
99 lines.push(Line::from(""));
100 lines.push(Line::from(Span::styled(
101 message.to_string(),
102 Style::default().fg(palette::STATUS_WARNING),
103 )));
104 }
105 lines
106 }
107
108 #[cfg(test)]
109 mod tests {
110 use super::*;
111 use crate::config::Config;
112 use crate::tui::app::TuiOptions;
113 use crate::tui::views::action_footer_lines;
114 use std::path::PathBuf;
115
116 #[test]
117 fn prompt_names_the_workspace_boundary_and_effects() {
118 let options = TuiOptions {
119 model: "test-model".to_string(),
120 ..crate::test_support::test_tui_options(PathBuf::from("workspace-fixture"))
121 };
122 let mut app = App::new(options, &Config::default());
123 app.ui_locale = codewhale_localization::Locale::En;
124 let body = lines(&app, 70)
125 .into_iter()
126 .flat_map(|line| line.spans.into_iter().map(|span| span.content.to_string()))
127 .collect::<Vec<_>>()
128 .join("\n");
129 // The prose wraps, so a phrase can straddle a line break. Match on
130 // collapsed whitespace: this test is about which facts the screen
131 // states, not about where the lane happens to break them.
132 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
133
134 assert!(flat.contains("Know this workspace"), "{body}");
135 assert!(flat.contains("instructions and files"), "{body}");
136 assert!(flat.contains("prompt injection"), "{body}");
137 assert!(flat.contains("tools and hooks"), "{body}");
138 }
139
140 /// Trust keys stay explicit and in the action rail: Enter must never
141 /// grant trust, and the three choices must each advertise their own key.
142 #[test]
143 fn trust_actions_are_explicit_keys_in_the_action_rail() {
144 let mut app = App::new(
145 TuiOptions {
146 model: "test-model".to_string(),
147 ..crate::test_support::test_tui_options(PathBuf::from("workspace-fixture"))
148 },
149 &Config::default(),
150 );
151 app.ui_locale = codewhale_localization::Locale::En;
152 app.onboarding = crate::tui::app::OnboardingState::TrustDirectory;
153
154 let rail = super::super::action_hints(&app)
155 .iter()
156 .flat_map(|hint| action_footer_lines(std::slice::from_ref(hint), 60))
157 .flat_map(|line| {
158 line.spans
159 .into_iter()
160 .map(|span| span.content.to_string())
161 .collect::<Vec<_>>()
162 })
163 .collect::<Vec<_>>()
164 .join(" ");
165
166 for expected in ["1/Y", "2/U", "3/N"] {
167 assert!(
168 rail.contains(expected),
169 "missing {expected} in rail: {rail}"
170 );
171 }
172 assert!(rail.contains("trust and continue"), "{rail}");
173 assert!(rail.contains("continue without trusting"), "{rail}");
174 assert!(rail.contains("quit Codewhale"), "{rail}");
175 }
176 }
177
178 #[cfg(test)]
179 mod narrow_terminal_tests {
180 use super::*;
181 use crate::config::Config;
182 use crate::tui::app::TuiOptions;
183 use codewhale_localization::{Locale, MessageId, tr};
184 use std::path::PathBuf;
185 use unicode_width::UnicodeWidthStr;
186
187 fn app_at(workspace: &str) -> App {
188 let options = TuiOptions {
189 model: "test-model".to_string(),
190 ..crate::test_support::test_tui_options(PathBuf::from(workspace))
191 };
192 App::new(options, &Config::default())
193 }
194
195 /// The trust screen asks for filesystem trust. Every locale's prose has to
196 /// survive a small terminal intact: a question cut mid-word is not a
197 /// question the reader can answer.
198 #[test]
199 fn every_locale_keeps_the_trust_prose_whole_at_forty_columns() {
200 let mut app = app_at("/tmp/probe/ws");
201 for locale in Locale::shipped().iter().copied() {
202 app.ui_locale = locale;
203 for width in [40usize, 60, 80, 120] {
204 let rendered: Vec<String> = lines(&app, width)
205 .iter()
206 .map(|line| {
207 line.spans
208 .iter()
209 .map(|span| span.content.as_ref())
210 .collect::<String>()
211 })
212 .collect();
213
214 for row in &rendered {
215 assert!(
216 row.width() <= width,
217 "{locale:?} at {width}: row overflows the lane: {row:?}",
218 );
219 }
220
221 // The question must be present in full, not clipped. Compare on
222 // the rejoined prose so a wrap is fine and a cut is not.
223 let joined = rendered.join(" ");
224 let question = tr(locale, MessageId::OnboardTrustQuestion);
225 // Compare with whitespace removed entirely, not collapsed.
226 // Japanese and Chinese wrap between characters that have no
227 // space between them in the source, so rejoining with a space
228 // would make a correctly wrapped line look like a changed one.
229 let normalize =
230 |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
231 assert!(
232 normalize(&joined).contains(&normalize(question.as_ref()))
233 || question.as_ref().chars().all(|c| c.is_whitespace()),
234 "{locale:?} at {width}: the trust question was cut.\nwanted: {question}\ngot: {joined}",
235 );
236 }
237 }
238 }
239 }
240
240 lines RUST