| 1 | // Frozen original approval band and helpers; private test counterpart only. |
| 2 | use super::*; |
| 3 | /// Compact, bottom-anchored approval card. |
| 4 | /// |
| 5 | /// The widget reads its selected option and locale directly from the |
| 6 | /// [`ApprovalView`]. Rendering preserves transcript context while reserving |
| 7 | /// the complete action set and at least one load-bearing command/preview row |
| 8 | /// on ordinary terminal sizes. |
| 9 | pub struct ApprovalWidget<'a> { |
| 10 | request: &'a ApprovalRequest, |
| 11 | view: &'a ApprovalView, |
| 12 | } |
| 13 | |
| 14 | impl<'a> ApprovalWidget<'a> { |
| 15 | pub fn new(request: &'a ApprovalRequest, view: &'a ApprovalView) -> Self { |
| 16 | Self { request, view } |
| 17 | } |
| 18 | |
| 19 | /// Build the inline approval content, split into the informational `body` |
| 20 | /// (which may scroll/truncate within its region) and the interactive |
| 21 | /// `controls` (which are always reserved and can never be clipped). Both |
| 22 | /// `render` and `inline_region` use this so the painted band and the |
| 23 | /// dimmed backdrop region always agree. |
| 24 | /// |
| 25 | /// The save preview says what a persistent rule would cover while the |
| 26 | /// controls offer to save it, so it is never dropped: it is a trust |
| 27 | /// boundary, not decoration. A band too short for the full preview gets |
| 28 | /// one line per rule instead of calling the request "truncated" (#6566). |
| 29 | /// The band always reserves the compact preview's rows and `render` pins |
| 30 | /// the preview above the controls, so a short band cuts the request |
| 31 | /// detail, never the preview. A frame too small (or too narrow) for even |
| 32 | /// the one-line preview fails closed: the card drops the preview and the |
| 33 | /// save offers together (`[p]`, `s`), keeping only one-off decisions. |
| 34 | fn build_inline_content(&self, area: Rect) -> InlineContent { |
| 35 | let (compact, save_start, controls) = self.build_inline_parts(area, true, true); |
| 36 | let save_reserve = measure_wrapped_rows(&compact[save_start..], area.width); |
| 37 | let compact = InlineContent { |
| 38 | body: compact, |
| 39 | save_start, |
| 40 | save_reserve, |
| 41 | controls, |
| 42 | save_shown: true, |
| 43 | }; |
| 44 | if save_start == compact.body.len() { |
| 45 | return InlineContent { |
| 46 | save_shown: false, |
| 47 | ..compact |
| 48 | }; |
| 49 | } |
| 50 | if !compact.save_preview_fits(area) { |
| 51 | let (body, save_start, controls) = self.build_inline_parts(area, true, false); |
| 52 | return InlineContent { |
| 53 | body, |
| 54 | save_start, |
| 55 | save_reserve: 0, |
| 56 | controls, |
| 57 | save_shown: false, |
| 58 | }; |
| 59 | } |
| 60 | let (body, save_start, controls) = self.build_inline_parts(area, false, true); |
| 61 | let full = InlineContent { |
| 62 | body, |
| 63 | save_start, |
| 64 | save_reserve, |
| 65 | controls, |
| 66 | save_shown: true, |
| 67 | }; |
| 68 | if full.body_fits(area) { full } else { compact } |
| 69 | } |
| 70 | |
| 71 | /// The body, how many of its leading lines come before the save preview, |
| 72 | /// and the controls. `compact_save_preview` puts each rule on one line; |
| 73 | /// without `offer_save` there is neither a save preview nor a save offer. |
| 74 | fn build_inline_parts( |
| 75 | &self, |
| 76 | area: Rect, |
| 77 | compact_save_preview: bool, |
| 78 | offer_save: bool, |
| 79 | ) -> (Vec<Line<'static>>, usize, Vec<Line<'static>>) { |
| 80 | let risk = self.request.risk; |
| 81 | let stakes = self.request.stakes(); |
| 82 | let locale = self.view.locale(); |
| 83 | let repo_law = self.request.is_repo_law_prompt(); |
| 84 | let palette_colors = if repo_law { |
| 85 | repo_law_approval_palette() |
| 86 | } else { |
| 87 | approval_palette(stakes) |
| 88 | }; |
| 89 | let critical = matches!(stakes, crate::tui::approval::ApprovalStakes::Critical); |
| 90 | |
| 91 | let mut body: Vec<Line<'static>> = Vec::with_capacity(16); |
| 92 | // Header: effect badge + the plain summary of the call (E6). The raw |
| 93 | // tool name stays one details chord away in the pager. |
| 94 | body.push(Line::from(vec![ |
| 95 | Span::raw(" "), |
| 96 | Span::styled( |
| 97 | format!( |
| 98 | " {} ", |
| 99 | if repo_law { |
| 100 | tr(locale, MessageId::ApprovalRepoLawBadge) |
| 101 | } else { |
| 102 | effect_badge_text(self.request, stakes, locale) |
| 103 | } |
| 104 | ), |
| 105 | Style::default() |
| 106 | .fg(palette::WHALE_BG) |
| 107 | .bg(palette_colors.accent) |
| 108 | .add_modifier(Modifier::BOLD), |
| 109 | ), |
| 110 | Span::raw(" "), |
| 111 | Span::styled( |
| 112 | if repo_law { |
| 113 | format!( |
| 114 | "{} · {}", |
| 115 | tr(locale, MessageId::ApprovalRepoLawTitle), |
| 116 | approval_heading(self.request, locale) |
| 117 | ) |
| 118 | } else { |
| 119 | approval_heading(self.request, locale) |
| 120 | }, |
| 121 | Style::default() |
| 122 | .fg(palette::WHALE_ACTION) |
| 123 | .add_modifier(Modifier::BOLD), |
| 124 | ), |
| 125 | ])); |
| 126 | |
| 127 | // A child's card names the agent that is waiting (approvals C1). |
| 128 | if let Some(owner) = self.request.owner.as_ref() { |
| 129 | body.push(Line::from(vec![ |
| 130 | Span::raw(" "), |
| 131 | Span::styled( |
| 132 | approval_owner_header(owner, locale), |
| 133 | Style::default() |
| 134 | .fg(palette::TEXT_SECONDARY) |
| 135 | .add_modifier(Modifier::BOLD), |
| 136 | ), |
| 137 | ])); |
| 138 | } |
| 139 | |
| 140 | if repo_law { |
| 141 | body.push(Line::from(vec![ |
| 142 | Span::raw(" "), |
| 143 | Span::styled( |
| 144 | "◆ ", |
| 145 | Style::default() |
| 146 | .fg(palette::STATUS_WARNING) |
| 147 | .add_modifier(Modifier::BOLD), |
| 148 | ), |
| 149 | Span::styled( |
| 150 | tr(locale, MessageId::ApprovalRepoLawWarning), |
| 151 | Style::default() |
| 152 | .fg(palette::WHALE_ERROR) |
| 153 | .add_modifier(Modifier::BOLD), |
| 154 | ), |
| 155 | ])); |
| 156 | body.push(Line::from(vec![ |
| 157 | Span::raw(" "), |
| 158 | Span::styled( |
| 159 | tr(locale, MessageId::ApprovalRepoLawRuleLabel), |
| 160 | Style::default().fg(palette::TEXT_HINT), |
| 161 | ), |
| 162 | Span::styled( |
| 163 | self.request.description.clone(), |
| 164 | Style::default().fg(palette::TEXT_SECONDARY), |
| 165 | ), |
| 166 | ])); |
| 167 | } |
| 168 | |
| 169 | // Command / change preview FIRST — for an approval the thing being run |
| 170 | // is the load-bearing content, so on a short terminal it is the |
| 171 | // secondary context (about/impacts/category) that scrolls away, never |
| 172 | // the command. |
| 173 | let details = self.request.prominent_detail_items(locale); |
| 174 | if details.is_empty() { |
| 175 | push_params_detail_line(&mut body, self.request, locale, area.width); |
| 176 | } else { |
| 177 | let mut rendered_detail = false; |
| 178 | for detail in details.iter().take(4) { |
| 179 | let is_change_preview = matches!(detail.label.as_str(), "Preview" | "预览"); |
| 180 | if let Some(shell_lines) = detail.shell_lines.as_deref() { |
| 181 | let command_width = area.width.saturating_sub(10) as usize; |
| 182 | // A short approval band has room for only one detail row |
| 183 | // before its truncation hint. Project the most useful |
| 184 | // command/change into that row instead of spending it on |
| 185 | // setup (`cd`, `set`) or diff metadata. The complete, |
| 186 | // original-order value remains available in the details |
| 187 | // pager. |
| 188 | let inline_shell_lines = prioritize_inline_shell_lines( |
| 189 | shell_lines, |
| 190 | is_change_preview, |
| 191 | area.height <= 24, |
| 192 | ); |
| 193 | // Bound every multi-line preview so one huge command cannot |
| 194 | // grow the band without limit; the details chord opens the rest. |
| 195 | let max_rows = if is_change_preview { |
| 196 | if self.request.intent_summary.is_some() { |
| 197 | Some(3) |
| 198 | } else { |
| 199 | Some(5) |
| 200 | } |
| 201 | } else { |
| 202 | Some(8) |
| 203 | }; |
| 204 | push_shell_command_lines( |
| 205 | &mut body, |
| 206 | &detail.label, |
| 207 | &inline_shell_lines, |
| 208 | command_width.max(20), |
| 209 | max_rows, |
| 210 | ); |
| 211 | } else { |
| 212 | push_detail_line(&mut body, &detail.label, &detail.value); |
| 213 | } |
| 214 | rendered_detail = true; |
| 215 | } |
| 216 | if !rendered_detail { |
| 217 | push_params_detail_line(&mut body, self.request, locale, area.width); |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // Intent summary ("why this change is needed", #2381). |
| 222 | if let Some(ref summary) = self.request.intent_summary { |
| 223 | let max_width = area.width.saturating_sub(14) as usize; |
| 224 | if max_width > 0 { |
| 225 | let intent_label = tr(locale, MessageId::ApprovalIntentLabel); |
| 226 | let summary_lines: Vec<&str> = summary.lines().collect(); |
| 227 | let intent_lines = 3usize; |
| 228 | for (i, sline) in summary_lines.iter().take(intent_lines).enumerate() { |
| 229 | let prefix = if i == 0 { |
| 230 | intent_label.clone() |
| 231 | } else { |
| 232 | Cow::Borrowed(" ") |
| 233 | }; |
| 234 | let truncated = crate::utils::truncate_with_ellipsis(sline, max_width, "..."); |
| 235 | body.push(Line::from(vec![ |
| 236 | Span::raw(" "), |
| 237 | Span::styled( |
| 238 | prefix, |
| 239 | if i == 0 { |
| 240 | Style::default().fg(palette::TEXT_HINT) |
| 241 | } else { |
| 242 | Style::default() |
| 243 | }, |
| 244 | ), |
| 245 | Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)), |
| 246 | ])); |
| 247 | } |
| 248 | if summary_lines.len() > intent_lines { |
| 249 | let more = tr(locale, MessageId::ApprovalMoreLines) |
| 250 | .replace("{count}", &(summary_lines.len() - intent_lines).to_string()); |
| 251 | body.push(Line::from(vec![ |
| 252 | Span::raw(" "), |
| 253 | Span::styled(more, Style::default().fg(palette::TEXT_HINT)), |
| 254 | ])); |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // Destructive policy / cancel semantics — critical stakes only. For |
| 260 | // routine and elevated work the controls speak for themselves; the |
| 261 | // extra policy prose was noise that made every edit read like an |
| 262 | // emergency. |
| 263 | // The semantics prose says Esc stops the turn; a child's card hides |
| 264 | // on Esc instead, so it never shows that line. |
| 265 | if critical && self.request.owner.is_none() { |
| 266 | push_destructive_approval_semantics(&mut body, locale, false); |
| 267 | } |
| 268 | |
| 269 | // Secondary context: what it is and what it touches. Only critical |
| 270 | // prompts carry the full about/impact/category dossier by default — |
| 271 | // everything stays one details chord away in the pager. Keep a single |
| 272 | // About line as fallback context when nothing else was rendered. |
| 273 | if critical || details.is_empty() { |
| 274 | body.push(Line::from(vec![ |
| 275 | Span::raw(" "), |
| 276 | Span::styled(label_about(locale), Style::default().fg(palette::TEXT_HINT)), |
| 277 | Span::styled( |
| 278 | self.request.description_for_locale(locale), |
| 279 | Style::default().fg(palette::TEXT_BODY), |
| 280 | ), |
| 281 | ])); |
| 282 | } |
| 283 | if critical { |
| 284 | for impact in self.request.impacts_for_locale(locale).into_iter().take(4) { |
| 285 | body.push(Line::from(vec![ |
| 286 | Span::raw(" "), |
| 287 | Span::styled( |
| 288 | label_impact(locale), |
| 289 | Style::default().fg(palette::TEXT_HINT), |
| 290 | ), |
| 291 | Span::styled(impact, Style::default().fg(palette::TEXT_BODY)), |
| 292 | ])); |
| 293 | } |
| 294 | // Category line — localized risk category. |
| 295 | let (cat_label, cat_color) = category_label_for(self.request, locale); |
| 296 | body.push(Line::from(vec![ |
| 297 | Span::raw(" "), |
| 298 | Span::styled(label_type(locale), Style::default().fg(palette::TEXT_HINT)), |
| 299 | Span::styled( |
| 300 | cat_label, |
| 301 | Style::default().fg(cat_color).add_modifier(Modifier::BOLD), |
| 302 | ), |
| 303 | ])); |
| 304 | } |
| 305 | |
| 306 | // Preview the validated persistent-rule candidates. Informational, so |
| 307 | // they live in the scrollable body rather than the action rows. |
| 308 | let essential_len = body.len(); |
| 309 | if let Some(preview) = self.request.ask_rule_save_preview().filter(|_| offer_save) { |
| 310 | push_permission_rule_save_preview( |
| 311 | &mut body, |
| 312 | &preview, |
| 313 | palette_colors.shortcut, |
| 314 | area.width, |
| 315 | compact_save_preview, |
| 316 | ); |
| 317 | } |
| 318 | if let Some(preview) = self |
| 319 | .request |
| 320 | .allow_rule_save_preview() |
| 321 | .filter(|_| offer_save) |
| 322 | { |
| 323 | push_permission_rule_save_preview( |
| 324 | &mut body, |
| 325 | &preview, |
| 326 | palette_colors.shortcut, |
| 327 | area.width, |
| 328 | compact_save_preview, |
| 329 | ); |
| 330 | } |
| 331 | |
| 332 | let controls = build_approval_controls( |
| 333 | self.request, |
| 334 | self.view, |
| 335 | risk, |
| 336 | locale, |
| 337 | palette_colors.accent, |
| 338 | palette_colors.shortcut, |
| 339 | offer_save, |
| 340 | ); |
| 341 | (body, essential_len, controls) |
| 342 | } |
| 343 | |
| 344 | /// Bottom-anchored band this inline prompt occupies within `area`. Must |
| 345 | /// match what `render` paints so the backdrop dims exactly this strip. |
| 346 | pub(crate) fn inline_region(&self, area: Rect) -> Rect { |
| 347 | if area.width == 0 || area.height == 0 { |
| 348 | return Rect { |
| 349 | x: area.x, |
| 350 | y: area.y.saturating_add(area.height), |
| 351 | width: 0, |
| 352 | height: 0, |
| 353 | }; |
| 354 | } |
| 355 | if self.view.collapsed { |
| 356 | // Collapsed mode is a single banner row pinned to the bottom. |
| 357 | let h = area.height.min(1); |
| 358 | return Rect { |
| 359 | x: area.x, |
| 360 | y: area.y.saturating_add(area.height.saturating_sub(h)), |
| 361 | width: area.width, |
| 362 | height: h, |
| 363 | }; |
| 364 | } |
| 365 | self.build_inline_content(area).region(area) |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | impl Renderable for ApprovalWidget<'_> { |
| 370 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 371 | if area.width == 0 || area.height == 0 { |
| 372 | return; |
| 373 | } |
| 374 | |
| 375 | // Collapsed mode: a single-line banner at the bottom of the area |
| 376 | // so the user can still see the transcript behind it. |
| 377 | if self.view.collapsed { |
| 378 | self.view.set_mouse_hitboxes(Vec::new()); |
| 379 | self.view.set_save_preview_shown(false); |
| 380 | let bar_y = area.y.saturating_add(area.height.saturating_sub(1)); |
| 381 | let bar_area = Rect::new(area.x, bar_y, area.width, 1); |
| 382 | Clear.render(bar_area, buf); |
| 383 | |
| 384 | let stakes = self.request.stakes(); |
| 385 | let repo_law = self.request.is_repo_law_prompt(); |
| 386 | let palette_colors = if repo_law { |
| 387 | repo_law_approval_palette() |
| 388 | } else { |
| 389 | approval_palette(stakes) |
| 390 | }; |
| 391 | let summary = format!( |
| 392 | " {} — {} [Tab to expand] ", |
| 393 | if repo_law { |
| 394 | tr(self.view.locale(), MessageId::ApprovalRepoLawTitle) |
| 395 | } else { |
| 396 | Cow::Owned(approval_heading(self.request, self.view.locale())) |
| 397 | }, |
| 398 | if repo_law { |
| 399 | tr(self.view.locale(), MessageId::ApprovalRepoLawBadge) |
| 400 | } else { |
| 401 | effect_badge_text(self.request, stakes, self.view.locale()) |
| 402 | }, |
| 403 | ); |
| 404 | let line = Line::from(Span::styled( |
| 405 | summary, |
| 406 | Style::default() |
| 407 | .fg(palette::WHALE_BG) |
| 408 | .bg(palette_colors.accent) |
| 409 | .add_modifier(Modifier::BOLD), |
| 410 | )); |
| 411 | Paragraph::new(line).render(bar_area, buf); |
| 412 | return; |
| 413 | } |
| 414 | |
| 415 | // Compute stakes once for this render pass (it runs command_safety |
| 416 | // analysis on shell commands); reuse it for the palette and the |
| 417 | // left-rail gate instead of re-deriving per band. |
| 418 | let stakes = self.request.stakes(); |
| 419 | let repo_law = self.request.is_repo_law_prompt(); |
| 420 | let palette_colors = if repo_law { |
| 421 | repo_law_approval_palette() |
| 422 | } else { |
| 423 | approval_palette(stakes) |
| 424 | }; |
| 425 | let content = self.build_inline_content(area); |
| 426 | let region = content.region(area); |
| 427 | let InlineContent { |
| 428 | body, |
| 429 | save_start, |
| 430 | controls, |
| 431 | save_shown, |
| 432 | .. |
| 433 | } = content; |
| 434 | self.view.set_save_preview_shown(false); |
| 435 | if region.width == 0 || region.height == 0 { |
| 436 | return; |
| 437 | } |
| 438 | self.view.set_save_preview_shown(save_shown); |
| 439 | |
| 440 | // Opaque inline panel anchored to the bottom of the frame. The |
| 441 | // transcript above stays visible; only this band is painted — the |
| 442 | // approval is no longer a full-screen takeover (#3799). |
| 443 | Clear.render(region, buf); |
| 444 | Block::default() |
| 445 | .style(Style::default().bg(palette::WHALE_BG)) |
| 446 | .render(region, buf); |
| 447 | |
| 448 | // Top separator rule, risk-tinted, so the prompt reads as a distinct |
| 449 | // panel without a heavy full border box. |
| 450 | let rule_glyph = if repo_law { "═" } else { "─" }; |
| 451 | let rule: String = rule_glyph.repeat(region.width as usize); |
| 452 | buf.set_string( |
| 453 | region.x, |
| 454 | region.y, |
| 455 | &rule, |
| 456 | Style::default().fg(palette_colors.border), |
| 457 | ); |
| 458 | |
| 459 | // Reserve the controls FIRST: they take their rows off the bottom of |
| 460 | // the band and can never be clipped, no matter how long the body is. |
| 461 | // The informational body takes whatever remains and shows a pager |
| 462 | // affordance when it does not fit. This is the core #3799 fix — the |
| 463 | // action row is no longer the last thing in a single clipping |
| 464 | // Paragraph. |
| 465 | let inner_top = region.y.saturating_add(1); |
| 466 | let inner_height = region.height.saturating_sub(1); |
| 467 | let control_rows = measure_wrapped_rows(&controls, region.width).min(inner_height); |
| 468 | let body_height = inner_height.saturating_sub(control_rows); |
| 469 | |
| 470 | let body_rect = Rect { |
| 471 | x: region.x, |
| 472 | y: inner_top, |
| 473 | width: region.width, |
| 474 | height: body_height, |
| 475 | }; |
| 476 | let control_rect = Rect { |
| 477 | x: region.x, |
| 478 | y: inner_top.saturating_add(body_height), |
| 479 | width: region.width, |
| 480 | height: control_rows, |
| 481 | }; |
| 482 | |
| 483 | // One hitbox per option in `ApprovalOption` order; an option the card |
| 484 | // is not offering keeps an empty box so the indices stay aligned. |
| 485 | let mut hitboxes = Vec::new(); |
| 486 | let options = |
| 487 | approval_options_for_request(self.request, self.request.risk, self.view.locale()); |
| 488 | let mut shown_index = 0; |
| 489 | for option in &options { |
| 490 | if option.persistent && !save_shown { |
| 491 | hitboxes.push(Rect::default()); |
| 492 | continue; |
| 493 | } |
| 494 | let first_line = 1 + shown_index; |
| 495 | shown_index += 1; |
| 496 | let y_offset = measure_wrapped_rows(&controls[..first_line], region.width); |
| 497 | let next_offset = measure_wrapped_rows(&controls[..first_line + 1], region.width); |
| 498 | let y = control_rect.y.saturating_add(y_offset); |
| 499 | let height = next_offset.saturating_sub(y_offset).min( |
| 500 | control_rect |
| 501 | .y |
| 502 | .saturating_add(control_rect.height) |
| 503 | .saturating_sub(y), |
| 504 | ); |
| 505 | if height > 0 { |
| 506 | hitboxes.push(Rect::new(control_rect.x, y, control_rect.width, height)); |
| 507 | } |
| 508 | } |
| 509 | self.view.set_mouse_hitboxes(hitboxes); |
| 510 | |
| 511 | let body_rows = measure_wrapped_rows(&body, region.width); |
| 512 | if body_rows > body_height && body_height > 0 { |
| 513 | // Body does not fit (short terminal). The save preview is pinned |
| 514 | // directly above the controls that offer to save it; the request |
| 515 | // detail above it shows as much as fits and points at the params |
| 516 | // pager through the platform-aware details chord. |
| 517 | let mut body = body; |
| 518 | let save = body.split_off(save_start.min(body.len())); |
| 519 | let save_rows = measure_wrapped_rows(&save, region.width).min(body_height); |
| 520 | let head_height = body_height.saturating_sub(save_rows); |
| 521 | if head_height > 0 { |
| 522 | let shown = head_height.saturating_sub(1); |
| 523 | if shown > 0 { |
| 524 | Paragraph::new(body).wrap(Wrap { trim: false }).render( |
| 525 | Rect { |
| 526 | height: shown, |
| 527 | ..body_rect |
| 528 | }, |
| 529 | buf, |
| 530 | ); |
| 531 | } |
| 532 | buf.set_string( |
| 533 | region.x, |
| 534 | body_rect.y.saturating_add(shown), |
| 535 | approval_truncation_hint(self.view.locale()), |
| 536 | Style::default().fg(palette::TEXT_HINT), |
| 537 | ); |
| 538 | } |
| 539 | if save_rows > 0 { |
| 540 | Paragraph::new(save).wrap(Wrap { trim: false }).render( |
| 541 | Rect { |
| 542 | y: body_rect.y.saturating_add(head_height), |
| 543 | height: save_rows, |
| 544 | ..body_rect |
| 545 | }, |
| 546 | buf, |
| 547 | ); |
| 548 | } |
| 549 | } else { |
| 550 | Paragraph::new(body) |
| 551 | .wrap(Wrap { trim: false }) |
| 552 | .render(body_rect, buf); |
| 553 | } |
| 554 | |
| 555 | Paragraph::new(controls) |
| 556 | .wrap(Wrap { trim: false }) |
| 557 | .render(control_rect, buf); |
| 558 | } |
| 559 | |
| 560 | fn desired_height(&self, _width: u16) -> u16 { |
| 561 | 1 |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | /// The inline approval band's lines. `body[save_start..]` is the |
| 566 | /// persistent-rule save preview; `save_reserve` is the rows its one-line |
| 567 | /// form needs, which the band always keeps for it. `save_shown` says the |
| 568 | /// preview is on screen, and with it the offers to save the rule. |
| 569 | struct InlineContent { |
| 570 | body: Vec<Line<'static>>, |
| 571 | save_start: usize, |
| 572 | save_reserve: u16, |
| 573 | controls: Vec<Line<'static>>, |
| 574 | save_shown: bool, |
| 575 | } |
| 576 | |
| 577 | impl InlineContent { |
| 578 | fn region(&self, area: Rect) -> Rect { |
| 579 | inline_region_for(area, &self.body, self.save_reserve, &self.controls) |
| 580 | } |
| 581 | |
| 582 | /// Whether the band keeps the whole one-line save preview on screen |
| 583 | /// above the controls (render pins it there when the body is cut). |
| 584 | fn save_preview_fits(&self, area: Rect) -> bool { |
| 585 | let region = self.region(area); |
| 586 | let inner_height = region.height.saturating_sub(1); |
| 587 | let control_rows = measure_wrapped_rows(&self.controls, region.width).min(inner_height); |
| 588 | self.save_reserve <= inner_height.saturating_sub(control_rows) |
| 589 | } |
| 590 | |
| 591 | /// Whether the whole body fits the band above the controls. |
| 592 | fn body_fits(&self, area: Rect) -> bool { |
| 593 | let region = self.region(area); |
| 594 | let inner_height = region.height.saturating_sub(1); |
| 595 | let control_rows = measure_wrapped_rows(&self.controls, region.width).min(inner_height); |
| 596 | measure_wrapped_rows(&self.body, region.width) <= inner_height.saturating_sub(control_rows) |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | /// Bottom-anchored band the inline approval prompt occupies within `area`. |
| 601 | /// Sized to the measured content, capped to half the frame like the compact |
| 602 | /// permission surfaces in peer coding agents, and always tall enough to show |
| 603 | /// the reserved controls (#3799). Full details remain available through the |
| 604 | /// platform-aware details chord. |
| 605 | /// |
| 606 | /// `save_rows` are the rows of the one-line persistent-rule save preview. |
| 607 | /// They are always reserved after the controls, on every frame height, |
| 608 | /// because the controls offer to save that rule and the person must see what |
| 609 | /// it covers. |
| 610 | fn inline_region_for( |
| 611 | area: Rect, |
| 612 | body: &[Line<'static>], |
| 613 | save_rows: u16, |
| 614 | controls: &[Line<'static>], |
| 615 | ) -> Rect { |
| 616 | if area.width == 0 || area.height == 0 { |
| 617 | return Rect { |
| 618 | x: area.x, |
| 619 | y: area.y.saturating_add(area.height), |
| 620 | width: 0, |
| 621 | height: 0, |
| 622 | }; |
| 623 | } |
| 624 | let width = area.width; |
| 625 | let body_rows = measure_wrapped_rows(body, width); |
| 626 | let control_rows = measure_wrapped_rows(controls, width); |
| 627 | // +1 for the top separator rule. |
| 628 | let desired = 1u16.saturating_add(body_rows).saturating_add(control_rows); |
| 629 | // Never shrink below the rule + controls. At normal terminal heights, |
| 630 | // reserve four body rows: header, detail label, at least one command or |
| 631 | // preview row, and the truncation hint. Half a viewport is the preferred |
| 632 | // cap; up to four fifths is allowed only when necessary to retain that |
| 633 | // load-bearing preview on a short frame. The extra permanent-grant row |
| 634 | // needs one more reserved line than the legacy four-action card. Truly |
| 635 | // tiny frames prioritize the complete action set and details chord. |
| 636 | let controls_floor = 1u16.saturating_add(control_rows).min(area.height); |
| 637 | // The request's own preview (what runs now) and the save preview (what a |
| 638 | // saved rule would cover from now on) are reserved side by side: neither |
| 639 | // may push the other off a short band. |
| 640 | let head_rows = body_rows.saturating_sub(save_rows); |
| 641 | let preview_rows = if area.height >= 16 { |
| 642 | head_rows.min(4).saturating_add(save_rows) |
| 643 | } else { |
| 644 | save_rows |
| 645 | }; |
| 646 | let preview_floor = controls_floor.saturating_add(preview_rows).min(area.height); |
| 647 | let preferred_cap = area.height.div_ceil(2); |
| 648 | let short_frame_cap = area.height.saturating_mul(4).div_ceil(5); |
| 649 | // The save preview is never traded for the short-frame cap: whenever the |
| 650 | // frame has rows after the controls, the preview gets them first. |
| 651 | let save_floor = controls_floor.saturating_add(save_rows).min(area.height); |
| 652 | let max_height = preferred_cap |
| 653 | .max(preview_floor.min(short_frame_cap.saturating_add(save_rows))) |
| 654 | .max(save_floor) |
| 655 | .min(area.height); |
| 656 | let min_height = controls_floor; |
| 657 | let height = desired.clamp(min_height, max_height); |
| 658 | Rect { |
| 659 | x: area.x, |
| 660 | y: area.y.saturating_add(area.height.saturating_sub(height)), |
| 661 | width, |
| 662 | height, |
| 663 | } |
| 664 | } |
| 665 | |
| 666 | /// Terminal rows `lines` occupy under the exact ratatui word-wrap used by the |
| 667 | /// renderer. Exact measurement keeps localized controls and their mouse |
| 668 | /// hitboxes aligned without padding the compact approval band. |
| 669 | fn measure_wrapped_rows(lines: &[Line<'_>], width: u16) -> u16 { |
| 670 | if width == 0 { |
| 671 | return lines.len() as u16; |
| 672 | } |
| 673 | let rows = Paragraph::new(lines.to_vec()) |
| 674 | .wrap(Wrap { trim: false }) |
| 675 | .line_count(width); |
| 676 | u16::try_from(rows).unwrap_or(u16::MAX) |
| 677 | } |
| 678 | |
| 679 | /// Build the always-visible approval controls: a "proceed?" prompt, the |
| 680 | /// numbered/selectable options, and the selection hint. Rendered into a region |
| 681 | /// reserved off the bottom of the band so it can never be clipped (#3799). |
| 682 | fn build_approval_controls( |
| 683 | request: &ApprovalRequest, |
| 684 | view: &ApprovalView, |
| 685 | risk: RiskLevel, |
| 686 | locale: Locale, |
| 687 | accent: Color, |
| 688 | shortcut: Color, |
| 689 | offer_save: bool, |
| 690 | ) -> Vec<Line<'static>> { |
| 691 | let mut controls: Vec<Line<'static>> = Vec::with_capacity(6); |
| 692 | controls.push(Line::from(vec![ |
| 693 | Span::raw(" "), |
| 694 | Span::styled( |
| 695 | approval_proceed_question(locale), |
| 696 | Style::default() |
| 697 | .fg(palette::TEXT_BODY) |
| 698 | .add_modifier(Modifier::BOLD), |
| 699 | ), |
| 700 | ])); |
| 701 | let options = approval_options_for_request(request, risk, locale); |
| 702 | for (i, opt) in options.iter().enumerate() { |
| 703 | if opt.persistent && !offer_save { |
| 704 | continue; |
| 705 | } |
| 706 | let is_selected = i == view.selected(); |
| 707 | let label_color = if opt.dangerous { |
| 708 | accent |
| 709 | } else { |
| 710 | palette::TEXT_BODY |
| 711 | }; |
| 712 | let option_style = approval_option_style(is_selected, label_color); |
| 713 | let shortcut_style = approval_option_style(is_selected, shortcut); |
| 714 | // Leading caret marks the row Enter will fire — selection is not |
| 715 | // signalled by background alone. |
| 716 | let lead = if is_selected { |
| 717 | Span::styled("\u{276f} ", approval_selected_style()) |
| 718 | } else { |
| 719 | Span::raw(" ") |
| 720 | }; |
| 721 | controls.push(Line::from(vec![ |
| 722 | lead, |
| 723 | Span::styled( |
| 724 | format!("[{}] ", opt.key_hint), |
| 725 | shortcut_style.add_modifier(Modifier::BOLD), |
| 726 | ), |
| 727 | Span::styled(opt.label.to_string(), option_style), |
| 728 | ])); |
| 729 | } |
| 730 | controls.push(Line::from(vec![ |
| 731 | Span::raw(" "), |
| 732 | Span::styled( |
| 733 | if request.owner.is_some() { |
| 734 | child_footer_controls(locale) |
| 735 | } else { |
| 736 | footer_controls(locale) |
| 737 | }, |
| 738 | Style::default().fg(palette::TEXT_MUTED), |
| 739 | ), |
| 740 | if offer_save && request.can_save_ask_rule() { |
| 741 | Span::styled(save_ask_rule_hint(locale), Style::default().fg(shortcut)) |
| 742 | } else { |
| 743 | Span::raw("") |
| 744 | }, |
| 745 | ])); |
| 746 | controls |
| 747 | } |
| 748 | |
| 749 | fn approval_proceed_question(locale: Locale) -> &'static str { |
| 750 | match locale { |
| 751 | Locale::ZhHans => "是否继续?", |
| 752 | _ => "Do you want to proceed?", |
| 753 | } |
| 754 | } |
| 755 | |
| 756 | fn approval_truncation_hint(locale: Locale) -> Cow<'static, str> { |
| 757 | let details = crate::tui::shell_key_routing::tool_details_chord(); |
| 758 | Cow::Owned(tr(locale, MessageId::ApprovalTruncationHint).replace("{details}", details.as_ref())) |
| 759 | } |
| 760 | |
| 761 | /// Approval palette per risk variant. |
| 762 | struct ApprovalColors { |
| 763 | border: Color, |
| 764 | accent: Color, |
| 765 | shortcut: Color, |
| 766 | } |
| 767 | |
| 768 | fn approval_palette(stakes: crate::tui::approval::ApprovalStakes) -> ApprovalColors { |
| 769 | use crate::tui::approval::ApprovalStakes; |
| 770 | match stakes { |
| 771 | ApprovalStakes::Routine => ApprovalColors { |
| 772 | border: palette::BORDER_COLOR, |
| 773 | accent: palette::WHALE_HUMAN, |
| 774 | shortcut: palette::WHALE_ACTION, |
| 775 | }, |
| 776 | // Ordinary state-touching work: a calm ask, not an alarm. |
| 777 | ApprovalStakes::Elevated => ApprovalColors { |
| 778 | border: palette::WHALE_HUMAN, |
| 779 | accent: palette::WHALE_HUMAN, |
| 780 | shortcut: palette::WHALE_ACTION, |
| 781 | }, |
| 782 | ApprovalStakes::Critical => ApprovalColors { |
| 783 | border: palette::WHALE_ERROR, |
| 784 | accent: palette::WHALE_ERROR, |
| 785 | shortcut: palette::STATUS_WARNING, |
| 786 | }, |
| 787 | } |
| 788 | } |
| 789 | |
| 790 | fn repo_law_approval_palette() -> ApprovalColors { |
| 791 | ApprovalColors { |
| 792 | border: palette::STATUS_WARNING, |
| 793 | accent: palette::WHALE_ERROR, |
| 794 | shortcut: palette::STATUS_WARNING, |
| 795 | } |
| 796 | } |
| 797 | |
| 798 | fn approval_selected_style() -> Style { |
| 799 | menu_style::selected_row_style() |
| 800 | } |
| 801 | |
| 802 | fn approval_option_style(is_selected: bool, color: Color) -> Style { |
| 803 | if is_selected { |
| 804 | approval_selected_style() |
| 805 | } else { |
| 806 | Style::default().fg(color) |
| 807 | } |
| 808 | } |
| 809 | |
| 810 | /// The approval card's heading: the plain summary of the call (E6), in the |
| 811 | /// card's language, falling back to the tool name only when no summary was |
| 812 | /// derived. |
| 813 | fn approval_heading(request: &ApprovalRequest, locale: Locale) -> String { |
| 814 | if request.summary.trim().is_empty() { |
| 815 | return request.tool_name.clone(); |
| 816 | } |
| 817 | let summary = request.summary_for_locale(locale); |
| 818 | if summary.trim().is_empty() { |
| 819 | request.tool_name.clone() |
| 820 | } else { |
| 821 | summary |
| 822 | } |
| 823 | } |
| 824 | |
| 825 | /// Badge naming what the call does, not a risk tier: "Reads only", "Changes |
| 826 | /// files", "Runs a command", "Uses the network". Anything the stakes |
| 827 | /// classifier calls destructive or publishing reads "Can't be undone". |
| 828 | fn effect_badge_text( |
| 829 | request: &ApprovalRequest, |
| 830 | stakes: crate::tui::approval::ApprovalStakes, |
| 831 | locale: Locale, |
| 832 | ) -> Cow<'static, str> { |
| 833 | if stakes == crate::tui::approval::ApprovalStakes::Critical { |
| 834 | return tr(locale, MessageId::ApprovalRiskDestructive); |
| 835 | } |
| 836 | let id = match request.category { |
| 837 | ToolCategory::Safe | ToolCategory::McpRead => MessageId::ApprovalEffectReadsOnly, |
| 838 | ToolCategory::FileWrite => MessageId::ApprovalEffectChangesFiles, |
| 839 | ToolCategory::Shell => MessageId::ApprovalEffectRunsCommand, |
| 840 | ToolCategory::Network => MessageId::ApprovalEffectUsesNetwork, |
| 841 | ToolCategory::McpAction => MessageId::ApprovalEffectConnectedApp, |
| 842 | ToolCategory::Agent => MessageId::ApprovalEffectStartsAgent, |
| 843 | ToolCategory::Unknown => MessageId::ApprovalEffectUnclassified, |
| 844 | }; |
| 845 | tr(locale, id) |
| 846 | } |
| 847 | |
| 848 | fn category_label_for(request: &ApprovalRequest, locale: Locale) -> (Cow<'static, str>, Color) { |
| 849 | let category = request.category; |
| 850 | let label = match category { |
| 851 | ToolCategory::Safe => tr(locale, MessageId::ApprovalCategorySafe), |
| 852 | ToolCategory::FileWrite => tr(locale, MessageId::ApprovalCategoryFileWrite), |
| 853 | ToolCategory::Shell => tr(locale, MessageId::ApprovalCategoryShell), |
| 854 | ToolCategory::Network => tr(locale, MessageId::ApprovalCategoryNetwork), |
| 855 | ToolCategory::McpRead => tr(locale, MessageId::ApprovalCategoryMcpRead), |
| 856 | ToolCategory::McpAction => tr(locale, MessageId::ApprovalCategoryMcpAction), |
| 857 | ToolCategory::Agent => tr(locale, MessageId::ApprovalCategoryAgent), |
| 858 | ToolCategory::Unknown => tr(locale, MessageId::ApprovalCategoryUnknown), |
| 859 | }; |
| 860 | // "Connected app (github)": name the server the tool comes from. |
| 861 | let label = match ( |
| 862 | category, |
| 863 | crate::tui::approval::connected_app_server(&request.tool_name), |
| 864 | ) { |
| 865 | (ToolCategory::McpRead | ToolCategory::McpAction, Some(server)) => { |
| 866 | Cow::Owned(format!("{label} ({server})")) |
| 867 | } |
| 868 | _ => label, |
| 869 | }; |
| 870 | let color = match category { |
| 871 | ToolCategory::Safe => palette::STATUS_SUCCESS, |
| 872 | ToolCategory::FileWrite => palette::STATUS_WARNING, |
| 873 | ToolCategory::Shell => palette::STATUS_ERROR, |
| 874 | ToolCategory::Network => palette::STATUS_WARNING, |
| 875 | ToolCategory::McpRead => palette::WHALE_ACTION, |
| 876 | ToolCategory::McpAction => palette::STATUS_WARNING, |
| 877 | ToolCategory::Agent => palette::WHALE_ACTION, |
| 878 | ToolCategory::Unknown => palette::STATUS_ERROR, |
| 879 | }; |
| 880 | (label, color) |
| 881 | } |
| 882 | |
| 883 | fn label_type(locale: Locale) -> Cow<'static, str> { |
| 884 | tr(locale, MessageId::ApprovalFieldType) |
| 885 | } |
| 886 | |
| 887 | fn label_about(locale: Locale) -> Cow<'static, str> { |
| 888 | tr(locale, MessageId::ApprovalFieldAbout) |
| 889 | } |
| 890 | |
| 891 | fn label_impact(locale: Locale) -> Cow<'static, str> { |
| 892 | tr(locale, MessageId::ApprovalFieldImpact) |
| 893 | } |
| 894 | |
| 895 | fn label_params(locale: Locale) -> Cow<'static, str> { |
| 896 | tr(locale, MessageId::ApprovalFieldParams) |
| 897 | } |
| 898 | |
| 899 | fn push_detail_line(lines: &mut Vec<Line<'static>>, label: &str, value: &str) { |
| 900 | lines.push(Line::from(vec![ |
| 901 | Span::raw(" "), |
| 902 | Span::styled( |
| 903 | format!("{label:<7} "), |
| 904 | Style::default() |
| 905 | .fg(palette::WHALE_ACTION) |
| 906 | .add_modifier(Modifier::BOLD), |
| 907 | ), |
| 908 | Span::styled(value.to_string(), Style::default().fg(palette::TEXT_BODY)), |
| 909 | ])); |
| 910 | } |
| 911 | |
| 912 | fn push_params_detail_line( |
| 913 | lines: &mut Vec<Line<'static>>, |
| 914 | request: &ApprovalRequest, |
| 915 | locale: Locale, |
| 916 | card_width: u16, |
| 917 | ) { |
| 918 | let params_str = request.params_display(); |
| 919 | let params_width = card_width.saturating_sub(14) as usize; |
| 920 | let params_truncated = |
| 921 | crate::utils::truncate_with_ellipsis(¶ms_str, params_width.max(20), "..."); |
| 922 | lines.push(Line::from(vec![ |
| 923 | Span::raw(" "), |
| 924 | Span::styled( |
| 925 | label_params(locale), |
| 926 | Style::default().fg(palette::TEXT_HINT), |
| 927 | ), |
| 928 | Span::styled( |
| 929 | params_truncated, |
| 930 | Style::default().fg(palette::TEXT_SECONDARY), |
| 931 | ), |
| 932 | ])); |
| 933 | } |
| 934 | |
| 935 | fn push_permission_rule_save_preview( |
| 936 | lines: &mut Vec<Line<'static>>, |
| 937 | preview: &crate::tui::approval::PermissionRuleSavePreview, |
| 938 | shortcut: Color, |
| 939 | card_width: u16, |
| 940 | compact: bool, |
| 941 | ) { |
| 942 | if compact { |
| 943 | // One line: what saving does, then what it covers, with the count of |
| 944 | // entries that did not fit kept visible after any ellipsis. |
| 945 | let summary = preview.summary(); |
| 946 | let more = if preview.omitted > 0 { |
| 947 | format!(" +{} more", preview.omitted) |
| 948 | } else { |
| 949 | String::new() |
| 950 | }; |
| 951 | let budget = (card_width as usize) |
| 952 | .saturating_sub(10 + summary.chars().count() + 3 + more.chars().count()) |
| 953 | .max(12); |
| 954 | let entries = |
| 955 | crate::utils::truncate_with_ellipsis(&preview.entries.join("; "), budget, "..."); |
| 956 | lines.push(Line::from(vec![ |
| 957 | Span::raw(" "), |
| 958 | Span::styled( |
| 959 | "Save: ", |
| 960 | Style::default().fg(shortcut).add_modifier(Modifier::BOLD), |
| 961 | ), |
| 962 | Span::styled(summary, Style::default().fg(palette::TEXT_BODY)), |
| 963 | Span::styled( |
| 964 | format!(" · {entries}{more}"), |
| 965 | Style::default().fg(palette::TEXT_SECONDARY), |
| 966 | ), |
| 967 | ])); |
| 968 | return; |
| 969 | } |
| 970 | lines.push(Line::from(vec![ |
| 971 | Span::raw(" "), |
| 972 | Span::styled( |
| 973 | "Save: ", |
| 974 | Style::default().fg(shortcut).add_modifier(Modifier::BOLD), |
| 975 | ), |
| 976 | Span::styled(preview.summary(), Style::default().fg(palette::TEXT_BODY)), |
| 977 | ])); |
| 978 | |
| 979 | let entry_width = card_width.saturating_sub(10) as usize; |
| 980 | let entries = preview.entries.join("; "); |
| 981 | let truncated = crate::utils::truncate_with_ellipsis(&entries, entry_width.max(20), "..."); |
| 982 | lines.push(Line::from(vec![ |
| 983 | Span::raw(" "), |
| 984 | Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)), |
| 985 | ])); |
| 986 | if preview.omitted > 0 { |
| 987 | lines.push(Line::from(vec![ |
| 988 | Span::raw(" "), |
| 989 | Span::styled( |
| 990 | format!("... {} more", preview.omitted), |
| 991 | Style::default().fg(palette::TEXT_HINT), |
| 992 | ), |
| 993 | ])); |
| 994 | } |
| 995 | } |
| 996 | |
| 997 | fn push_shell_command_lines( |
| 998 | lines: &mut Vec<Line<'static>>, |
| 999 | label: &str, |
| 1000 | command_lines: &[String], |
| 1001 | command_width: usize, |
| 1002 | max_rows: Option<usize>, |
| 1003 | ) { |
| 1004 | lines.push(Line::from(vec![ |
| 1005 | Span::raw(" "), |
| 1006 | Span::styled( |
| 1007 | format!("{label}:"), |
| 1008 | Style::default() |
| 1009 | .fg(palette::WHALE_ACTION) |
| 1010 | .add_modifier(Modifier::BOLD), |
| 1011 | ), |
| 1012 | ])); |
| 1013 | |
| 1014 | let mut rendered = 0usize; |
| 1015 | for line in command_lines { |
| 1016 | for wrapped in wrap_text(line, command_width) { |
| 1017 | if max_rows.is_some_and(|limit| rendered >= limit) { |
| 1018 | lines.push(Line::from(vec![ |
| 1019 | Span::raw(" "), |
| 1020 | Span::styled( |
| 1021 | "...", |
| 1022 | Style::default() |
| 1023 | .fg(palette::TEXT_HINT) |
| 1024 | .add_modifier(Modifier::BOLD), |
| 1025 | ), |
| 1026 | ])); |
| 1027 | return; |
| 1028 | } |
| 1029 | lines.push(Line::from(vec![ |
| 1030 | Span::raw(" "), |
| 1031 | Span::styled( |
| 1032 | wrapped, |
| 1033 | Style::default() |
| 1034 | .fg(palette::TEXT_BODY) |
| 1035 | .add_modifier(Modifier::BOLD), |
| 1036 | ), |
| 1037 | ])); |
| 1038 | rendered += 1; |
| 1039 | } |
| 1040 | } |
| 1041 | } |
| 1042 | |
| 1043 | /// Put one representative command/change first for compact inline rendering. |
| 1044 | /// This is a display-only projection: approval parameters and the details |
| 1045 | /// pager retain the exact original order. |
| 1046 | fn prioritize_inline_shell_lines( |
| 1047 | command_lines: &[String], |
| 1048 | is_change_preview: bool, |
| 1049 | compact: bool, |
| 1050 | ) -> Vec<String> { |
| 1051 | if !compact || command_lines.len() < 2 { |
| 1052 | return command_lines.to_vec(); |
| 1053 | } |
| 1054 | |
| 1055 | let representative = if is_change_preview { |
| 1056 | command_lines |
| 1057 | .iter() |
| 1058 | .enumerate() |
| 1059 | .max_by_key(|(index, line)| (preview_line_priority(line), std::cmp::Reverse(*index))) |
| 1060 | .map(|(index, _)| index) |
| 1061 | } else { |
| 1062 | command_lines |
| 1063 | .iter() |
| 1064 | .enumerate() |
| 1065 | .max_by_key(|(index, line)| (command_line_priority(line), std::cmp::Reverse(*index))) |
| 1066 | .map(|(index, _)| index) |
| 1067 | }; |
| 1068 | let Some(representative) = representative.filter(|index| *index > 0) else { |
| 1069 | return command_lines.to_vec(); |
| 1070 | }; |
| 1071 | |
| 1072 | let mut projected = Vec::with_capacity(command_lines.len()); |
| 1073 | projected.push(command_lines[representative].clone()); |
| 1074 | projected.extend( |
| 1075 | command_lines |
| 1076 | .iter() |
| 1077 | .enumerate() |
| 1078 | .filter(|(index, _)| *index != representative) |
| 1079 | .map(|(_, line)| line.clone()), |
| 1080 | ); |
| 1081 | projected |
| 1082 | } |
| 1083 | |
| 1084 | fn preview_line_priority(line: &str) -> u8 { |
| 1085 | let trimmed = line.trim_start(); |
| 1086 | if trimmed.starts_with('+') && !trimmed.starts_with("+++") { |
| 1087 | 4 |
| 1088 | } else if trimmed.starts_with('-') && !trimmed.starts_with("---") { |
| 1089 | 3 |
| 1090 | } else if trimmed.starts_with("@@") { |
| 1091 | 2 |
| 1092 | } else if trimmed.starts_with("diff ") |
| 1093 | || trimmed.starts_with("---") |
| 1094 | || trimmed.starts_with("+++") |
| 1095 | { |
| 1096 | 0 |
| 1097 | } else { |
| 1098 | 1 |
| 1099 | } |
| 1100 | } |
| 1101 | |
| 1102 | fn command_line_priority(line: &str) -> u8 { |
| 1103 | let trimmed = line.trim(); |
| 1104 | if trimmed.is_empty() || trimmed.starts_with('#') { |
| 1105 | return 0; |
| 1106 | } |
| 1107 | |
| 1108 | let tokens = trimmed |
| 1109 | .split(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '|' | '&' | '(' | ')')) |
| 1110 | .filter(|token| !token.is_empty()) |
| 1111 | .map(|token| token.rsplit('/').next().unwrap_or(token)) |
| 1112 | .collect::<Vec<_>>(); |
| 1113 | if tokens.iter().any(|token| { |
| 1114 | matches!( |
| 1115 | *token, |
| 1116 | "rm" | "rmdir" |
| 1117 | | "unlink" |
| 1118 | | "mv" |
| 1119 | | "dd" |
| 1120 | | "chmod" |
| 1121 | | "chown" |
| 1122 | | "kill" |
| 1123 | | "pkill" |
| 1124 | | "shutdown" |
| 1125 | | "reboot" |
| 1126 | | "mkfs" |
| 1127 | ) |
| 1128 | }) || tokens.windows(2).any(|pair| { |
| 1129 | matches!( |
| 1130 | pair, |
| 1131 | ["git", "push"] | ["cargo", "publish"] | ["npm", "publish"] |
| 1132 | ) |
| 1133 | }) || trimmed.contains('>') |
| 1134 | { |
| 1135 | return 4; |
| 1136 | } |
| 1137 | |
| 1138 | let first = tokens.first().copied().unwrap_or_default(); |
| 1139 | if matches!( |
| 1140 | first, |
| 1141 | "cd" | "pushd" | "popd" | "set" | "export" | "unset" | "pwd" | ":" | "true" |
| 1142 | ) { |
| 1143 | 1 |
| 1144 | } else if matches!(first, "echo" | "printf") { |
| 1145 | 2 |
| 1146 | } else { |
| 1147 | 3 |
| 1148 | } |
| 1149 | } |
| 1150 | |
| 1151 | fn push_destructive_approval_semantics( |
| 1152 | lines: &mut Vec<Line<'static>>, |
| 1153 | locale: Locale, |
| 1154 | compact: bool, |
| 1155 | ) { |
| 1156 | if compact { |
| 1157 | let (label, value) = destructive_approval_compact_semantics(locale); |
| 1158 | lines.push(Line::from(vec![ |
| 1159 | Span::raw(" "), |
| 1160 | Span::styled(label, Style::default().fg(palette::TEXT_HINT)), |
| 1161 | Span::styled(value, Style::default().fg(palette::TEXT_SECONDARY)), |
| 1162 | ])); |
| 1163 | return; |
| 1164 | } |
| 1165 | |
| 1166 | for (label, value) in destructive_approval_semantics(locale) { |
| 1167 | lines.push(Line::from(vec![ |
| 1168 | Span::raw(" "), |
| 1169 | Span::styled(label, Style::default().fg(palette::TEXT_HINT)), |
| 1170 | Span::styled(value, Style::default().fg(palette::TEXT_SECONDARY)), |
| 1171 | ])); |
| 1172 | } |
| 1173 | } |
| 1174 | |
| 1175 | fn destructive_approval_compact_semantics(locale: Locale) -> (&'static str, &'static str) { |
| 1176 | match locale { |
| 1177 | Locale::ZhHans => ("规则: ", "批准策略要求确认;拒绝跳过本次,Esc 中止整轮。"), |
| 1178 | _ => ( |
| 1179 | "Why: ", |
| 1180 | "Your permissions ask before this; d doesn't allow it, Esc stops the turn.", |
| 1181 | ), |
| 1182 | } |
| 1183 | } |
| 1184 | |
| 1185 | fn destructive_approval_semantics(locale: Locale) -> [(&'static str, &'static str); 2] { |
| 1186 | match locale { |
| 1187 | Locale::ZhHans => [ |
| 1188 | ("规则: ", "你的设置要求先确认这一步。"), |
| 1189 | ("取消: ", "拒绝只跳过本次工具调用;Esc 会中止整轮。"), |
| 1190 | ], |
| 1191 | _ => [ |
| 1192 | ("Why: ", "Your settings ask you to confirm this step first."), |
| 1193 | ( |
| 1194 | "Stop: ", |
| 1195 | "Don't allow skips only this step; Esc stops the whole turn.", |
| 1196 | ), |
| 1197 | ], |
| 1198 | } |
| 1199 | } |
| 1200 | |
| 1201 | fn footer_controls(locale: Locale) -> Cow<'static, str> { |
| 1202 | // Platform-aware details chord (⌥V on macOS, Alt+V elsewhere). Bare `v` |
| 1203 | // is never advertised as a details shortcut (TUI-DOG-002). |
| 1204 | let details = crate::tui::shell_key_routing::tool_details_chord(); |
| 1205 | Cow::Owned(tr(locale, MessageId::ApprovalControlsHint).replace("{details}", details.as_ref())) |
| 1206 | } |
| 1207 | |
| 1208 | /// Controls hint for a child's card: Esc hides it, `g` opens the agent. |
| 1209 | fn child_footer_controls(locale: Locale) -> Cow<'static, str> { |
| 1210 | let details = crate::tui::shell_key_routing::tool_details_chord(); |
| 1211 | Cow::Owned(format!( |
| 1212 | "{} · {}", |
| 1213 | tr(locale, MessageId::ApprovalControlsHintChild).replace("{details}", details.as_ref()), |
| 1214 | tr(locale, MessageId::ApprovalGoToAgent) |
| 1215 | )) |
| 1216 | } |
| 1217 | |
| 1218 | /// "Agent: {agent} · {role}", dropping the role segment when the roster does |
| 1219 | /// not know the agent's role yet. |
| 1220 | fn approval_owner_header(owner: &crate::tui::approval::ApprovalOwner, locale: Locale) -> String { |
| 1221 | let template = tr(locale, MessageId::ApprovalOwnerHeader); |
| 1222 | let with_agent = template.replace("{agent}", &owner.label); |
| 1223 | match owner.role.as_deref() { |
| 1224 | Some(role) => with_agent.replace("{role}", role), |
| 1225 | None => with_agent |
| 1226 | .replace(" · {role}", "") |
| 1227 | .replace("{role}", "") |
| 1228 | .trim_end() |
| 1229 | .to_string(), |
| 1230 | } |
| 1231 | } |
| 1232 | |
| 1233 | fn save_ask_rule_hint(locale: Locale) -> Cow<'static, str> { |
| 1234 | tr(locale, MessageId::ApprovalSaveAskRuleHint) |
| 1235 | } |
| 1236 | |
| 1237 | #[derive(Clone)] |
| 1238 | struct ApprovalOptionRow { |
| 1239 | label: Cow<'static, str>, |
| 1240 | key_hint: &'static str, |
| 1241 | dangerous: bool, |
| 1242 | /// Saves a persistent rule: offered only beside its save preview. |
| 1243 | persistent: bool, |
| 1244 | } |
| 1245 | |
| 1246 | fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 4] { |
| 1247 | let dangerous = matches!(risk, RiskLevel::Destructive); |
| 1248 | [ |
| 1249 | ApprovalOptionRow { |
| 1250 | label: option_approve_once(locale), |
| 1251 | key_hint: "1 / y", |
| 1252 | dangerous, |
| 1253 | persistent: false, |
| 1254 | }, |
| 1255 | ApprovalOptionRow { |
| 1256 | label: option_approve_always(locale), |
| 1257 | key_hint: "2 / a", |
| 1258 | dangerous, |
| 1259 | persistent: false, |
| 1260 | }, |
| 1261 | ApprovalOptionRow { |
| 1262 | label: option_deny(locale), |
| 1263 | key_hint: "3 / d / n", |
| 1264 | dangerous: false, |
| 1265 | persistent: false, |
| 1266 | }, |
| 1267 | ApprovalOptionRow { |
| 1268 | label: option_abort(locale), |
| 1269 | key_hint: "Esc", |
| 1270 | dangerous: false, |
| 1271 | persistent: false, |
| 1272 | }, |
| 1273 | ] |
| 1274 | } |
| 1275 | |
| 1276 | /// Workflow elevated-plan card options (#4126): Approve / Edit plan / Cancel. |
| 1277 | fn workflow_approval_options(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 3] { |
| 1278 | let dangerous = matches!(risk, RiskLevel::Destructive); |
| 1279 | [ |
| 1280 | ApprovalOptionRow { |
| 1281 | label: workflow_option_approve(locale), |
| 1282 | key_hint: "1 / y", |
| 1283 | dangerous, |
| 1284 | persistent: false, |
| 1285 | }, |
| 1286 | ApprovalOptionRow { |
| 1287 | label: workflow_option_edit_plan(locale), |
| 1288 | key_hint: "2 / e", |
| 1289 | dangerous: false, |
| 1290 | persistent: false, |
| 1291 | }, |
| 1292 | ApprovalOptionRow { |
| 1293 | label: workflow_option_cancel(locale), |
| 1294 | key_hint: "3 / Esc", |
| 1295 | dangerous: false, |
| 1296 | persistent: false, |
| 1297 | }, |
| 1298 | ] |
| 1299 | } |
| 1300 | |
| 1301 | fn approval_options_for_request( |
| 1302 | request: &ApprovalRequest, |
| 1303 | risk: RiskLevel, |
| 1304 | locale: Locale, |
| 1305 | ) -> Vec<ApprovalOptionRow> { |
| 1306 | if request.tool_name == "workflow" { |
| 1307 | workflow_approval_options(risk, locale).to_vec() |
| 1308 | } else { |
| 1309 | let mut options = approval_options_for(risk, locale).to_vec(); |
| 1310 | if request.owner.is_some() { |
| 1311 | // Must match `ApprovalOption::CHILD_ORDER`: no "Stop this turn". |
| 1312 | options.pop(); |
| 1313 | return options; |
| 1314 | } |
| 1315 | if request.can_save_allow_rule() { |
| 1316 | options.insert( |
| 1317 | 2, |
| 1318 | ApprovalOptionRow { |
| 1319 | label: tr(locale, MessageId::ApprovalOptionAllowExactRepo), |
| 1320 | key_hint: "p", |
| 1321 | dangerous: false, |
| 1322 | persistent: true, |
| 1323 | }, |
| 1324 | ); |
| 1325 | } |
| 1326 | options |
| 1327 | } |
| 1328 | } |
| 1329 | |
| 1330 | fn workflow_option_approve(locale: Locale) -> Cow<'static, str> { |
| 1331 | match locale { |
| 1332 | Locale::ZhHans => Cow::Borrowed("批准"), |
| 1333 | _ => Cow::Borrowed("Approve"), |
| 1334 | } |
| 1335 | } |
| 1336 | |
| 1337 | fn workflow_option_edit_plan(locale: Locale) -> Cow<'static, str> { |
| 1338 | match locale { |
| 1339 | Locale::ZhHans => Cow::Borrowed("编辑计划"), |
| 1340 | _ => Cow::Borrowed("Edit plan"), |
| 1341 | } |
| 1342 | } |
| 1343 | |
| 1344 | fn workflow_option_cancel(locale: Locale) -> Cow<'static, str> { |
| 1345 | match locale { |
| 1346 | Locale::ZhHans => Cow::Borrowed("取消"), |
| 1347 | _ => Cow::Borrowed("Cancel"), |
| 1348 | } |
| 1349 | } |
| 1350 | |
| 1351 | fn option_approve_once(locale: Locale) -> Cow<'static, str> { |
| 1352 | tr(locale, MessageId::ApprovalOptionApproveOnce) |
| 1353 | } |
| 1354 | |
| 1355 | fn option_approve_always(locale: Locale) -> Cow<'static, str> { |
| 1356 | tr(locale, MessageId::ApprovalOptionApproveAlways) |
| 1357 | } |
| 1358 | |
| 1359 | fn option_deny(locale: Locale) -> Cow<'static, str> { |
| 1360 | tr(locale, MessageId::ApprovalOptionDeny) |
| 1361 | } |
| 1362 | |
| 1363 | fn option_abort(locale: Locale) -> Cow<'static, str> { |
| 1364 | tr(locale, MessageId::ApprovalOptionAbortTurn) |
| 1365 | } |
| 1366 | |
| 1367 | // End exact frozen source counterpart; never included in production. |
| 1368 |