Skip to main content

vtcode_ui/tui/ui/
shell_syntax.rs

1//! Shell syntax helpers for `• Ran` tool-call lines.
2//!
3//! Extracted from `src/agent/runloop/unified/tool_pipeline/pty_stream/segments.rs`
4//! so both live PTY rendering and the compact-activity row share one
5//! tokenizer + palette. Keeps command/args/option/keyword coloring DRY.
6
7use 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    /// Structural tokens (`|`, `;`, `&&`, redirections) — muted so command
28    /// words and args carry the color hierarchy.
29    pub separator: Arc<InlineTextStyle>,
30    /// Grouped `N commands` counts — bold accent matching the verb so the
31    /// collapsed row stays prominent instead of washing out.
32    pub count: Arc<InlineTextStyle>,
33}
34
35impl ShellLineStyles {
36    /// Styles derived from the process-global UI theme (used by PTY live view
37    /// when no session is available). The binary's PTY stream reuses this type.
38    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    /// Styles derived from a session's resolved theme — preferred inside
44    /// `AppSession`/`Session` where `InlineTheme` is already available.
45    pub fn from_session(_session: &crate::tui::core_tui::app::session::AppSession) -> Self {
46        let theme_styles = crate::theme::active_styles();
47        // Keep verb synced with the session's primary, body with pty_output
48        // so compact rows track theme changes (e.g. catppuccin-latte).
49        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        // Args use the themed body color (opaque) instead of hardcoded dimmed
62        // white so paths/args stay legible on light themes; separators stay
63        // dimmed so command words keep the visual hierarchy.
64        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
124/// Redirection operators, including fd-prefixed and target-attached forms:
125/// `>`, `>>`, `2>`, `2>&1`, `2>/dev/null`, `<`, `<<`.
126fn 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
215/// Split trailing `;`/`&`/`|` runs off a whitespace-delimited token so
216/// attached separators (`-120;`, `'---';`) color as separators instead of
217/// inheriting the word's option/string style. Redirections (`2>/dev/null`,
218/// `2>&1`) end in word characters and are left intact.
219fn 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
262/// Prefer shell syntax highlighting only when it keeps token colors distinct.
263///
264/// A handful of syntax themes return one foreground color for a shell line.
265/// In that case the semantic tokenizer provides more useful command, option,
266/// and argument styling than accepting the uniform highlighter result.
267pub(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    // • Ran <command>  (single) or • Ran N commands (grouped)
327    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        // Grouped: bold accent count matches the verb so `Ran 4 commands`
343        // stays prominent instead of washing out.
344        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        // Long single-command rows wrap with explicit `\` continuations so a
350        // chained `git add … && git commit … && git log …` reads as one shell
351        // command instead of terminal-reflow word wrap. Short rows stay
352        // single-line; `\n` splits are honored by transcript reflow.
353        const COMPACT_CONT_INDENT: &str = "\n      ";
354        let needs_wrap = cmd.chars().count() > RAN_COMMAND_FIRST_WIDTH;
355        if needs_wrap {
356            // Operator-aware (same `&&`/`||`/`|` chunking as the expanded
357            // `• Ran` headers): each chain/pipe stage starts on a fresh row.
358            // The wrapper already appends ` \\` to non-final rows; strip it
359            // here so the marker is emitted once with separator styling.
360            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                    // First wrapped line uses command-position highlighting;
368                    // continuations highlight as args (no leading command word).
369                    let expect_command = idx == 0;
370                    let is_final_line = idx + 1 == wrapped.len();
371                    // The wrapper appends ` \` to non-final rows only, so on
372                    // the final row a trailing ` \` is the command's own text
373                    // (a real line continuation) and must be kept verbatim.
374                    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        // Screenshot 2026-09-24 16:37: a single-command `• Ran` compact row
447        // must carry the complete pipeline — `||` inside the quoted pattern,
448        // every `| grep -v` segment, and the `\.backup` arg — with no `…`.
449        // (Viewport-aware wrapping owns the overflow at render time.)
450        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        // The wrapper appends ` \` to non-final rows only, so on the final
474        // wrapped row a trailing ` \` belongs to the command itself (a real
475        // line continuation). Stripping it there would make the transcript
476        // diverge from what executed.
477        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        // Asymmetric check: a command that does not end in ` \` gains no
496        // marker on the final row either.
497        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}