1use std::sync::Arc;
8
9use anstyle::{AnsiColor, Color as AnsiColorEnum, Effects, Style as AnsiStyle};
10use vtcode_commons::formatting::{RAN_COMMAND_CONTINUATION_WIDTH, RAN_COMMAND_FIRST_WIDTH};
11use vtcode_commons::ui_protocol::{InlineSegment, InlineTextStyle, convert_style};
12
13use crate::tui::ui::syntax_highlight;
14
15pub struct ShellLineStyles {
16 pub output: Arc<InlineTextStyle>,
17 pub bullet: Arc<InlineTextStyle>,
18 pub glyph: Arc<InlineTextStyle>,
19 pub verb: Arc<InlineTextStyle>,
20 pub command: Arc<InlineTextStyle>,
21 pub args: Arc<InlineTextStyle>,
22 pub keyword: Arc<InlineTextStyle>,
23 pub variable: Arc<InlineTextStyle>,
24 pub string: Arc<InlineTextStyle>,
25 pub option: Arc<InlineTextStyle>,
26 pub truncation: Arc<InlineTextStyle>,
27 pub separator: Arc<InlineTextStyle>,
30 pub count: Arc<InlineTextStyle>,
33}
34
35impl ShellLineStyles {
36 pub fn new() -> Self {
39 let theme_styles = crate::theme::active_styles();
40 Self::from_ansi_styles(theme_styles.primary, theme_styles.pty_output)
41 }
42
43 pub fn from_session(_session: &crate::tui::core_tui::app::session::AppSession) -> Self {
46 let theme_styles = crate::theme::active_styles();
47 Self::from_ansi_styles(theme_styles.primary, theme_styles.pty_output)
50 }
51
52 fn from_ansi_styles(primary: AnsiStyle, pty_output: AnsiStyle) -> Self {
53 let output = Arc::new(convert_style(pty_output));
54 let magenta_bold = Arc::new(convert_style(
55 AnsiStyle::new()
56 .fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Magenta)))
57 .effects(Effects::BOLD),
58 ));
59 let accent_bold = Arc::new(convert_style(primary | Effects::BOLD));
60 let yellow = Arc::new(convert_style(AnsiStyle::new().fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Yellow)))));
61 let args = Arc::new(convert_style(pty_output));
65
66 Self {
67 output: Arc::clone(&output),
68 bullet: Arc::new(convert_style(AnsiStyle::new().fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Green))))),
69 glyph: Arc::clone(&output),
70 verb: accent_bold,
71 command: Arc::new(convert_style(
72 AnsiStyle::new()
73 .fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Green)))
74 .effects(Effects::BOLD),
75 )),
76 args: Arc::clone(&args),
77 keyword: magenta_bold,
78 variable: Arc::clone(&yellow),
79 string: yellow,
80 option: Arc::new(convert_style(AnsiStyle::new().fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Red))))),
81 truncation: Arc::new(convert_style(pty_output | Effects::DIMMED)),
82 separator: Arc::new(convert_style(pty_output | Effects::DIMMED)),
83 count: Arc::new(convert_style(primary | Effects::BOLD)),
84 }
85 }
86}
87
88impl Default for ShellLineStyles {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94pub fn is_bash_keyword(token: &str) -> bool {
95 matches!(
96 token,
97 "if" | "then"
98 | "else"
99 | "elif"
100 | "fi"
101 | "for"
102 | "in"
103 | "do"
104 | "done"
105 | "while"
106 | "until"
107 | "case"
108 | "esac"
109 | "function"
110 | "select"
111 | "time"
112 | "coproc"
113 | "{"
114 | "}"
115 | "[["
116 | "]]"
117 )
118}
119
120pub fn is_command_separator(token: &str) -> bool {
121 matches!(token, "|" | "||" | "&&" | ";" | ";;" | "&")
122}
123
124fn is_redirection_token(token: &str) -> bool {
127 let body = token.trim_start_matches(|c: char| c.is_ascii_digit());
128 body.starts_with('>') || body.starts_with('<')
129}
130
131pub fn tokenize_preserve_whitespace(text: &str) -> Vec<&str> {
132 let mut parts = Vec::new();
133 let mut in_single = false;
134 let mut in_double = false;
135 let mut escaped = false;
136 let mut token_start: Option<usize> = None;
137 let mut token_is_whitespace = false;
138
139 for (idx, ch) in text.char_indices() {
140 if escaped {
141 escaped = false;
142 } else if ch == '\\' && !in_single {
143 escaped = true;
144 } else if ch == '\'' && !in_double {
145 in_single = !in_single;
146 } else if ch == '"' && !in_single {
147 in_double = !in_double;
148 }
149
150 let is_whitespace = !in_single && !in_double && ch.is_whitespace();
151 match token_start {
152 None => {
153 token_start = Some(idx);
154 token_is_whitespace = is_whitespace;
155 }
156 Some(start) if token_is_whitespace != is_whitespace => {
157 parts.push(&text[start..idx]);
158 token_start = Some(idx);
159 token_is_whitespace = is_whitespace;
160 }
161 _ => {}
162 }
163 }
164
165 if let Some(start) = token_start {
166 parts.push(&text[start..]);
167 }
168
169 parts
170}
171
172fn style_for_token<'a>(token: &'a str, expect_command: &mut bool, styles: &'a ShellLineStyles) -> Arc<InlineTextStyle> {
173 if token.trim().is_empty() {
174 return Arc::clone(&styles.output);
175 }
176
177 if is_command_separator(token) {
178 *expect_command = true;
179 return Arc::clone(&styles.separator);
180 }
181
182 if is_redirection_token(token) {
183 *expect_command = false;
184 return Arc::clone(&styles.separator);
185 }
186
187 if token.starts_with('"') || token.starts_with('\'') || token.ends_with('"') || token.ends_with('\'') {
188 *expect_command = false;
189 return Arc::clone(&styles.string);
190 }
191
192 if token.starts_with('$') || token.contains("=$") || token.starts_with("${") {
193 *expect_command = false;
194 return Arc::clone(&styles.variable);
195 }
196
197 if token.starts_with('-') && token.len() > 1 {
198 *expect_command = false;
199 return Arc::clone(&styles.option);
200 }
201
202 if is_bash_keyword(token) {
203 *expect_command = true;
204 return Arc::clone(&styles.keyword);
205 }
206
207 if *expect_command {
208 *expect_command = false;
209 return Arc::clone(&styles.command);
210 }
211
212 Arc::clone(&styles.args)
213}
214
215fn split_trailing_command_operators(token: &str) -> Vec<&str> {
220 let bytes = token.as_bytes();
221 let mut end = bytes.len();
222 while end > 0 && matches!(bytes[end - 1], b';' | b'&' | b'|') {
223 end -= 1;
224 }
225 if end == 0 || end == bytes.len() {
226 return vec![token];
227 }
228 vec![&token[..end], &token[end..]]
229}
230
231fn bash_segments(text: &str, styles: &ShellLineStyles, expect_command: bool) -> Vec<InlineSegment> {
232 let mut segments = Vec::new();
233 let mut command_expected = expect_command;
234 for token in tokenize_preserve_whitespace(text) {
235 if token.trim().is_empty() {
236 segments.push(InlineSegment {
237 text: token.to_string(),
238 style: Arc::clone(&styles.output),
239 });
240 continue;
241 }
242 for part in split_trailing_command_operators(token) {
243 segments.push(InlineSegment {
244 text: part.to_string(),
245 style: style_for_token(part, &mut command_expected, styles),
246 });
247 }
248 }
249 segments
250}
251
252pub fn shell_syntax_segments(text: &str, styles: &ShellLineStyles, expect_command: bool) -> Vec<InlineSegment> {
253 let highlighted = syntax_highlight::highlight_line_to_anstyle_segments(
254 text,
255 Some("bash"),
256 syntax_highlight::get_active_syntax_theme(),
257 true,
258 );
259 shell_syntax_segments_with_highlighted(text, styles, expect_command, highlighted)
260}
261
262pub(crate) fn shell_syntax_segments_with_highlighted(
268 text: &str,
269 styles: &ShellLineStyles,
270 expect_command: bool,
271 highlighted: Option<Vec<(AnsiStyle, String)>>,
272) -> Vec<InlineSegment> {
273 let semantic = bash_segments(text, styles, expect_command);
274 let Some(highlighted) = highlighted else {
275 return semantic;
276 };
277
278 if highlighted.is_empty() {
279 return semantic;
280 }
281
282 let converted = highlighted
283 .into_iter()
284 .map(|(style, text)| InlineSegment {
285 text,
286 style: Arc::new(convert_style(style).merge_color(styles.args.color)),
287 })
288 .collect::<Vec<_>>();
289
290 let converted_text = converted.iter().map(|segment| segment.text.as_str()).collect::<String>();
291 if converted_text != text {
292 return semantic;
293 }
294
295 let non_ws_count = semantic.iter().filter(|segment| !segment.text.trim().is_empty()).count();
296 if non_ws_count > 1 {
297 let mut first_colors: Option<(Option<AnsiColorEnum>, Option<AnsiColorEnum>)> = None;
298 let mut has_distinct = false;
299 for style in converted
300 .iter()
301 .filter(|segment| !segment.text.trim().is_empty())
302 .map(|segment| segment.style.as_ref())
303 {
304 let colors = (style.color, style.bg_color);
305 if let Some(seed) = first_colors {
306 if colors != seed {
307 has_distinct = true;
308 break;
309 }
310 } else {
311 first_colors = Some(colors);
312 }
313 }
314 if !has_distinct {
315 return semantic;
316 }
317 }
318
319 converted
320}
321
322pub fn line_to_compact_segments(
323 metadata: &vtcode_commons::ui_protocol::CompactActivityMetadata,
324 styles: &ShellLineStyles,
325) -> Vec<InlineSegment> {
326 let mut segments = Vec::new();
328 segments.push(InlineSegment {
329 text: "• ".to_string(),
330 style: Arc::clone(&styles.bullet),
331 });
332 segments.push(InlineSegment {
333 text: "Ran".to_string(),
334 style: Arc::clone(&styles.verb),
335 });
336 segments.push(InlineSegment {
337 text: " ".to_string(),
338 style: Arc::clone(&styles.output),
339 });
340
341 if metadata.command_count > 1 {
342 segments.push(InlineSegment {
345 text: format!("{} commands", metadata.command_count),
346 style: Arc::clone(&styles.count),
347 });
348 } else if let Some(cmd) = metadata.command.as_deref() {
349 const COMPACT_CONT_INDENT: &str = "\n ";
354 let needs_wrap = cmd.chars().count() > RAN_COMMAND_FIRST_WIDTH;
355 if needs_wrap {
356 let wrapped = vtcode_commons::formatting::wrap_shell_command_with_continuations(
361 cmd,
362 RAN_COMMAND_FIRST_WIDTH,
363 RAN_COMMAND_CONTINUATION_WIDTH,
364 );
365 if wrapped.len() > 1 {
366 for (idx, line) in wrapped.iter().enumerate() {
367 let expect_command = idx == 0;
370 let is_final_line = idx + 1 == wrapped.len();
371 let body = if is_final_line {
375 line.as_str()
376 } else {
377 line.strip_suffix(" \\").unwrap_or(line)
378 };
379 segments.extend(shell_syntax_segments(body, styles, expect_command));
380 if idx + 1 < wrapped.len() {
381 segments.push(InlineSegment {
382 text: " \\".to_string(),
383 style: Arc::clone(&styles.separator),
384 });
385 segments.push(InlineSegment {
386 text: COMPACT_CONT_INDENT.to_string(),
387 style: Arc::clone(&styles.output),
388 });
389 }
390 }
391 } else {
392 segments.extend(shell_syntax_segments(cmd, styles, true));
393 }
394 } else {
395 segments.extend(shell_syntax_segments(cmd, styles, true));
396 }
397 if metadata.hidden_line_count > 0 {
398 segments.push(InlineSegment {
399 text: format!(" · … +{} lines", metadata.hidden_line_count),
400 style: Arc::clone(&styles.truncation),
401 });
402 }
403 } else {
404 segments.push(InlineSegment {
405 text: "command".to_string(),
406 style: Arc::clone(&styles.args),
407 });
408 }
409
410 if let Some(suffix) = metadata.suffix.as_deref().filter(|s| !s.is_empty()) {
411 segments.push(InlineSegment {
412 text: " · ".to_string(),
413 style: Arc::clone(&styles.truncation),
414 });
415 segments.push(InlineSegment {
416 text: suffix.to_string(),
417 style: Arc::clone(&styles.truncation),
418 });
419 }
420
421 segments
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn command_header_preserves_distinct_semantic_token_colors() {
430 let styles = ShellLineStyles::new();
431 let segments =
432 shell_syntax_segments("find src/agent/runloop -maxdepth 3 -type f -name *.rs | sort", &styles, true);
433 let option = segments
434 .iter()
435 .find(|segment| segment.text.contains("maxdepth"))
436 .expect("option token");
437 let command = segments
438 .iter()
439 .find(|segment| segment.text.contains("find"))
440 .expect("command token");
441 assert_ne!(option.style.color, command.style.color);
442 }
443
444 #[test]
445 fn single_command_row_carries_full_pipeline_without_truncation() {
446 let styles = ShellLineStyles::new();
451 let command = "grep -rn \"@vinhnx/vtcode|npm install -g||npx @vinhnx\" docs | grep -v node_modules | grep -v package-lock | grep -v \"\\.backup\"";
452 let meta = vtcode_commons::ui_protocol::CompactActivityMetadata {
453 group_id: 1,
454 command_count: 1,
455 command: Some(command.into()),
456 hidden_line_count: 0,
457 suffix: None,
458 review_anchor: None,
459 review_anchors: vec![],
460 };
461 let segs = line_to_compact_segments(&meta, &styles);
462 let text: String = segs.iter().map(|s| s.text.as_str()).collect();
463 assert!(!text.contains('…'), "compact row must not truncate, got: {text:?}");
464 assert!(text.starts_with("• Ran "), "got: {text:?}");
465 assert_eq!(text.matches('|').count(), 6, "pattern pipes + shell pipes must survive: {text:?}");
466 for fragment in ["node_modules", "package-lock", "\\.backup"] {
467 assert!(text.contains(fragment), "missing {fragment:?} in {text:?}");
468 }
469 }
470
471 #[test]
472 fn compact_row_keeps_command_own_trailing_continuation_backslash() {
473 let styles = ShellLineStyles::new();
478 let with_continuation = "echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \\";
479 assert!(with_continuation.chars().count() > RAN_COMMAND_FIRST_WIDTH);
480 let meta = vtcode_commons::ui_protocol::CompactActivityMetadata {
481 group_id: 1,
482 command_count: 1,
483 command: Some(with_continuation.into()),
484 hidden_line_count: 0,
485 suffix: None,
486 review_anchor: None,
487 review_anchors: vec![],
488 };
489 let text: String = line_to_compact_segments(&meta, &styles)
490 .iter()
491 .map(|s| s.text.as_str())
492 .collect();
493 assert!(text.ends_with(" \\"), "command's own trailing continuation must survive: {text:?}");
494
495 let without_continuation = "echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa target";
498 let meta = vtcode_commons::ui_protocol::CompactActivityMetadata {
499 group_id: 1,
500 command_count: 1,
501 command: Some(without_continuation.into()),
502 hidden_line_count: 0,
503 suffix: None,
504 review_anchor: None,
505 review_anchors: vec![],
506 };
507 let text: String = line_to_compact_segments(&meta, &styles)
508 .iter()
509 .map(|s| s.text.as_str())
510 .collect();
511 assert!(!text.ends_with(" \\"), "no continuation marker may appear on the final row: {text:?}");
512 }
513
514 #[test]
515 fn grouped_has_no_single_command_highlight() {
516 let styles = ShellLineStyles::new();
517 let meta = vtcode_commons::ui_protocol::CompactActivityMetadata {
518 group_id: 1,
519 command_count: 4,
520 command: None,
521 hidden_line_count: 10,
522 suffix: Some("output retained".into()),
523 review_anchor: Some(1),
524 review_anchors: vec![1],
525 };
526 let segs = line_to_compact_segments(&meta, &styles);
527 let text: String = segs.iter().map(|s| s.text.as_str()).collect();
528 assert!(text.contains("4 commands"));
529 assert!(text.contains("output retained"));
530 }
531
532 #[test]
533 fn grouped_count_stays_bold_instead_of_dimmed() {
534 let styles = ShellLineStyles::new();
535 assert!(styles.count.effects.contains(Effects::BOLD));
536 assert!(!styles.count.effects.contains(Effects::DIMMED));
537 }
538
539 #[test]
540 fn args_use_themed_body_color_for_light_theme_legibility() {
541 let styles = ShellLineStyles::new();
542 assert_eq!(styles.args.color, styles.output.color);
543 }
544
545 #[test]
546 fn attached_separator_splits_from_word_style() {
547 let styles = ShellLineStyles::new();
548 let segments = bash_segments("head -120; echo hi", &styles, true);
549 let option = segments.iter().find(|s| s.text == "-120").expect("option part");
550 let separator = segments.iter().find(|s| s.text == ";").expect("separator part");
551 assert_eq!(option.style.color, styles.option.color);
552 assert_eq!(separator.style.color, styles.separator.color);
553 assert!(separator.style.effects.contains(Effects::DIMMED));
554 }
555
556 #[test]
557 fn uniform_syntax_highlighting_uses_semantic_fallback() {
558 let styles = ShellLineStyles::new();
559 let highlighted_style = AnsiStyle::new().fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Cyan)));
560 let highlighted = ["cargo", " ", "check", " ", "-p", " ", "vtcode"]
561 .into_iter()
562 .map(|text| (highlighted_style, text.to_string()))
563 .collect();
564
565 let segments =
566 shell_syntax_segments_with_highlighted("cargo check -p vtcode", &styles, true, Some(highlighted));
567 let command = segments.iter().find(|segment| segment.text == "cargo").expect("command token");
568 let option = segments.iter().find(|segment| segment.text == "-p").expect("option token");
569
570 assert_eq!(command.style.color, styles.command.color);
571 assert_eq!(option.style.color, styles.option.color);
572 assert_ne!(command.style.color, option.style.color);
573 }
574}