返回 CodeWhale
previews.rs
根目录 / crates / tui / src / tui / approval / previews.rs
1 //! Shell, file-write, edit, and apply-patch approval previews.
2 //!
3 //! Preview generation stays separate from modal state so bounded formatting
4 //! and exact detail-pager rendering can be reviewed without changing approval
5 //! decisions or persistence semantics.
6
7 use std::borrow::Cow;
8
9 use serde_json::Value;
10
11 use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input};
12 use crate::tools::canonical_action::canonical_action_alias;
13 use codewhale_localization::{Locale, MessageId, tr};
14
15 pub(super) fn file_write_preview_lines(tool_name: &str, params: &Value) -> Option<Vec<String>> {
16 match canonical_action_alias(tool_name, params) {
17 "write_file" => {
18 let content = param_text(params, &["content"])?;
19 Some(prefixed_preview_lines(
20 "proposed content",
21 "+ ",
22 &content,
23 5,
24 ))
25 }
26 "edit_file" => {
27 // Keep the per-frame card preview bounded. The details pager builds the
28 // complete version lazily when the reviewer asks for it.
29 edit_file_preview_lines(params, 3)
30 }
31 "apply_patch" => match normalize_apply_patch_input(params) {
32 Ok(NormalizedApplyPatchInput::Patch(patch)) => apply_patch_preview_lines(patch),
33 Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => {
34 changes_preview_lines(entries)
35 }
36 Err(_) => None,
37 },
38 _ => None,
39 }
40 .filter(|lines| !lines.is_empty())
41 }
42
43 fn edit_file_preview_lines(params: &Value, max_lines: usize) -> Option<Vec<String>> {
44 if let Some(edits) = params.get("edits").and_then(Value::as_array) {
45 let mut lines = Vec::new();
46 for (index, edit) in edits.iter().take(max_lines).enumerate() {
47 let old = param_text(edit, &["oldText"])?;
48 let new = param_text(edit, &["newText"])?;
49 lines.push(format!("edit {}", index + 1));
50 lines.extend(prefixed_preview_lines("replace this", "- ", &old, 1));
51 lines.extend(prefixed_preview_lines("with this", "+ ", &new, 1));
52 }
53 if edits.len() > max_lines {
54 lines.push(format!("... (+{} more edits)", edits.len() - max_lines));
55 }
56 return (!lines.is_empty()).then_some(lines);
57 }
58 let search = param_text(params, &["search"])?;
59 let replace = param_text(params, &["replace"])?;
60 let mut lines = Vec::new();
61 lines.extend(prefixed_preview_lines(
62 "replace this",
63 "- ",
64 &search,
65 max_lines,
66 ));
67 lines.extend(prefixed_preview_lines(
68 "with this",
69 "+ ",
70 &replace,
71 max_lines,
72 ));
73 Some(lines)
74 }
75
76 pub(super) fn exact_edit_file_preview_lines(params: &Value, locale: Locale) -> Option<Vec<String>> {
77 if let Some(edits) = params.get("edits").and_then(Value::as_array) {
78 let mut lines = Vec::new();
79 for (index, edit) in edits.iter().enumerate() {
80 let old = param_text(edit, &["oldText"])?;
81 let new = param_text(edit, &["newText"])?;
82 lines.push(format!("edit {}", index + 1));
83 lines.push(tr(locale, MessageId::ApprovalLabelReplaceThis).into_owned());
84 lines.extend(exact_preview_body_lines("- ", &old));
85 lines.push(tr(locale, MessageId::ApprovalLabelWithThis).into_owned());
86 lines.extend(exact_preview_body_lines("+ ", &new));
87 }
88 return (!lines.is_empty()).then_some(lines);
89 }
90 let search = param_text(params, &["search"])?;
91 let replace = param_text(params, &["replace"])?;
92 let mut lines = vec![tr(locale, MessageId::ApprovalLabelReplaceThis).into_owned()];
93 lines.extend(exact_preview_body_lines("- ", &search));
94 lines.push(tr(locale, MessageId::ApprovalLabelWithThis).into_owned());
95 lines.extend(exact_preview_body_lines("+ ", &replace));
96 Some(lines)
97 }
98
99 fn exact_preview_body_lines(prefix: &str, content: &str) -> Vec<String> {
100 if content.is_empty() {
101 return vec![format!("{prefix}\"\"")];
102 }
103
104 content
105 .split_inclusive('\n')
106 .map(|chunk| {
107 let (body, ending) = if let Some(body) = chunk.strip_suffix("\r\n") {
108 (body, "\\r\\n")
109 } else if let Some(body) = chunk.strip_suffix('\n') {
110 (body, "\\n")
111 } else {
112 (chunk, "")
113 };
114 exact_preview_body_line(prefix, body, ending)
115 })
116 .collect()
117 }
118
119 fn exact_preview_body_line(prefix: &str, body: &str, ending: &str) -> String {
120 let mut line = String::with_capacity(prefix.len() + body.len() + ending.len() + 2);
121 line.push_str(prefix);
122 line.push('"');
123 for ch in body.chars() {
124 match ch {
125 '\\' => line.push_str("\\\\"),
126 '"' => line.push_str("\\\""),
127 ' ' => line.push_str("\\x20"),
128 '\t' => line.push_str("\\t"),
129 '\r' => line.push_str("\\r"),
130 ch if ch.is_whitespace() || ch.is_control() => line.extend(ch.escape_unicode()),
131 ch => line.push(ch),
132 }
133 }
134 line.push_str(ending);
135 line.push('"');
136 line
137 }
138
139 fn prefixed_preview_lines(
140 header: &str,
141 prefix: &str,
142 content: &str,
143 max_lines: usize,
144 ) -> Vec<String> {
145 let mut lines = vec![header.to_string()];
146 if content.is_empty() {
147 lines.push(format!("{prefix}<empty>"));
148 return lines;
149 }
150
151 let total = content.lines().count();
152 for line in content.lines().take(max_lines) {
153 lines.push(format!("{prefix}{line}"));
154 }
155 if total > max_lines {
156 lines.push(format!("... (+{} more lines)", total - max_lines));
157 }
158 lines
159 }
160
161 fn push_preview_line(lines: &mut Vec<String>, line: impl Into<String>, limit: usize) -> bool {
162 if lines.len() >= limit {
163 return false;
164 }
165 lines.push(line.into());
166 true
167 }
168
169 fn append_preview_truncation(lines: &mut Vec<String>, line: String, limit: usize) {
170 if push_preview_line(lines, line.clone(), limit) {
171 return;
172 }
173 if let Some(last) = lines.last_mut() {
174 *last = line;
175 }
176 }
177
178 pub(super) fn apply_patch_preview_lines(patch: &str) -> Option<Vec<String>> {
179 const PREVIEW_LIMIT: usize = 7;
180
181 let mut lines = Vec::new();
182 let mut omitted = 0usize;
183 for line in patch.lines().filter(|line| !line.trim().is_empty()) {
184 let is_diff_header = line.starts_with("diff --git ")
185 || line.starts_with("--- ")
186 || line.starts_with("+++ ")
187 || line.starts_with("@@");
188 let is_change_line = (line.starts_with('+') && !line.starts_with("+++"))
189 || (line.starts_with('-') && !line.starts_with("---"));
190 if is_diff_header || is_change_line {
191 if !push_preview_line(&mut lines, line, PREVIEW_LIMIT) {
192 omitted += 1;
193 }
194 } else {
195 omitted += 1;
196 }
197 }
198
199 if lines.is_empty() {
200 omitted = 0;
201 for line in patch.lines().filter(|line| !line.trim().is_empty()) {
202 if !push_preview_line(&mut lines, line, PREVIEW_LIMIT) {
203 omitted += 1;
204 }
205 }
206 }
207
208 if omitted > 0 {
209 if lines.len() >= PREVIEW_LIMIT {
210 omitted += 1;
211 }
212 append_preview_truncation(
213 &mut lines,
214 format!("... (+{omitted} more patch lines)"),
215 PREVIEW_LIMIT,
216 );
217 }
218 if lines.is_empty() { None } else { Some(lines) }
219 }
220
221 fn changes_preview_lines(changes: &[Value]) -> Option<Vec<String>> {
222 const PREVIEW_LIMIT: usize = 7;
223
224 let mut lines = Vec::new();
225 let mut rendered_changes = 0usize;
226 for (idx, change) in changes.iter().enumerate() {
227 let path = change
228 .get("path")
229 .and_then(Value::as_str)
230 .unwrap_or("<file>");
231 let content = change.get("content").and_then(Value::as_str).unwrap_or("");
232 if idx > 0 && !push_preview_line(&mut lines, String::new(), PREVIEW_LIMIT) {
233 break;
234 }
235 if !push_preview_line(&mut lines, format!("file: {path}"), PREVIEW_LIMIT) {
236 break;
237 }
238 rendered_changes += 1;
239 for line in prefixed_preview_lines("replacement content", "+ ", content, PREVIEW_LIMIT)
240 .into_iter()
241 .skip(1)
242 {
243 if !push_preview_line(&mut lines, line, PREVIEW_LIMIT) {
244 break;
245 }
246 }
247 if lines.len() >= PREVIEW_LIMIT {
248 break;
249 }
250 }
251 let skipped_changes = changes.len().saturating_sub(rendered_changes);
252 if skipped_changes > 0 {
253 append_preview_truncation(
254 &mut lines,
255 format!("... (+{skipped_changes} more files)"),
256 PREVIEW_LIMIT,
257 );
258 }
259 if lines.is_empty() { None } else { Some(lines) }
260 }
261
262 pub(super) fn param_text(params: &Value, keys: &[&str]) -> Option<String> {
263 let Value::Object(map) = params else {
264 return None;
265 };
266
267 for key in keys {
268 let Some(value) = map.get(*key) else {
269 continue;
270 };
271 match value {
272 Value::String(text) => return Some(text.clone()),
273 Value::Number(number) => return Some(number.to_string()),
274 Value::Bool(flag) => return Some(flag.to_string()),
275 other => return Some(other.to_string()),
276 }
277 }
278
279 None
280 }
281
282 pub(super) fn localize_detail_label(label: &str, locale: Locale) -> Cow<'static, str> {
283 match locale {
284 Locale::ZhHans => match label {
285 "Command" => tr(locale, MessageId::ApprovalLabelCommand),
286 "Dir" => tr(locale, MessageId::ApprovalLabelDir),
287 "File" => tr(locale, MessageId::ApprovalLabelFile),
288 "Preview" => tr(locale, MessageId::ApprovalLabelPreview),
289 "proposed content" => tr(locale, MessageId::ApprovalLabelProposedContent),
290 "replace this" => tr(locale, MessageId::ApprovalLabelReplaceThis),
291 "with this" => tr(locale, MessageId::ApprovalLabelWithThis),
292 "replacement content" => tr(locale, MessageId::ApprovalLabelReplacementContent),
293 "Path" => tr(locale, MessageId::ApprovalLabelPath),
294 "Target" => tr(locale, MessageId::ApprovalLabelTarget),
295 "Input" => tr(locale, MessageId::ApprovalLabelInput),
296 "Action" => tr(locale, MessageId::ApprovalLabelAction),
297 "Type" => tr(locale, MessageId::ApprovalLabelType),
298 "Prompt" => tr(locale, MessageId::ApprovalLabelPrompt),
299 "Goal" => "目标".into(),
300 "Children" => "子任务".into(),
301 "Writes" => "写入".into(),
302 "Shell" => "Shell".into(),
303 "Network" => "网络".into(),
304 "Budget" => "预算".into(),
305 "Trust mode" => "信任模式".into(),
306 "Auto-approve" => "自动批准".into(),
307 "Mode" => "模式".into(),
308 "Workspace" => "工作区".into(),
309 _ => label.to_string().into(),
310 },
311 _ => label.to_string().into(),
312 }
313 }
314
315 pub(super) fn localize_preview_shell_line(
316 tool_name: &str,
317 line: &str,
318 locale: Locale,
319 ) -> Cow<'static, str> {
320 match tool_name {
321 "write_file" if line == "proposed content" => localize_detail_label(line, locale),
322 "edit_file" if matches!(line, "replace this" | "with this") => {
323 localize_detail_label(line, locale)
324 }
325 _ => line.to_string().into(),
326 }
327 }
328
329 pub(crate) fn format_shell_command_for_approval(command: &str) -> Vec<String> {
330 if let Some(preview) = parse_printf_write_file_command(command) {
331 return format_printf_write_file_preview(preview);
332 }
333
334 let mut out = Vec::new();
335 for raw_line in command.lines() {
336 split_shell_display_line(raw_line, &mut out);
337 }
338 if out.is_empty() && !command.trim().is_empty() {
339 out.push(command.trim().to_string());
340 }
341 out
342 }
343
344 fn split_shell_display_line(line: &str, out: &mut Vec<String>) {
345 let mut quote: Option<char> = None;
346 let mut escaped = false;
347 let mut current = String::new();
348 let mut chars = line.chars().peekable();
349
350 while let Some(ch) = chars.next() {
351 if escaped {
352 current.push(ch);
353 escaped = false;
354 continue;
355 }
356
357 if ch == '\\' {
358 current.push(ch);
359 escaped = true;
360 continue;
361 }
362
363 if matches!(ch, '"' | '\'') {
364 if quote == Some(ch) {
365 quote = None;
366 } else if quote.is_none() {
367 quote = Some(ch);
368 }
369 current.push(ch);
370 continue;
371 }
372
373 if quote.is_none() {
374 match ch {
375 '&' if chars.peek() == Some(&'&') => {
376 chars.next();
377 push_shell_clause(out, &mut current, Some("&&"));
378 continue;
379 }
380 '|' if chars.peek() == Some(&'|') => {
381 chars.next();
382 push_shell_clause(out, &mut current, Some("||"));
383 continue;
384 }
385 '|' => {
386 push_shell_clause(out, &mut current, Some("|"));
387 continue;
388 }
389 ';' => {
390 push_shell_clause(out, &mut current, Some(";"));
391 continue;
392 }
393 _ => {}
394 }
395 }
396
397 current.push(ch);
398 }
399
400 push_shell_clause(out, &mut current, None);
401 }
402
403 fn push_shell_clause(out: &mut Vec<String>, current: &mut String, operator: Option<&str>) {
404 let trimmed = current.trim();
405 if trimmed.is_empty() {
406 if let Some(operator) = operator {
407 out.push(operator.to_string());
408 }
409 } else if let Some(operator) = operator {
410 out.push(format!("{trimmed} {operator}"));
411 } else {
412 out.push(trimmed.to_string());
413 }
414 current.clear();
415 }
416
417 #[derive(Debug, Clone, PartialEq, Eq)]
418 struct PrintfWriteFilePreview {
419 target: String,
420 lines: Vec<String>,
421 }
422
423 fn parse_printf_write_file_command(command: &str) -> Option<PrintfWriteFilePreview> {
424 // A chained or substituted command is not a plain file write: collapsing
425 // it into `printf > target` would hide everything after the operator
426 // from the approval card. Scan the whole command (not the halves around
427 // the redirect) so quote state is never read from a mid-string split.
428 // shlex and the scanners below only understand POSIX quoting. Bash's
429 // ANSI-C and locale quotes need their original command shown in full.
430 if command.contains("$'") || command.contains("$\"") || has_unquoted_shell_control(command) {
431 return None;
432 }
433 let (before_redirect, after_redirect) = split_unquoted_redirect(command)?;
434 let before_redirect = before_redirect.trim();
435 if !before_redirect.starts_with("printf") {
436 return None;
437 }
438
439 let tokens = shlex::split(before_redirect)?;
440 if tokens.first()?.as_str() != "printf" {
441 return None;
442 }
443 let target_parts = shlex::split(after_redirect.trim())?;
444 if target_parts.len() != 1 {
445 return None;
446 }
447 let target = target_parts
448 .into_iter()
449 .next()?
450 .trim_matches(|ch| ch == '"' || ch == '\'')
451 .to_string();
452 if target.is_empty() {
453 return None;
454 }
455
456 let args = &tokens[1..];
457 if args.is_empty() {
458 return None;
459 }
460 let values = if args.len() >= 2 && args[0].contains('%') {
461 &args[1..]
462 } else {
463 args
464 };
465 let mut lines = Vec::new();
466 for value in values {
467 let normalized = value.replace("\\n", "\n");
468 for line in normalized.lines() {
469 lines.push(line.to_string());
470 }
471 }
472 if lines.is_empty() {
473 lines.push(String::new());
474 }
475
476 Some(PrintfWriteFilePreview { target, lines })
477 }
478
479 fn format_printf_write_file_preview(preview: PrintfWriteFilePreview) -> Vec<String> {
480 const MAX_PREVIEW_LINES: usize = 12;
481 let mut out = vec![format!("printf > {}", preview.target)];
482 let total = preview.lines.len();
483 for line in preview.lines.into_iter().take(MAX_PREVIEW_LINES) {
484 out.push(format!(" {line}"));
485 }
486 if total > MAX_PREVIEW_LINES {
487 out.push(format!(" ... (+{} more lines)", total - MAX_PREVIEW_LINES));
488 }
489 out
490 }
491
492 /// Whether `text` holds shell syntax that runs or chains another command:
493 /// an unquoted `;`, `&`, `|`, newline, `(` (subshell, `$(`, `<(`), or a
494 /// command substitution (`$(` / backtick) even inside double quotes.
495 fn has_unquoted_shell_control(text: &str) -> bool {
496 let mut quote: Option<char> = None;
497 let mut escaped = false;
498 let mut prev: Option<char> = None;
499 for ch in text.chars() {
500 if escaped {
501 escaped = false;
502 prev = None;
503 continue;
504 }
505 match quote {
506 Some('\'') => {
507 if ch == '\'' {
508 quote = None;
509 }
510 }
511 Some(_) => match ch {
512 '\\' => escaped = true,
513 '"' => quote = None,
514 '`' => return true,
515 '(' if prev == Some('$') => return true,
516 _ => {}
517 },
518 None => match ch {
519 '\\' => escaped = true,
520 '\'' | '"' => quote = Some(ch),
521 ';' | '&' | '|' | '`' | '(' | '\n' | '\r' => return true,
522 _ => {}
523 },
524 }
525 prev = Some(ch);
526 }
527 false
528 }
529
530 fn split_unquoted_redirect(command: &str) -> Option<(&str, &str)> {
531 let mut quote: Option<char> = None;
532 let mut escaped = false;
533 for (idx, ch) in command.char_indices() {
534 if escaped {
535 escaped = false;
536 continue;
537 }
538 if ch == '\\' && quote != Some('\'') {
539 escaped = true;
540 continue;
541 }
542 if matches!(ch, '"' | '\'') {
543 if quote == Some(ch) {
544 quote = None;
545 } else if quote.is_none() {
546 quote = Some(ch);
547 }
548 continue;
549 }
550 if quote.is_none() && ch == '>' {
551 return Some((&command[..idx], &command[idx + ch.len_utf8()..]));
552 }
553 }
554 None
555 }
556
556 lines RUST