| 1 | //! Workspace trust prompt for onboarding. |
| 2 | |
| 3 | use ratatui::style::{Modifier, Style}; |
| 4 | use ratatui::text::{Line, Span}; |
| 5 | |
| 6 | use crate::localization::MessageId; |
| 7 | use crate::palette; |
| 8 | use crate::tui::app::App; |
| 9 | |
| 10 | /// Wrap a path-bearing line at `/` boundaries so a deep workspace never |
| 11 | /// hard-splits mid-component under ratatui's whitespace-only `Wrap`. |
| 12 | /// Continuation lines are indented to read as one location. |
| 13 | fn wrap_on_path_separators(text: &str, width: usize) -> Vec<String> { |
| 14 | let width = width.max(8); |
| 15 | let mut out: Vec<String> = Vec::new(); |
| 16 | let mut current = String::new(); |
| 17 | let mut chunk = String::new(); |
| 18 | let flush = |current: &mut String, chunk: &mut String, out: &mut Vec<String>| { |
| 19 | if chunk.is_empty() { |
| 20 | return; |
| 21 | } |
| 22 | let candidate_len = current.chars().count() + chunk.chars().count(); |
| 23 | if candidate_len > width && !current.is_empty() { |
| 24 | out.push(std::mem::take(current)); |
| 25 | current.push_str(" "); |
| 26 | } |
| 27 | current.push_str(chunk); |
| 28 | chunk.clear(); |
| 29 | }; |
| 30 | for ch in text.chars() { |
| 31 | chunk.push(ch); |
| 32 | if ch == '/' { |
| 33 | flush(&mut current, &mut chunk, &mut out); |
| 34 | } |
| 35 | } |
| 36 | flush(&mut current, &mut chunk, &mut out); |
| 37 | if !current.is_empty() { |
| 38 | out.push(current); |
| 39 | } |
| 40 | if out.is_empty() { |
| 41 | vec![String::new()] |
| 42 | } else { |
| 43 | out |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | pub fn lines(app: &App, content_width: usize) -> Vec<Line<'static>> { |
| 48 | let mut lines = Vec::new(); |
| 49 | lines.push(Line::from(Span::styled( |
| 50 | app.tr(MessageId::OnboardTrustTitle).to_string(), |
| 51 | Style::default() |
| 52 | .fg(palette::WHALE_INFO) |
| 53 | .add_modifier(Modifier::BOLD), |
| 54 | ))); |
| 55 | lines.push(Line::from("")); |
| 56 | lines.push(Line::from(Span::styled( |
| 57 | app.tr(MessageId::OnboardTrustQuestion).to_string(), |
| 58 | Style::default().fg(palette::TEXT_PRIMARY), |
| 59 | ))); |
| 60 | let location = format!( |
| 61 | "{}{}", |
| 62 | app.tr(MessageId::OnboardTrustLocationPrefix), |
| 63 | crate::utils::display_path(&app.workspace) |
| 64 | ); |
| 65 | for segment in wrap_on_path_separators(&location, content_width) { |
| 66 | lines.push(Line::from(Span::styled( |
| 67 | segment, |
| 68 | Style::default().fg(palette::TEXT_MUTED), |
| 69 | ))); |
| 70 | } |
| 71 | lines.push(Line::from("")); |
| 72 | lines.push(Line::from(Span::styled( |
| 73 | app.tr(MessageId::OnboardTrustRiskHint).to_string(), |
| 74 | Style::default().fg(palette::TEXT_MUTED), |
| 75 | ))); |
| 76 | lines.push(Line::from(Span::styled( |
| 77 | app.tr(MessageId::OnboardTrustEffectHint).to_string(), |
| 78 | Style::default().fg(palette::TEXT_MUTED), |
| 79 | ))); |
| 80 | if let Some(message) = app.status_message.as_deref() { |
| 81 | lines.push(Line::from("")); |
| 82 | lines.push(Line::from(Span::styled( |
| 83 | message.to_string(), |
| 84 | Style::default().fg(palette::STATUS_WARNING), |
| 85 | ))); |
| 86 | } |
| 87 | lines.push(Line::from("")); |
| 88 | lines.push(Line::from(vec![ |
| 89 | Span::styled( |
| 90 | app.tr(MessageId::OnboardTrustFooterPrefix).to_string(), |
| 91 | Style::default().fg(palette::TEXT_MUTED), |
| 92 | ), |
| 93 | Span::styled( |
| 94 | "1/Y", |
| 95 | Style::default() |
| 96 | .fg(palette::TEXT_PRIMARY) |
| 97 | .add_modifier(Modifier::BOLD), |
| 98 | ), |
| 99 | Span::styled( |
| 100 | app.tr(MessageId::OnboardTrustFooterMiddle).to_string(), |
| 101 | Style::default().fg(palette::TEXT_MUTED), |
| 102 | ), |
| 103 | Span::styled( |
| 104 | "2/U", |
| 105 | Style::default() |
| 106 | .fg(palette::TEXT_PRIMARY) |
| 107 | .add_modifier(Modifier::BOLD), |
| 108 | ), |
| 109 | Span::styled( |
| 110 | app.tr(MessageId::OnboardTrustFooterUntrustedMiddle) |
| 111 | .to_string(), |
| 112 | Style::default().fg(palette::TEXT_MUTED), |
| 113 | ), |
| 114 | Span::styled( |
| 115 | "3/N/Esc", |
| 116 | Style::default() |
| 117 | .fg(palette::TEXT_PRIMARY) |
| 118 | .add_modifier(Modifier::BOLD), |
| 119 | ), |
| 120 | Span::styled( |
| 121 | app.tr(MessageId::OnboardTrustFooterSuffix).to_string(), |
| 122 | Style::default().fg(palette::TEXT_MUTED), |
| 123 | ), |
| 124 | ])); |
| 125 | lines |
| 126 | } |
| 127 | |
| 128 | #[cfg(test)] |
| 129 | mod tests { |
| 130 | use super::*; |
| 131 | use crate::config::Config; |
| 132 | use crate::tui::app::TuiOptions; |
| 133 | use std::path::PathBuf; |
| 134 | |
| 135 | #[test] |
| 136 | fn prompt_names_the_workspace_boundary_and_effects() { |
| 137 | let options = TuiOptions { |
| 138 | model: "test-model".to_string(), |
| 139 | ..crate::test_support::test_tui_options(PathBuf::from("workspace-fixture")) |
| 140 | }; |
| 141 | let mut app = App::new(options, &Config::default()); |
| 142 | app.ui_locale = crate::localization::Locale::En; |
| 143 | let body = lines(&app, 70) |
| 144 | .into_iter() |
| 145 | .flat_map(|line| line.spans.into_iter().map(|span| span.content.to_string())) |
| 146 | .collect::<Vec<_>>() |
| 147 | .join("\n"); |
| 148 | |
| 149 | assert!(body.contains("Know this workspace")); |
| 150 | assert!(body.contains("instructions and files")); |
| 151 | assert!(body.contains("prompt injection")); |
| 152 | assert!(body.contains("tools and hooks")); |
| 153 | assert!(body.contains("1/Y")); |
| 154 | assert!(body.contains("2/U")); |
| 155 | assert!(body.contains("continue without trusting")); |
| 156 | assert!(body.contains("3/N/Esc")); |
| 157 | assert!(body.contains("quit Codewhale")); |
| 158 | } |
| 159 | } |
| 160 |