worktrunk 0.35.3

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
//! Consolidated styling module for terminal output.
//!
//! This module uses the anstyle ecosystem:
//! - anstream for auto-detecting color support
//! - anstyle for composable styling
//! - Semantic style constants for domain-specific use
//!
//! ## stdout vs stderr principle
//!
//! - **stdout**: Primary data output (table data, JSON, statusline)
//! - **stderr**: Status messages (progress, success, errors, hints, warnings)
//!
//! This separation allows piping (`wt list | grep foo`) without status messages interfering.
//! Use `println!` for primary output, `eprintln!` for status messages.

mod constants;
mod format;
mod highlighting;
mod hyperlink;
mod line;
mod suggest;

use ansi_str::AnsiStr;
use unicode_width::UnicodeWidthStr;

// Re-exports from anstream (auto-detecting output)
pub use anstream::{eprint, eprintln, print, println, stderr, stdout};

// Re-exports from anstyle (for composition)
pub use anstyle::Style as AnstyleStyle;

// Re-export our public types
pub use constants::*;
#[cfg(all(test, feature = "syntax-highlighting"))]
pub(crate) use format::format_bash_with_gutter_at_width;
pub use format::{GUTTER_OVERHEAD, format_bash_with_gutter, format_with_gutter, wrap_styled_text};
pub use highlighting::format_toml;
pub use hyperlink::{Stream, hyperlink_stdout, strip_osc8_hyperlinks, supports_hyperlinks};
pub use line::{StyledLine, StyledString, truncate_visible};
pub use suggest::{suggest_command, suggest_command_in_dir};

// ============================================================================
// Verbosity
// ============================================================================

use std::sync::atomic::{AtomicU8, Ordering};

/// Global verbosity level, set at startup.
/// 0 = normal, 1 = verbose (-v), 2+ = debug (-vv)
static VERBOSITY: AtomicU8 = AtomicU8::new(0);

/// Set the global verbosity level.
///
/// Call this once at startup after parsing CLI arguments.
pub fn set_verbosity(level: u8) {
    VERBOSITY.store(level, Ordering::Relaxed);
}

/// Get the current verbosity level.
///
/// - 0: normal (no verbose output)
/// - 1: verbose (`-v`) - nice styled output for templates, etc.
/// - 2+: debug (`-vv`) - full debug logging
pub fn verbosity() -> u8 {
    VERBOSITY.load(Ordering::Relaxed)
}

/// Get terminal width, or `usize::MAX` if detection fails.
///
/// Prefers direct terminal size detection over COLUMNS environment variable,
/// because tools like cargo may set COLUMNS incorrectly.
///
/// Checks stderr first (for status messages), then stdout (for table output).
///
/// When detection fails (piped context, no TTY), returns `usize::MAX` rather than
/// an arbitrary default. Callers that need width-based formatting will produce
/// full output, letting the consumer handle truncation.
pub fn terminal_width() -> usize {
    // Prefer direct terminal detection (more accurate than COLUMNS which may be stale/wrong)
    // Check stderr first (status messages), then stdout (table output)
    if let Some((terminal_size::Width(w), _)) =
        terminal_size::terminal_size_of(std::io::stderr()).or_else(terminal_size::terminal_size)
    {
        return w as usize;
    }

    // Fall back to COLUMNS env var (for scripts, piped contexts, or when detection fails)
    if let Ok(cols) = std::env::var("COLUMNS")
        && let Ok(width) = cols.parse::<usize>()
    {
        return width;
    }

    // Try parent TTY detection (Unix only)
    // This is used when running in a subprocess without direct TTY access,
    // such as Claude Code's statusline hook.
    #[cfg(unix)]
    if let Some(width) = detect_parent_tty_width() {
        return width;
    }

    // Can't detect width — don't truncate, let the consumer handle it
    usize::MAX
}

/// Detect terminal width by walking up the process tree to find a TTY.
///
/// This is a fallback for subprocesses (like Claude Code hooks) that don't have
/// direct TTY access. Walks up to 10 parent processes looking for one with a TTY,
/// then queries that TTY's size.
///
/// Returns 80% of the detected width to reserve space for Claude Code's UI messages
/// (like "Approaching context limit").
#[cfg(unix)]
fn detect_parent_tty_width() -> Option<usize> {
    use std::process::Command;

    let mut pid = std::process::id().to_string();

    for _ in 0..10 {
        let output = Command::new("ps")
            .args(["-o", "ppid=,tty=", "-p", &pid])
            .output()
            .ok()?;

        let info = String::from_utf8_lossy(&output.stdout);
        let mut parts = info.split_whitespace();
        let ppid = parts.next()?;
        let tty = parts.next()?;

        // Valid TTY found (not "?" or "??")
        if !tty.is_empty() && tty != "?" && tty != "??" {
            // Query TTY size using stty
            let size = Command::new("sh")
                .args(["-c", &format!("stty size < /dev/{tty}")])
                .output()
                .ok()?;

            let cols = String::from_utf8_lossy(&size.stdout)
                .split_whitespace()
                .nth(1)?
                .parse::<usize>()
                .ok()?;

            // Reserve 20% for Claude Code UI messages
            return Some(cols * 80 / 100);
        }

        if ppid == "1" || ppid == "0" {
            break;
        }
        pid = ppid.to_string();
    }

    None
}

/// Calculate visual width of a string, ignoring ANSI escape codes
///
/// Uses unicode-width for proper handling of wide characters (CJK, emoji).
pub fn visual_width(s: &str) -> usize {
    s.ansi_strip().width()
}

/// Fix dim rendering for terminals that don't handle \e[2m after \e[39m.
///
/// Claude Code's terminal doesn't render dim (\e[2m) correctly when it follows
/// a foreground color reset (\e[39m). This function replaces that sequence with
/// a full reset (\e[0m) before dim, which works correctly.
pub fn fix_dim_after_color_reset(s: &str) -> String {
    s.replace("\x1b[39m\x1b[2m", "\x1b[0m\x1b[2m")
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use insta::assert_snapshot;

    use super::*;
    use anstyle::Style;
    use unicode_width::UnicodeWidthStr;

    #[test]
    fn test_toml_formatting() {
        let toml_content = r#"worktree-path = "../{{ repo }}.{{ branch }}"

[llm]
args = []

# This is a comment
[[approved-commands]]
project = "github.com/user/repo"
command = "npm install"
"#;

        assert_snapshot!(format_toml(toml_content), @r#"
          worktree-path = "../{{ repo }}.{{ branch }}"
          
          [llm]
          args = []
          
          # This is a comment
          [[approved-commands]]
          project = "github.com/user/repo"
          command = "npm install"
        "#);
    }

    // StyledString tests
    #[test]
    fn test_styled_string_width() {
        // ASCII strings
        let s = StyledString::raw("hello");
        assert_eq!(s.width(), 5);

        // Unicode arrows
        let s = StyledString::raw("↑3 ↓2");
        assert_eq!(
            s.width(),
            5,
            "↑3 ↓2 should have width 5, not {}",
            s.text.len()
        );

        // Mixed Unicode
        let s = StyledString::raw("日本語");
        assert_eq!(s.width(), 6); // CJK characters are typically width 2

        // Emoji
        let s = StyledString::raw("🎉");
        assert_eq!(s.width(), 2); // Emoji are typically width 2
    }

    // StyledLine tests
    #[test]
    fn test_styled_line_width() {
        let mut line = StyledLine::new();
        line.push_raw("Branch");
        line.push_raw("  ");
        line.push_raw("↑3 ↓2");

        // "Branch" (6) + "  " (2) + "↑3 ↓2" (5) = 13
        assert_eq!(line.width(), 13);
    }

    #[test]
    fn test_styled_line_padding() {
        let mut line = StyledLine::new();
        line.push_raw("test");
        assert_eq!(line.width(), 4);

        line.pad_to(10);
        assert_eq!(line.width(), 10);

        // Padding when already at target should not change width
        line.pad_to(10);
        assert_eq!(line.width(), 10);
    }

    #[test]
    fn test_sparse_column_padding() {
        // Build simplified lines to test sparse column padding
        let mut line1 = StyledLine::new();
        line1.push_raw(format!("{:8}", "branch-a"));
        line1.push_raw("  ");
        // Has ahead/behind
        line1.push_raw(format!("{:5}", "↑3 ↓2"));
        line1.push_raw("  ");

        let mut line2 = StyledLine::new();
        line2.push_raw(format!("{:8}", "branch-b"));
        line2.push_raw("  ");
        // No ahead/behind, should pad with spaces
        line2.push_raw(" ".repeat(5));
        line2.push_raw("  ");

        // Both lines should have same width up to this point
        assert_eq!(
            line1.width(),
            line2.width(),
            "Rows with and without sparse column data should have same width"
        );
    }

    // Word-wrapping tests
    #[test]
    fn test_wrap_text_no_wrapping_needed() {
        let result = super::format::wrap_text_at_width("short line", 50);
        assert_eq!(result, vec!["short line"]);
    }

    #[test]
    fn test_wrap_text_at_word_boundary() {
        let text = "This is a very long line that needs to be wrapped at word boundaries";
        let result = super::format::wrap_text_at_width(text, 30);

        // Should wrap at word boundaries
        assert!(result.len() > 1);

        // Each line should be within the width limit (or be a single long word)
        for line in &result {
            assert!(
                line.width() <= 30 || !line.contains(' '),
                "Line '{}' has width {} which exceeds 30 and contains spaces",
                line,
                line.width()
            );
        }

        // Joining should recover most of the original text (whitespace may differ)
        let rejoined = result.join(" ");
        assert_eq!(
            rejoined.split_whitespace().collect::<Vec<_>>(),
            text.split_whitespace().collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_wrap_text_single_long_word() {
        // A single word longer than max_width should still be included
        let result = super::format::wrap_text_at_width("verylongwordthatcannotbewrapped", 10);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0], "verylongwordthatcannotbewrapped");
    }

    #[test]
    fn test_wrap_text_empty_input() {
        let result = super::format::wrap_text_at_width("", 50);
        assert_eq!(result, vec![""]);
    }

    #[test]
    fn test_wrap_text_unicode() {
        // Unicode characters should be handled correctly by width
        let text = "This line has emoji 🎉 and should wrap correctly when needed";
        let result = super::format::wrap_text_at_width(text, 30);

        // Should wrap
        assert!(result.len() > 1);

        // Should preserve the emoji
        let rejoined = result.join(" ");
        assert!(rejoined.contains("🎉"));
    }

    #[test]
    fn test_format_with_gutter_preserves_newlines() {
        assert_snapshot!(format_with_gutter("Line 1\nLine 2\nLine 3", Some(80)), @"
          Line 1
          Line 2
          Line 3
        ");
    }

    #[test]
    fn test_format_with_gutter_long_paragraph() {
        // Realistic commit message scenario - a long unbroken paragraph
        let commit_msg = "This commit refactors the authentication system to use a more secure token-based approach instead of the previous session-based system which had several security vulnerabilities that were identified during the security audit last month. The new implementation follows industry best practices and includes proper token rotation and expiration handling.";

        // Use fixed width for consistent testing (80 columns)
        let result = format_with_gutter(commit_msg, Some(80));

        assert_snapshot!(result, @"
          This commit refactors the authentication system to use a more secure
          token-based approach instead of the previous session-based system which had
          several security vulnerabilities that were identified during the security
          audit last month. The new implementation follows industry best practices and
          includes proper token rotation and expiration handling.
        ");
    }

    #[test]
    fn test_bash_gutter_formatting_ends_with_reset() {
        // Test that bash gutter formatting properly resets colors at the end of each line
        // to prevent color bleeding into subsequent output (like child process output)
        let command = "pre-commit run --all-files";
        let result = format_bash_with_gutter(command);

        // The output should end with ANSI reset code (no trailing newline - caller adds it)
        // ANSI reset is \x1b[0m (ESC[0m)
        assert!(
            result.ends_with("\x1b[0m"),
            "Bash gutter formatting should end with ANSI reset code, got: {:?}",
            result.chars().rev().take(20).collect::<String>()
        );
        assert!(
            !result.ends_with('\n'),
            "Bash gutter formatting should not have trailing newline"
        );

        // Verify the reset appears at the end of EVERY line (for multi-line commands)
        let multi_line_command = "npm install && \\\n    npm run build";
        let multi_result = format_bash_with_gutter(multi_line_command);

        // Each line should end with reset code
        for line in multi_result.lines() {
            if !line.is_empty() {
                // Check that line contains a reset code somewhere
                // (The actual position depends on the highlighting, but it should be present)
                assert!(
                    line.contains("\x1b[0m"),
                    "Each line should contain ANSI reset code, line: {:?}",
                    line
                );
            }
        }

        // Most importantly: the final output should end with reset (no trailing newline)
        assert!(
            multi_result.ends_with("\x1b[0m"),
            "Multi-line bash gutter formatting should end with ANSI reset"
        );
    }

    #[test]
    fn test_reset_code_behavior() {
        // IMPORTANT: {:#} on Style::new() produces an EMPTY STRING, not a reset!
        // This is the root cause of color bleeding bugs.
        let style_reset = format!("{:#}", Style::new());
        assert_eq!(
            style_reset, "",
            "Style::new() with {{:#}} produces empty string (this is why we had color leaking!)"
        );

        // The correct way to get a reset code is anstyle::Reset
        let anstyle_reset = format!("{}", anstyle::Reset);
        assert_eq!(
            anstyle_reset, "\x1b[0m",
            "anstyle::Reset produces proper ESC[0m reset code"
        );

        // Document the fix: always use anstyle::Reset, never {:#} on Style::new()
        assert_ne!(
            style_reset, anstyle_reset,
            "Style::new() and anstyle::Reset are NOT equivalent - always use anstyle::Reset"
        );
    }

    #[test]
    fn test_wrap_text_with_ansi_codes() {
        use super::format::wrap_text_at_width;

        // Simulate a git log line with ANSI color codes
        // Visual content: "* 9452817 Clarify wt merge worktree removal behavior" (52 chars)
        // But with ANSI codes, the raw string is much longer
        let colored_text = "* \x1b[33m9452817\x1b[m Clarify wt merge worktree removal behavior";

        // Without ANSI stripping, this would wrap prematurely because the raw string
        // (with escape codes) is ~70 chars. With proper ANSI stripping, the visual
        // width is only ~52 chars, so it should NOT wrap at width 60.
        let result = wrap_text_at_width(colored_text, 60);

        assert_eq!(
            result.len(),
            1,
            "Colored text should NOT wrap when visual width (52) < max_width (60)"
        );
        assert_eq!(
            result[0], colored_text,
            "Should return original text with ANSI codes intact"
        );

        // Now test that it DOES wrap when visual width exceeds max_width
        let result = wrap_text_at_width(colored_text, 30);
        assert!(
            result.len() > 1,
            "Should wrap into multiple lines when visual width (52) > max_width (30)"
        );
    }

    // wrap_styled_text tests
    #[test]
    fn test_wrap_styled_text_no_wrapping_needed() {
        let result = wrap_styled_text("short line", 50);
        assert_eq!(result, vec!["short line"]);
    }

    #[test]
    fn test_wrap_styled_text_at_word_boundary() {
        let text = "This is a very long line that needs wrapping";
        let result = wrap_styled_text(text, 20);

        // Should wrap into multiple lines
        assert!(result.len() > 1);

        // Each line should be within width limit (using visual_width to ignore ANSI codes)
        for line in &result {
            let visual = visual_width(line);
            assert!(
                visual <= 20 || !line.contains(' '),
                "Line '{}' has visual width {} which exceeds 20",
                line,
                visual
            );
        }
    }

    #[test]
    fn test_wrap_styled_text_preserves_styles_across_breaks() {
        // Create styled text: bold text that spans multiple words
        let bold = Style::new().bold();
        let input = format!("{bold}This is bold text that will wrap{bold:#}");
        let result = wrap_styled_text(&input, 15);

        // Should wrap
        assert!(result.len() > 1);

        // First line should start with bold code
        assert!(
            result[0].contains("\x1b[1m"),
            "First line should have bold code"
        );
    }

    #[test]
    fn test_wrap_styled_text_single_long_word() {
        // A single word longer than max_width should still be included
        let result = wrap_styled_text("verylongwordthatcannotbewrapped", 10);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0], "verylongwordthatcannotbewrapped");
    }

    #[test]
    fn test_wrap_styled_text_preserves_dim_across_wrap_points() {
        // This test verifies that wrap_ansi preserves dim styling across wrap points.
        // This was a bug where wrap_ansi lost track of ANSI state at line breaks,
        // causing text after the wrap to lose its dim styling.
        //
        // Simulate what format_bash_with_gutter produces:
        // - Starts with dim
        // - Highlighted tokens have styles like bold+dim+color
        // - After each highlight, we reset then restore dim: [0m[2m
        // - Unhighlighted text stays dimmed

        let dim = Style::new().dimmed();
        let reset = anstyle::Reset;
        let cmd_style = Style::new()
            .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Blue)))
            .bold()
            .dimmed();

        // Create a styled line exactly like the highlighter produces:
        // [dim][cmd_style]cp[reset][dim] -cR {{ repo_root }}/target/debug/build {{ worktree }}/target/debug/
        let styled = format!(
            "{dim}{cmd_style}cp{reset}{dim} -cR {{{{ repo_root }}}}/target/debug/build {{{{ worktree }}}}/target/debug/"
        );

        // Wrap at a width that forces a break in the middle of unhighlighted text
        let result = wrap_styled_text(&styled, 40);

        // Should wrap into multiple lines
        assert!(
            result.len() > 1,
            "Should wrap into multiple lines, got {} lines: {:?}",
            result.len(),
            result
        );

        // The key assertion: each wrapped line should start with dim
        // wrap_ansi should restore the dim state at the start of each wrapped line
        let dim_code = "\x1b[2m";

        for (i, line) in result.iter().enumerate() {
            // Every line should start with dim code
            // Line 0: starts with our initial dim
            // Line 1+: wrap_ansi should prepend dim to maintain state
            assert!(
                line.starts_with(dim_code),
                "Line {} should START with dim code, but got: {:?}",
                i + 1,
                &line[..line.len().min(30)]
            );
        }
    }

    #[test]
    fn test_format_bash_with_gutter_template_command() {
        // Test that format_bash_with_gutter produces consistent dim styling
        // for a realistic command with Jinja-style template variables.
        // This is a regression test for wrap-point discontinuity.

        let command = "cp -cR {{ repo_root }}/target/debug/.fingerprint {{ repo_root }}/target/debug/build {{ worktree }}/target/debug/";

        // Use explicit width for deterministic output (avoids env var mutation in parallel tests)
        let result = format_bash_with_gutter_at_width(command, 80);

        // Snapshot the raw output to verify ANSI codes are consistent
        assert_snapshot!(result);
    }

    #[test]
    fn test_format_bash_multiline_command_consistent_styling() {
        // This test simulates the REAL user scenario: a multi-line command
        // where each line is processed separately by tree-sitter.
        //
        // The user's actual command:
        // ```
        // [ -d {{ repo_root }}/target/debug/deps ] && [ ! -e {{ worktree }}/target ] &&
        // mkdir -p {{ worktree }}/target/debug/deps &&
        // cp -c {{ repo_root }}/target/debug/deps/*.rlib ... {{ worktree
        // }}/target/debug/deps/ &&
        // ```
        //
        // Note: line 3 ends with `{{ worktree` and line 4 starts with `}}`
        // These should have IDENTICAL styling since both are unhighlighted text.

        let multiline_command = r#"[ -d {{ repo_root }}/target/debug/deps ] && [ ! -e {{ worktree }}/target ] &&
mkdir -p {{ worktree }}/target/debug/deps &&
cp -c {{ repo_root }}/target/debug/deps/*.rlib {{ repo_root }}/target/debug/deps/*.rmeta {{ worktree
}}/target/debug/deps/ &&
cp -cR {{ repo_root }}/target/debug/.fingerprint {{ repo_root }}/target/debug/build {{ worktree
}}/target/debug/"#;

        // Use explicit width for deterministic output (avoids env var mutation in parallel tests)
        let result = format_bash_with_gutter_at_width(multiline_command, 80);

        // Snapshot the output - each line should have consistent dim styling
        assert_snapshot!(result);
    }

    #[test]
    fn test_unhighlighted_text_has_consistent_dim_across_lines() {
        assert_snapshot!(format_bash_with_gutter("echo {{ worktree\n}}/path"));
    }

    #[test]
    fn test_syntax_highlighting_produces_multiple_colors() {
        let command = "echo 'hello' | grep hello > output.txt && cat output.txt";
        assert_snapshot!(format_bash_with_gutter(command));
    }

    #[test]
    fn test_no_color_discontinuity_in_template_variables() {
        // Regression test: wrap_ansi injects [39m (reset foreground color) at line ends
        // when it thinks a color is "open". This creates visual discontinuity where
        // template variables like `{{ worktree` on line N and `}}` on line N+1 have
        // different styling even though both should just be dim.
        //
        // We never emit [39m ourselves - all our resets use [0m (full reset).
        // So any [39m in the output is an artifact from wrap_ansi that we must strip.
        //
        // This is the actual post-start command from user config that exposed the bug.
        let command = r#"[ -d {{ repo_root }}/target/debug/deps ] && [ ! -e {{ worktree }}/target ] &&
mkdir -p {{ worktree }}/target/debug/deps &&
cp -c {{ repo_root }}/target/debug/deps/*.rlib {{ repo_root }}/target/debug/deps/*.rmeta {{ worktree
}}/target/debug/deps/ &&
cp -cR {{ repo_root }}/target/debug/.fingerprint {{ repo_root }}/target/debug/build {{ worktree
}}/target/debug/"#;

        let result = format_bash_with_gutter(command);

        // The critical assertion: NO [39m codes should appear anywhere in the output.
        // [39m is "reset foreground to default" - we never emit this, only [0m (full reset).
        assert!(
            !result.contains("\x1b[39m"),
            "Output should NOT contain [39m (foreground reset) - this indicates wrap_ansi discontinuity.\n\
             Found [39m in output:\n{}",
            result
                .lines()
                .filter(|line| line.contains("\x1b[39m"))
                .collect::<Vec<_>>()
                .join("\n")
        );

        // Similarly, [49m (reset background) shouldn't appear - we use [0m for all resets.
        assert!(
            !result.contains("\x1b[49m"),
            "Output should NOT contain [49m (background reset)"
        );

        // Verify we DO have the expected codes:
        // - [2m for dim (base styling)
        // - [0m for reset
        // - Various color codes (34m blue, 36m cyan) for syntax highlighting
        assert!(
            result.contains("\x1b[2m"),
            "Output should contain [2m (dim)"
        );
        assert!(
            result.contains("\x1b[0m"),
            "Output should contain [0m (reset)"
        );
    }

    #[test]
    fn test_no_bold_dim_conflict() {
        // Regression test: We must never mix bold (SGR 1) and dim (SGR 2) in the same
        // sequence because they are mutually exclusive in some terminals like Alacritty.
        //
        // The problematic pattern was [2m][1m][2m][34m] where dim, then bold, then dim
        // again would cause the final dim to not render correctly.
        //
        // The fix: token styles use dim+color only (no bold). This test ensures we
        // don't regress by checking that [2m][1m][2m] never appears.
        let command = "cp -cR path/to/source path/to/dest";
        let result = format_bash_with_gutter(command);

        // The problematic pattern is [2m] followed by [1m] followed by [2m]
        // This happens when: line starts with dim, style adds bold+dim
        assert!(
            !result.contains("\x1b[2m\x1b[1m\x1b[2m"),
            "Output should NOT contain [2m][1m][2m] - this indicates redundant dim in token styles.\n\
             Token styles should not include .dimmed() since the line already starts dim.\n\
             Found pattern in output:\n{:?}",
            result
        );
    }

    #[test]
    fn test_all_tokens_are_dimmed() {
        // Regression test: All highlighted tokens should be dimmed to match the base text.
        // We don't use bold because bold (SGR 1) and dim (SGR 2) are mutually exclusive
        // in some terminals like Alacritty.
        //
        // Token styles should emit [2m] (dim) along with their color.
        let command = "cp -cR path/to/source path/to/dest";
        let result = format_bash_with_gutter(command);

        // Verify commands are dim+blue: [0m][2m][34m] (reset, dim, blue)
        assert!(
            result.contains("\x1b[0m\x1b[2m\x1b[34m"),
            "Commands should be dim+blue [0m][2m][34m].\n\
             Output:\n{:?}",
            result
        );

        // Verify flags are dim+cyan: [0m][2m][36m] (reset, dim, cyan)
        assert!(
            result.contains("\x1b[0m\x1b[2m\x1b[36m"),
            "Flags should be dim+cyan [0m][2m][36m].\n\
             Output:\n{:?}",
            result
        );

        // Verify NO bold codes appear (we removed bold to avoid bold/dim conflict)
        assert!(
            !result.contains("\x1b[1m"),
            "Output should NOT contain [1m] (bold) - we use dim instead.\n\
             Output:\n{:?}",
            result
        );
    }

    #[test]
    fn test_fix_dim_after_color_reset() {
        // Basic case: foreground reset followed by dim
        assert_eq!(
            fix_dim_after_color_reset("\x1b[39m\x1b[2m"),
            "\x1b[0m\x1b[2m"
        );

        // With surrounding content (cyan ? then dim ^)
        assert_eq!(
            fix_dim_after_color_reset("\x1b[36m?\x1b[39m\x1b[2m^\x1b[22m"),
            "\x1b[36m?\x1b[0m\x1b[2m^\x1b[22m"
        );

        // Multiple occurrences
        assert_eq!(
            fix_dim_after_color_reset("a\x1b[39m\x1b[2mb\x1b[39m\x1b[2mc"),
            "a\x1b[0m\x1b[2mb\x1b[0m\x1b[2mc"
        );

        // No matches - idempotent
        assert_eq!(fix_dim_after_color_reset("no escapes"), "no escapes");

        // Similar but different sequence (bold, not dim) - should not match
        assert_eq!(
            fix_dim_after_color_reset("\x1b[39m\x1b[1m"),
            "\x1b[39m\x1b[1m"
        );
    }
}