Skip to content

Commit 324eff0

Browse files
sagnik11cursoragent
andcommitted
release: autter 2.0.2 — blame/stats consistency and Windows ANSI
### Fixed - Interactive blame notes when lines lack Autter attestations so it no longer looks like it contradicts `autter stats`. - Gate OSC 8 hyperlinks to capable terminals and enable Windows VT so ConHost does not leak raw escapes. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 37529a6 commit 324eff0

7 files changed

Lines changed: 159 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ This changelog is automatically generated by AI when releases are published.
6161

6262
For detailed design rationale and implementation notes, see `docs/superpowers/specs/2026-04-29-telemetry-streams-design.md`.
6363

64+
## [2.0.2] - 2026-09-18
65+
66+
### Fixed
67+
68+
- **Blame vs stats consistency**: when lines have no Autter attestation, interactive `autter blame` now notes that it is showing the git author (same as `git blame`) while `autter stats` counts those lines as untracked — and points at `--mark-unknown`.
69+
- **ANSI / OSC 8 on Windows**: enable VT processing for color, and only emit OSC 8 hyperlinks when the terminal supports them (e.g. Windows Terminal via `WT_SESSION`), so classic ConHost no longer dumps raw escape sequences.
70+
6471
## [2.0.1] - 2026-09-17
6572

6673
### Fixed

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ resolver = "3"
88

99
[package]
1010
name = "autter"
11-
version = "2.0.1"
11+
version = "2.0.2"
1212
edition = "2024"
1313
default-run = "autter"
1414

‎src/authorship/guidance.rs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ pub fn blame_line_missing_data_message() -> String {
3333
out
3434
}
3535

36+
/// Short notice when `autter blame` falls back to the git author for lines
37+
/// without Autter attestations — so it does not contradict `autter stats`,
38+
/// which reports those same lines as untracked.
39+
pub fn blame_git_author_fallback_notice(unattested_lines: usize) -> String {
40+
if unattested_lines == 1 {
41+
"\nNote: 1 line has no Autter attestation, so blame shows the git author \
42+
(same as `git blame`). `autter stats` counts that line as untracked. Use `--mark-unknown` to label it Unknown.\n"
43+
.to_string()
44+
} else {
45+
format!(
46+
"\nNote: {unattested_lines} lines have no Autter attestation, so blame shows the git author \
47+
(same as `git blame`). `autter stats` counts those lines as untracked. Use `--mark-unknown` to label them Unknown.\n"
48+
)
49+
}
50+
}
51+
3652
/// Guidance when `autter show` finds commits but no authorship notes.
3753
pub fn show_missing_data_message() -> String {
3854
let mut out = format!("{NO_AUTHORSHIP_DATA_MESSAGE}.\n");
@@ -72,4 +88,16 @@ mod tests {
7288
assert!(msg.contains("git notes"), "{msg}");
7389
assert!(msg.contains("autter doctor"), "{msg}");
7490
}
91+
92+
#[test]
93+
fn blame_fallback_notice_points_at_stats_and_mark_unknown() {
94+
let one = blame_git_author_fallback_notice(1);
95+
assert!(one.contains("1 line has"), "{one}");
96+
assert!(one.contains("autter stats"), "{one}");
97+
assert!(one.contains("--mark-unknown"), "{one}");
98+
99+
let many = blame_git_author_fallback_notice(4);
100+
assert!(many.contains("4 lines have"), "{many}");
101+
assert!(many.contains("untracked"), "{many}");
102+
}
75103
}

‎src/authorship/stats.rs‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,15 @@ pub fn write_stats_to_terminal(stats: &CommitStats, is_interactive: bool) -> Str
195195
// two anchors (human / AI) otherwise.
196196
if show_untracked {
197197
let untracked_percentage = untracked_pct_raw.round() as u32;
198-
// When interactive, wrap "untracked" in an OSC 8 hyperlink so it is clickable in
199-
// supporting terminals (iTerm2, Warp, etc.). Spaces are constructed manually —
200-
// not via format-width padding on the label — so that invisible escape bytes do
201-
// not misalign the output.
202-
let untracked_label = if is_interactive && colorize {
198+
// When interactive *and* the terminal supports OSC 8, wrap "untracked" in a
199+
// hyperlink. Color alone is not enough — Windows ConHost with VT enabled still
200+
// dumps OSC 8 as literal text. Spaces are constructed manually — not via
201+
// format-width padding on the label — so invisible escape bytes do not
202+
// misalign the output.
203+
let untracked_label = if is_interactive
204+
&& colorize
205+
&& crate::commands::arg_parser::use_hyperlinks()
206+
{
203207
"\x1b]8;;https://autter.dev/docs/cli/untracked\x1b\\\x1b[4muntracked\x1b[24m\x1b]8;;\x1b\\"
204208
.to_string()
205209
} else {
@@ -789,18 +793,27 @@ mod tests {
789793
let all_untracked_output = write_stats_to_terminal(&all_untracked_stats, false);
790794
assert_debug_snapshot!(all_untracked_output);
791795

792-
// OSC 8 hyperlink emitted when is_interactive = true
793-
// Not a snapshot test — asserts presence of the escape sequence directly.
794-
let hyperlink_output = write_stats_to_terminal(&untracked_stats, true);
796+
// OSC 8 hyperlink only when the terminal advertises hyperlink support
797+
// (see use_hyperlinks). In non-TTY unit tests this stays plain "untracked".
798+
let interactive_output = write_stats_to_terminal(&untracked_stats, true);
795799
assert!(
796-
hyperlink_output.contains("\x1b]8;;https://autter.dev/docs/cli/untracked\x1b\\"),
797-
"Expected OSC 8 hyperlink in interactive output, got: {:?}",
798-
hyperlink_output
799-
);
800-
assert!(
801-
hyperlink_output.contains("untracked"),
800+
interactive_output.contains("untracked"),
802801
"Expected 'untracked' label in interactive output"
803802
);
803+
if crate::commands::arg_parser::use_hyperlinks() {
804+
assert!(
805+
interactive_output
806+
.contains("\x1b]8;;https://autter.dev/docs/cli/untracked\x1b\\"),
807+
"Expected OSC 8 hyperlink when hyperlinks are enabled, got: {:?}",
808+
interactive_output
809+
);
810+
} else {
811+
assert!(
812+
!interactive_output.contains("\x1b]8;;"),
813+
"OSC 8 must not leak when hyperlinks are disabled, got: {:?}",
814+
interactive_output
815+
);
816+
}
804817
}
805818

806819
#[test]

‎src/commands/arg_parser.rs‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,84 @@ fn color_forced_off() -> bool {
165165
store().read().unwrap().no_color || std::env::var_os("NO_COLOR").is_some()
166166
}
167167

168+
/// On Windows, enable VT processing (and report whether ANSI works). Without
169+
/// this, ConHost prints raw `\x1b[…` bytes even when stdout is a TTY. Elsewhere
170+
/// a TTY is enough.
171+
fn terminal_supports_ansi() -> bool {
172+
#[cfg(windows)]
173+
{
174+
crossterm::ansi_support::supports_ansi()
175+
}
176+
#[cfg(not(windows))]
177+
{
178+
true
179+
}
180+
}
181+
168182
/// Centralized color decision for **stdout**: honor `--no-color`/`--plain`, the
169183
/// `NO_COLOR` env var, and whether stdout is a TTY. This is the project-wide
170184
/// `should_colorize()` for human-readable output written via `print!`/`println!`.
171185
pub fn use_color() -> bool {
172-
!color_forced_off() && std::io::stdout().is_terminal()
186+
if color_forced_off() || !std::io::stdout().is_terminal() {
187+
return false;
188+
}
189+
terminal_supports_ansi()
173190
}
174191

175192
/// Same decision as [`use_color`] but keyed on **stderr**'s TTY, for color
176193
/// emitted via `eprint!`/`eprintln!` (warnings, notices, progress messages).
177194
pub fn use_color_stderr() -> bool {
178-
!color_forced_off() && std::io::stderr().is_terminal()
195+
if color_forced_off() || !std::io::stderr().is_terminal() {
196+
return false;
197+
}
198+
terminal_supports_ansi()
199+
}
200+
201+
/// Whether OSC 8 hyperlinks are safe to emit. Color/VT support is not enough —
202+
/// classic ConHost with VT enabled still dumps OSC 8 sequences as literal text.
203+
pub fn use_hyperlinks() -> bool {
204+
if !use_color() {
205+
return false;
206+
}
207+
// Explicit opt-out / opt-in (https://gist.github.com/egmontkob/eb114476d1dda0276486)
208+
if let Ok(v) = std::env::var("FORCE_HYPERLINK") {
209+
return v != "0";
210+
}
211+
if std::env::var_os("DOMTERM").is_some() {
212+
return true;
213+
}
214+
if let Ok(program) = std::env::var("TERM_PROGRAM") {
215+
match program.as_str() {
216+
"iTerm.app" | "WezTerm" | "ghostty" | "vscode" | "Hyper" | "kitty" => return true,
217+
_ => {}
218+
}
219+
}
220+
if let Ok(term) = std::env::var("TERM") {
221+
if term.contains("kitty") || term.contains("vte") || term == "xterm-kitty" {
222+
return true;
223+
}
224+
}
225+
// Windows Terminal sets WT_SESSION; classic cmd/ConHost does not.
226+
if std::env::var_os("WT_SESSION").is_some() {
227+
return true;
228+
}
229+
// VTE-based terminals (GNOME Terminal, Tilix, …) advertise via VTE_VERSION ≥ 5000.
230+
if let Ok(vte) = std::env::var("VTE_VERSION")
231+
&& let Ok(n) = vte.parse::<u32>()
232+
&& n >= 5000
233+
{
234+
return true;
235+
}
236+
// Non-Windows TTYs often support OSC 8 in modern emulators; keep the prior
237+
// interactive behavior there. On Windows, require an explicit signal above.
238+
#[cfg(not(windows))]
239+
{
240+
return true;
241+
}
242+
#[cfg(windows)]
243+
{
244+
false
245+
}
179246
}
180247

181248
/// Wrap `text` in the SGR `code` (e.g. `"1;32"`) when stdout coloring is

‎src/commands/blame.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,7 @@ impl Repository {
626626
&lines,
627627
&request.line_ranges,
628628
&request.options,
629+
&commits_with_notes,
629630
)?;
630631
}
631632

@@ -1702,6 +1703,7 @@ fn output_default_format(
17021703
lines: &[&str],
17031704
line_ranges: &[(u32, u32)],
17041705
options: &AutterBlameOptions,
1706+
commits_with_notes: &std::collections::HashSet<String>,
17051707
) -> Result<(), AutterError> {
17061708
let mut output = String::new();
17071709

@@ -1721,6 +1723,21 @@ fn output_default_format(
17211723
let mut requested_lines: Vec<u32> = line_to_hunk.keys().copied().collect();
17221724
requested_lines.sort_unstable();
17231725

1726+
// Lines shown as the git author because Autter has no note (and the commit
1727+
// isn't a known-agent email simulation). Used for the consistency notice.
1728+
let mut unattested_git_author_lines = 0usize;
1729+
if !options.mark_unknown {
1730+
for line_num in &requested_lines {
1731+
if let Some(hunk) = line_to_hunk.get(line_num)
1732+
&& !commits_with_notes.contains(&hunk.commit_sha)
1733+
&& crate::authorship::agent_detection::match_email_to_agent(&hunk.author_email)
1734+
.is_none()
1735+
{
1736+
unattested_git_author_lines += 1;
1737+
}
1738+
}
1739+
}
1740+
17241741
// Calculate the maximum line number width for proper padding
17251742
let max_line_num = lines.len() as u32;
17261743
let line_num_width = max_line_num.to_string().len();
@@ -1876,6 +1893,15 @@ fn output_default_format(
18761893
output.push_str(stats);
18771894
}
18781895

1896+
// Interactive TTY only: explain git-author fallback so blame doesn't look
1897+
// like it contradicts `autter stats` on the same commit. Keep porcelain /
1898+
// pager-piped / scripted output byte-compatible with git blame.
1899+
if unattested_git_author_lines > 0 && io::stdout().is_terminal() {
1900+
output.push_str(&crate::authorship::guidance::blame_git_author_fallback_notice(
1901+
unattested_git_author_lines,
1902+
));
1903+
}
1904+
18791905
// Output handling - respect pager environment variables
18801906
let pager = std::env::var("GIT_PAGER")
18811907
.or_else(|_| std::env::var("PAGER"))

0 commit comments

Comments
 (0)