返回 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 _ => label.to_string().into(),
306 },
307 _ => label.to_string().into(),
308 }
309 }
310
311 pub(super) fn localize_preview_shell_line(
312 tool_name: &str,
313 line: &str,
314 locale: Locale,
315 ) -> Cow<'static, str> {
316 match tool_name {
317 "write_file" if line == "proposed content" => localize_detail_label(line, locale),
318 "edit_file" if matches!(line, "replace this" | "with this") => {
319 localize_detail_label(line, locale)
320 }
321 _ => line.to_string().into(),
322 }
323 }
324
325 pub(crate) fn format_shell_command_for_approval(command: &str) -> Vec<String> {
326 if let Some(preview) = parse_printf_write_file_command(command) {
327 return format_printf_write_file_preview(preview);
328 }
329
330 let mut out = Vec::new();
331 for raw_line in command.lines() {
332 split_shell_display_line(raw_line, &mut out);
333 }
334 if out.is_empty() && !command.trim().is_empty() {
335 out.push(command.trim().to_string());
336 }
337 out
338 }
339
340 fn split_shell_display_line(line: &str, out: &mut Vec<String>) {
341 let mut quote: Option<char> = None;
342 let mut escaped = false;
343 let mut current = String::new();
344 let mut chars = line.chars().peekable();
345
346 while let Some(ch) = chars.next() {
347 if escaped {
348 current.push(ch);
349 escaped = false;
350 continue;
351 }
352
353 if ch == '\\' {
354 current.push(ch);
355 escaped = true;
356 continue;
357 }
358
359 if matches!(ch, '"' | '\'') {
360 if quote == Some(ch) {
361 quote = None;
362 } else if quote.is_none() {
363 quote = Some(ch);
364 }
365 current.push(ch);
366 continue;
367 }
368
369 if quote.is_none() {
370 match ch {
371 '&' if chars.peek() == Some(&'&') => {
372 chars.next();
373 push_shell_clause(out, &mut current, Some("&&"));
374 continue;
375 }
376 '|' if chars.peek() == Some(&'|') => {
377 chars.next();
378 push_shell_clause(out, &mut current, Some("||"));
379 continue;
380 }
381 '|' => {
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 }
391 }
392
393 current.push(ch);
394 }
395
396 push_shell_clause(out, &mut current, None);
397 }
398
399 fn push_shell_clause(out: &mut Vec<String>, current: &mut String, operator: Option<&str>) {
400 let trimmed = current.trim();
401 if trimmed.is_empty() {
402 if let Some(operator) = operator {
403 out.push(operator.to_string());
404 }
405 } else if let Some(operator) = operator {
406 out.push(format!("{trimmed} {operator}"));
407 } else {
408 out.push(trimmed.to_string());
409 }
410 current.clear();
411 }
412
413 #[derive(Debug, Clone, PartialEq, Eq)]
414 struct PrintfWriteFilePreview {
415 target: String,
416 lines: Vec<String>,
417 }
418
419 fn parse_printf_write_file_command(command: &str) -> Option<PrintfWriteFilePreview> {
420 let (before_redirect, after_redirect) = split_unquoted_redirect(command)?;
421 let before_redirect = before_redirect.trim();
422 if !before_redirect.starts_with("printf") {
423 return None;
424 }
425
426 let tokens = shlex::split(before_redirect)?;
427 if tokens.first()?.as_str() != "printf" {
428 return None;
429 }
430 let target_parts = shlex::split(after_redirect.trim())?;
431 if target_parts.len() != 1 {
432 return None;
433 }
434 let target = target_parts
435 .into_iter()
436 .next()?
437 .trim_matches(|ch| ch == '"' || ch == '\'')
438 .to_string();
439 if target.is_empty() {
440 return None;
441 }
442
443 let args = &tokens[1..];
444 if args.is_empty() {
445 return None;
446 }
447 let values = if args.len() >= 2 && args[0].contains('%') {
448 &args[1..]
449 } else {
450 args
451 };
452 let mut lines = Vec::new();
453 for value in values {
454 let normalized = value.replace("\\n", "\n");
455 for line in normalized.lines() {
456 lines.push(line.to_string());
457 }
458 }
459 if lines.is_empty() {
460 lines.push(String::new());
461 }
462
463 Some(PrintfWriteFilePreview { target, lines })
464 }
465
466 fn format_printf_write_file_preview(preview: PrintfWriteFilePreview) -> Vec<String> {
467 const MAX_PREVIEW_LINES: usize = 12;
468 let mut out = vec![format!("printf > {}", preview.target)];
469 let total = preview.lines.len();
470 for line in preview.lines.into_iter().take(MAX_PREVIEW_LINES) {
471 out.push(format!(" {line}"));
472 }
473 if total > MAX_PREVIEW_LINES {
474 out.push(format!(" ... (+{} more lines)", total - MAX_PREVIEW_LINES));
475 }
476 out
477 }
478
479 fn split_unquoted_redirect(command: &str) -> Option<(&str, &str)> {
480 let mut quote: Option<char> = None;
481 let mut escaped = false;
482 for (idx, ch) in command.char_indices() {
483 if escaped {
484 escaped = false;
485 continue;
486 }
487 if ch == '\\' {
488 escaped = true;
489 continue;
490 }
491 if matches!(ch, '"' | '\'') {
492 if quote == Some(ch) {
493 quote = None;
494 } else if quote.is_none() {
495 quote = Some(ch);
496 }
497 continue;
498 }
499 if quote.is_none() && ch == '>' {
500 return Some((&command[..idx], &command[idx + ch.len_utf8()..]));
501 }
502 }
503 None
504 }
505
505 lines RUST