From 0055f11121dbba30be0ad4cfc79a3dc73131cc47 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Sun, 25 Oct 2026 15:51:42 +0530 Subject: [PATCH 1/2] Redesign onboarding and sign-in on a shared terminal UI Add a src/ui module that every interactive command goes through: terminal detection (color depth, Unicode support, width), a theme in the brand teal, the "a." logo header, a step rail, cliclack-based select/multiselect/masked input, a hand-drawn browser sign-in screen, the timed confirm, and the spinner. Color goes through caps().paint(..), so NO_COLOR and --no-color now apply to onboarding and login, which wrote escape codes unconditionally. autter onboard opens with the header and walks one rail of numbered questions. Sign-in shows the code with a live countdown: Enter reopens the browser, c copies the code over OSC 52, Esc continues in local mode, and polling stops when the user steps away. A failed attempt offers retry, paste an access token, or continue locally, replacing the block of fallback instructions. Integrations are a checklist of the detected tools plus git capture, all checked; only checked tools get hooks (install_hooks::run_for_tools), and --yes still installs everything. autter login uses the same screen and recovery. Non-interactive runs print the same information as before. Spinner outcome lines go to stderr on a terminal; piped stdout keeps the exact lines it had. The selector loses its 1-9, Tab and Space shortcuts, and Esc now cancels like Ctrl-C. cargo run --example ui_preview walks the screens with sample data without touching config or the server. Design and decisions are in specs/roadmap/tui-onboarding-design.md. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 172 ++++- Cargo.toml | 2 + .../redesigned-onboarding-and-sign-in.md | 29 + examples/ui_preview.rs | 110 +++ specs/roadmap/tui-onboarding-design.md | 309 ++++++++ src/auth/client.rs | 25 +- src/commands/install_hooks.rs | 69 +- src/commands/login.rs | 285 ++++++- src/commands/onboard.rs | 718 ++++++++++++------ src/mdm/spinner.rs | 68 +- src/ui.rs | 307 -------- src/ui/clipboard.rs | 46 ++ src/ui/confirm.rs | 180 +++++ src/ui/header.rs | 198 +++++ src/ui/mod.rs | 58 ++ src/ui/progress.rs | 148 ++++ src/ui/prompt.rs | 156 ++++ src/ui/rail.rs | 134 ++++ src/ui/signin.rs | 304 ++++++++ ...ter__ui__header__tests__full_16_color.snap | 7 + ...er__ui__header__tests__full_256_color.snap | 7 + ...er__ui__header__tests__full_truecolor.snap | 7 + ...ui__header__tests__full_without_color.snap | 7 + ...er__ui__header__tests__legacy_console.snap | 7 + ...ader__tests__narrow_terminal_is_plain.snap | 7 + src/ui/theme.rs | 513 +++++++++++++ 26 files changed, 3189 insertions(+), 684 deletions(-) create mode 100644 changelog/redesigned-onboarding-and-sign-in.md create mode 100644 examples/ui_preview.rs create mode 100644 specs/roadmap/tui-onboarding-design.md delete mode 100644 src/ui.rs create mode 100644 src/ui/clipboard.rs create mode 100644 src/ui/confirm.rs create mode 100644 src/ui/header.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/progress.rs create mode 100644 src/ui/prompt.rs create mode 100644 src/ui/rail.rs create mode 100644 src/ui/signin.rs create mode 100644 src/ui/snapshots/autter__ui__header__tests__full_16_color.snap create mode 100644 src/ui/snapshots/autter__ui__header__tests__full_256_color.snap create mode 100644 src/ui/snapshots/autter__ui__header__tests__full_truecolor.snap create mode 100644 src/ui/snapshots/autter__ui__header__tests__full_without_color.snap create mode 100644 src/ui/snapshots/autter__ui__header__tests__legacy_console.snap create mode 100644 src/ui/snapshots/autter__ui__header__tests__narrow_terminal_is_plain.snap create mode 100644 src/ui/theme.rs diff --git a/Cargo.lock b/Cargo.lock index 5bb728e..45aa69a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,6 +264,8 @@ dependencies = [ "base64", "chrono", "clap", + "cliclack", + "console", "criterion", "crossterm", "dirs", @@ -528,6 +530,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cliclack" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134778b6a125729ff495c23ba32f64e0c26d61edd52f1f6715a0d036785cf30b" +dependencies = [ + "console", + "indicatif", + "once_cell", + "strsim", + "textwrap", + "zeroize", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1814,12 +1830,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1827,22 +1844,43 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1854,16 +1892,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1874,18 +1913,20 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1893,6 +1934,29 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + [[package]] name = "id-arena" version = "2.3.0" @@ -2771,6 +2835,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3455,6 +3521,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "smol" version = "2.0.2" @@ -3543,6 +3615,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3554,6 +3637,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3630,6 +3724,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "icu_segmenter", + "smawk", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3702,11 +3807,12 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4675,15 +4781,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4692,14 +4798,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 3.0.9", + "synstructure 0.14.0", ] [[package]] @@ -4740,7 +4846,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -4765,21 +4871,23 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -4787,13 +4895,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a5418fa..ffa9856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ sha2 = "0.10" imara-diff = "0.2" chrono = { version = "0.4.44", default-features = false, features = ["serde", "clock", "std"] } indicatif = "0.18" +cliclack = "0.5" +console = "0.16" smol = "2.0" futures = "0.3" tokio = { version = "1.52", features = ["rt-multi-thread", "sync", "time", "macros"] } diff --git a/changelog/redesigned-onboarding-and-sign-in.md b/changelog/redesigned-onboarding-and-sign-in.md new file mode 100644 index 0000000..2361287 --- /dev/null +++ b/changelog/redesigned-onboarding-and-sign-in.md @@ -0,0 +1,29 @@ +# Redesigned onboarding and sign-in + +## Summary + +`autter onboard` and `autter login` get a redesigned terminal UI (`specs/roadmap/tui-onboarding-design.md`). Setup opens with the autter logo and walks one step rail with numbered questions. Sign-in shows its code with a countdown and offers a way forward when it fails. Integrations can be chosen per tool. Prompts, spinners and the update prompt share one theme and degrade the same way in plain and legacy terminals. + +## Added + +- **Brand header**: `autter onboard` opens with the "a." logo drawn in block characters, with the version, a setup note and the current folder beside it. Legacy Windows consoles get a variant built from half blocks. Narrow terminals, non-terminals and ASCII-only locales get plain lines. +- **Sign-in screen** (`autter onboard`, `autter login`): the one-time code, the URL, and a live "expires in m:ss" countdown. Enter reopens the browser, `c` copies the code through the terminal (OSC 52), and Esc continues in local mode during onboarding or cancels `autter login`. +- **Recovery after a failed sign-in**: try again with a new code, paste an access token (masked, checked before it's saved), or continue in local mode (or cancel, for `autter login`). This replaces the block of fallback instructions. +- **Per-tool integrations**: onboarding lists the coding tools it finds plus "Git capture and background service", all checked. Enter installs everything, unchecking all skips, and `--yes` still installs everything without asking. +- **`src/ui/` module**: terminal detection (color depth, Unicode support, width), the theme, header, step rail, selector, multiselect, masked input, sign-in screen, timed confirm and spinner. Commands go through `crate::ui` instead of using prompt or color crates directly. +- **`cargo run --example ui_preview`**: walks the onboarding screens with sample data, including a simulated sign-in, without touching config or contacting the server. + +## Changed + +- **Onboarding layout**: one rail from start to finish. Questions show their place ("2/4"), answered questions collapse to one line, the telemetry question fits on one line with the details in its hint, and the closing summary ends the rail. +- **Selector**: drawn with cliclack in autter's theme (`◆` question, `●`/`○` options, the focused option's hint on its own line). Arrow keys, j/k and Enter work as before. Esc now cancels like Ctrl-C. The 1–9, Tab and Space shortcuts were removed. +- **Spinner outcome lines** (`✓`/`⚠`/`✗`/`○`, used by `autter install-hooks`): on a terminal they print to stderr. When stdout is piped, or when stderr is redirected, the same plain line as before still goes to stdout. +- **Update prompt**: same keys and 60-second timeout, drawn in the shared theme. + +Non-interactive runs (scripted installs, CI) print the same information as before: no prompts, and the sign-in URL and code as plain lines. + +## Fixed + +- `autter login` and `autter onboard` wrote color codes unconditionally, ignoring `NO_COLOR` and `--no-color`. +- The onboarding welcome promised "two quick choices"; setup can ask up to five questions. +- Telemetry consent opened with "One last thing", although more questions followed. diff --git a/examples/ui_preview.rs b/examples/ui_preview.rs new file mode 100644 index 0000000..4cc8e50 --- /dev/null +++ b/examples/ui_preview.rs @@ -0,0 +1,110 @@ +//! Preview autter's terminal UI in the current terminal without touching any +//! configuration or contacting the server: `cargo run --example ui_preview`. +//! Walks through the onboarding screens with sample data: the brand header, +//! the mode question, a simulated browser sign-in that approves itself after +//! a few seconds, the integrations checklist, spinner outcomes, and the +//! closing line. Useful for checking a terminal from the matrix in +//! specs/roadmap/tui-onboarding-design.md. + +use std::sync::mpsc; +use std::thread::sleep; +use std::time::Duration; + +use autter::ui::signin::{self, SigninEnd, SigninScreen}; +use autter::ui::{self, SelectItem, progress::Spinner, rail}; + +fn main() { + eprintln!("{:?}", ui::caps()); + eprintln!(); + ui::Header::new( + "Autter", + format!("v{}", env!("CARGO_PKG_VERSION")), + "Preview \u{00B7} nothing is saved", + ui::header::display_cwd(), + ) + .print(); + eprintln!(); + + let caps = ui::caps(); + let items = [ + SelectItem::new( + "Connected \u{2014} link this machine to your Autter org (recommended)", + "Powers prompt search, team dashboards, and PR authorship views. Sign-in opens in your browser.", + ), + SelectItem::new( + "Local only \u{2014} everything stays on this machine", + "No account needed. Switch anytime with `autter onboard --connect`.", + ), + ]; + let question = rail::titled(&caps, "How should Autter run on this machine?", Some("1/3")); + if ui::select(&question, &items, 0) == 0 { + preview_sign_in(); + } + + let tools = ["Claude Code", "Cursor", "VS Code"]; + let mut items: Vec = tools + .iter() + .map(|name| SelectItem::new(name, "Hooks record the edits this tool's agent makes.")) + .collect(); + items.push(SelectItem::new( + "Git capture and background service", + "Global git trace2 settings and a background service, so commits from any terminal get authorship notes.", + )); + let all: Vec = (0..items.len()).collect(); + let question = format!( + "{}\n{}", + rail::titled(&caps, "Install integrations?", Some("3/3")), + caps.paint("2", "space toggles \u{00B7} enter confirms") + ); + let chosen = ui::multiselect(&question, &items, &all); + + // The four outcome styles `autter install-hooks` prints per tool. Nothing + // is installed. + let _rail = ui::progress::on_rail(); + for (i, finish) in [ + Spinner::success as fn(&Spinner, &str), + Spinner::pending, + Spinner::error, + ] + .into_iter() + .enumerate() + { + if !chosen.contains(&i) { + continue; + } + let message = format!("{}: sample outcome", tools[i]); + let spinner = Spinner::new(&message); + sleep(Duration::from_millis(800)); + finish(&spinner, &message); + } + drop(_rail); + + rail::print_gap(); + rail::print_end(&format!( + "{} Authorship is recorded automatically on every commit.", + caps.paint("1;32", "You're all set!") + )); +} + +/// The sign-in screen with a fake approval after six seconds. Esc steps +/// away; Enter and `c` behave as in the real flow. +fn preview_sign_in() { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + sleep(Duration::from_secs(6)); + let _ = tx.send(Ok::<_, String>("you@company.com \u{00B7} acme")); + }); + let screen = SigninScreen { + title: rail::titled(&ui::caps(), "Sign in to Autter", Some("2/3")), + code: "WDJB-MJHT", + url: "https://app.autter.dev/cli/authorize?code=WDJB-MJHT", + browser_opened: false, + expires_in: Duration::from_secs(900), + escape_label: "continue in local mode", + }; + match signin::wait(&screen, rx, || false) { + SigninEnd::Approved(who) => rail::print_step(&format!("Signed in as {who}"), Some("2/3")), + SigninEnd::Escaped => rail::print_step("Sign in \u{00B7} skipped", Some("2/3")), + SigninEnd::Failed(error) => rail::print_warn(&format!("Sign-in didn't finish: {error}")), + } +} diff --git a/specs/roadmap/tui-onboarding-design.md b/specs/roadmap/tui-onboarding-design.md new file mode 100644 index 0000000..ac2184a --- /dev/null +++ b/specs/roadmap/tui-onboarding-design.md @@ -0,0 +1,309 @@ +# TUI redesign: signup and everyday usage + +Status: design approved, open questions decided (§7). Nothing implemented yet. + +This plan covers the first-run path (install → `autter onboard` → sign-in → integrations) and the commands people use afterwards (`whoami`, `doctor`, `install-hooks`, the upgrade prompt). It describes how things work today, the design rules, which TUI package to use, the target screens, and the order to build them in. + +--- + +## 1. How it works today + +### The signup path + +``` +install.sh / install.ps1 + └─ autter onboard ( String` is a pure function, snapshot-tested for each fallback, and written to stderr. + +### 4.1 Onboarding, connected path + +The flow starts with the header from 4.0 (line 2: "Setup · about a minute · 4 steps"), and then the step rail: + +``` +│ +◇ How should Autter run on this machine? 1/4 +│ Connected — sync to your Autter org +│ +◆ Sign in 2/4 +│ Code WDJB-MJHT +│ Opened https://app.autter.dev/device in your browser +│ +│ enter reopen browser · c copy code · esc continue in local mode +◒ Waiting for approval… expires in 14:32 +``` + +Pressing `c` copies the code with an OSC 52 escape sequence (`ESC ] 52 ; c ; BEL`), which needs no new dependency. The terminal doesn't report whether the copy worked, so the hint briefly changes to "copy requested" rather than claiming success, and the code stays on screen either way. Under tmux, wrap the sequence in tmux's passthrough so it reaches the outer terminal. + +After approval, the sign-in step collapses to: + +``` +◇ Signed in as you@company.com · org acme 2/4 +│ +◇ Share anonymous usage data? 3/4 +│ Yes — events are mirrored to ~/.autter/internal/telemetry.log +│ +◆ Install integrations for detected tools? 4/4 +│ ◼ Claude Code ◼ Cursor ◼ VS Code ◼ git capture + background service +│ space toggle · enter confirm +``` + +The integrations step is a multiselect of the tools detected on this machine, with everything checked, so Enter installs everything, as "Yes" does today. Unchecking every item is the same as "Not now". `--yes` still installs everything without asking. To support this, `install_hooks::run` takes an optional allow-list of agent names. `autter install --system` with no list behaves as today. The consent rule doesn't change: only checked items are installed. + +Integrations installing (cliclack multi-progress, one row per agent, all on stderr): + +``` +◇ Installing integrations +│ ✓ Claude Code hooks installed +│ ✓ Cursor hooks installed +│ ✓ VS Code extension installed +│ ✓ git capture trace2 configured, background service running +│ +└ You're all set — authorship is recorded on every commit. + + autter stats AI vs human share of your latest commit + autter blame who wrote each line + autter dash open your dashboard +``` + +### 4.2 Sign-in failure (replaces the error + PAT text + green "local mode" block) + +``` +▲ Sign-in didn't finish: the code expired. +│ +◆ What next? +│ ● Try again with a new code +│ ○ Paste an access token (Dashboard → Settings → Access Tokens) +│ ○ Continue in local mode (connect later with `autter onboard --connect`) +``` + +"Paste an access token" opens a masked `password` input and calls `run_pat_login`. The closing summary reports the fallback as a warning and does not print a green success line. + +### 4.3 Telemetry, one short line with the details in the option hints + +``` +◆ Share anonymous usage data? 3/4 +│ ● Yes OS, CPU arch, Autter version, random install ID. Never code, prompts, paths or names. +│ ○ No Send nothing. Change anytime with `autter telemetry on|off`. +``` + +"Yes" stays preselected in interactive runs. Non-interactive runs with no flag still default to off. + +### 4.4 Everyday commands + +- **Bare `autter` on a TTY**: a short status card in place of the help wall. `autter help` still shows every command, and with no TTY bare `autter` still prints the overview. The card must stay fast: read only local state (config, credentials, the last commit's note) and make no network calls. A row that can't be computed quickly is left out. + ``` + ▗▟▀▀▙▟▌ Autter v2.3.0 + ▐▌ ▐█▌ Connected · you@company.com · acme + ▝▜▄▄▛▜▌ ██ ~\Documents\autter-cli · capturing in 3 tools + + Last commit a1b2c3d ████████████░░░░░░░ 62% AI · Claude Code + Pending uploads none + + autter stats · autter blame · autter dash · autter help + ``` +- **`whoami`**: on a TTY, the header followed by a grouped card (Organizations / Cloud sync), with token expiry shown as relative time ("in 6 days"). On a pipe, today's `Key: value` lines stay exactly as they are. +- **`doctor`**: a live checklist where each section shows a spinner while running and then collapses to ✓ / ▲ / ✗. Fixes appear under the failing row. The summary goes in the outro. +- **`install-hooks`**: the same row-per-agent multi-progress as onboarding, on stderr. When stdout is not a terminal, it also prints a plain one-line-per-agent result to stdout (`claude-code: installed`). Note the stream change in the changelog. +- **Update prompt**: the same `[Y/n]` behavior and timeout, rendered in the shared theme. + +--- + +## 5. Architecture + +Turn `src/ui.rs` into a small module tree. Call sites use only `crate::ui`. + +``` +src/ui/ + mod.rs re-exports; is_interactive() (stdin+stderr TTY, not CI, not inside an agent) + theme.rs glyphs (Unicode/ASCII), colors, cliclack Theme impl, NO_COLOR wiring + header.rs the "a." logo header (§4.0): TermCaps detection, full/legacy/plain variants + flow.rs Flow { total_steps } → intro(), step(n, title), outro_ok(), outro_warn(report) + prompt.rs select / multiselect / confirm / input / password — each takes the + non-interactive default; Ctrl-C/Esc → Cancelled (exit 130) + confirm.rs the existing timed post-command confirm, restyled (no cliclack) + progress.rs Spinner + MultiProgress replacing mdm::spinner, stderr only + render.rs pure fns returning String (summary, whoami card, doctor rows) for snapshot tests +``` + +Migration rules: +- `mdm::spinner::Spinner` becomes a re-export of `ui::progress::Spinner` for one release, then gets removed. +- Remove every raw `\x1b[` in `src/commands/` and route it through the theme. A lint test can grep for this. +- Keep everything that decides behavior (`integration_decision`, `BacklogDecision`, the onboarding flags) separate from rendering. The redesign should only change how things look and the order of steps. + +### Testing + +- **Snapshots (insta):** every `render.rs` function, with color both off and on, plus ASCII mode. `render_finished` already works this way. +- **Non-TTY integration tests:** `autter onboard --local --no-telemetry`, `--connect` with a mocked device flow (mockito), and `whoami` piped. Assert that stdout is unchanged and that nothing blocks. +- **Manual terminal matrix** before release: + - Windows Terminal, PowerShell 5.1 conhost, VS Code terminal, macOS Terminal, iTerm2, GNOME Terminal, tmux + - 60-column width + - `NO_COLOR=1` + - `curl | sh`, and `install.ps1` piped through `iex` + +--- + +## 6. Phases + +| Phase | Scope | Changes behavior? | +|---|---|---| +| **0. Design sign-off** | This doc and the decisions in §7. Optionally record the mockups with VHS/asciinema for review. | No (done) | +| **1. Foundation** | Add cliclack and the `ui/` facade and theme. Add the brand header (§4.0) and its fallbacks. Port `select` and `confirm`. Move Spinner output to stderr. Remove raw escapes from `login.rs`. Wire up the ASCII fallback. Snapshot tests. | Look only | +| **2. Signup flow** | Onboarding with intro, step counter and outro. New sign-in screen (OSC 52 copy, reopen browser, countdown, esc to go local). Failure screen with Retry / Token / Local. Shorter telemetry step (Yes still preselected). Integrations multiselect, with an agent allow-list for `install_hooks::run`. Fixed welcome copy. Installer hand-off so the PATH notice stays the last thing on screen. | Yes: failure handling, per-tool integrations, step copy | +| **3. Usage surfaces** | install-hooks multi-progress (progress on stderr, plain summary on stdout when piped), live doctor checklist, whoami card, restyled update prompt, bare-`autter` status card. | Yes: stderr/stdout split for install-hooks; bare `autter` on a TTY | +| **4. Interactive views (ratatui)** | Full-screen views built on the existing `ratatui` dependency, sharing the theme's colors: `autter stats -i` to browse recent commits by AI share and drill into per-file and per-agent splits, and a blame browser that opens a file with AI/human gutter colors and lets you jump to the prompt behind a line. Only on a TTY, using the alternate screen, with q/Esc to quit. The existing non-interactive commands stay unchanged. Scope this phase in its own short spec before building it. | New features | + +**Phase 1 notes (as built):** +- The selector now runs on cliclack. Arrow keys, j/k and Enter work as before. The 1–9, Tab and Space shortcuts are gone because cliclack's key handling isn't extensible. Esc now cancels like Ctrl-C (exit 130). +- cliclack chooses Unicode or ASCII glyphs from `WT_SESSION` alone, so `PromptTheme` overrides every method that draws a glyph and uses autter's own detection instead. +- `cargo run --example ui_preview` shows the header, the selector, the timed confirm and the spinner outcomes in any terminal without touching config. Use it for the terminal matrix. +- The spinner's outcome lines go to stderr on a terminal. Piped stdout keeps the exact lines it had before (Phase 3's stream decision, landed early because the spinner moved). + +**Phase 2 notes (as built):** +- Onboarding runs on one rail (`ui::rail`). Questions carry a counter for the path actually taken: the mode question counts the connected path; if the user picks Local, later counters use the shorter total. The non-interactive skip message now prints before the welcome header, not after it. +- The sign-in screen (`ui::signin`) is hand-drawn on crossterm like `confirm`: it redraws the countdown while a background thread polls (`OAuthClient::poll_for_token_until`, which stops polling when Esc is pressed). Enter reopens the browser, `c` sends OSC 52 (`ui::clipboard`), Esc continues locally in onboarding and cancels in `autter login`, and Ctrl-C exits 130. Without a terminal, sign-in prints the URL and code exactly as before. +- After a failed attempt: "Try again with a new code", "Paste an access token" (masked input that validates the token before storing it), and "Continue in local mode" (or "Cancel" in `autter login`). Continuing after a failure is listed as a warning in the summary. Pressing Esc without a failure is treated as a choice, so it isn't listed. +- Integrations are a multiselect of `install_hooks::detect_tools()` plus "Git capture and background service", all checked to start. `install_hooks::run_for_tools` installs only the checked tools, skips the "Coding Agents" header and "not detected" list, and indents spinner output under the rail (`ui::progress::on_rail`). `--yes` still installs everything. +- Onboarding no longer uses unconditional color codes, so `NO_COLOR` and `--no-color` apply to it too. +- `cargo run --example ui_preview` walks the same screens with sample data, including a sign-in that approves itself after six seconds. + +Phases 1 and 2 are the signup improvement. Phase 3 is the usage improvement. Phase 4 adds new interactive features. Each phase can ship as its own PR. + +--- + +## 7. Decisions + +| # | Question | Decision | +|---|---|---| +| 1 | Telemetry default | **Keep "Yes" preselected** in interactive onboarding. Non-interactive runs with no flag still default to off. | +| 2 | Bare `autter` | **Status card plus key commands** on a TTY, built from local state only. With no TTY it prints the overview, and `autter help` still shows everything. | +| 3 | Per-agent integrations | **Multiselect with everything checked.** `install_hooks::run` gets an agent allow-list, and `--yes` still installs all. | +| 4 | Copying the device code | **OSC 52 on the `c` key.** No new dependency, and the code always stays visible. | +| 5 | ratatui | **Keep it and commit to Phase 4** (interactive stats and blame views). | +| 6 | `install-hooks` output stream | **Progress to stderr, plain summary on stdout when piped.** Nothing in this repo parses that stdout (checked: installers, npm script, editor extensions, tests). Call it out in the changelog. | diff --git a/src/auth/client.rs b/src/auth/client.rs index 71bd527..5dbbaf7 100644 --- a/src/auth/client.rs +++ b/src/auth/client.rs @@ -4,8 +4,9 @@ use crate::auth::types::{ }; use crate::config; use crate::http; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; /// OAuth client for device authorization flow pub struct OAuthClient { @@ -141,6 +142,18 @@ impl OAuthClient { device_code: &str, interval: u32, expires_in: u32, + ) -> Result { + self.poll_for_token_until(device_code, interval, expires_in, &AtomicBool::new(false)) + } + + /// [`Self::poll_for_token`] that stops early, without another request, + /// once `cancel` is set (checked every quarter second while waiting). + pub fn poll_for_token_until( + &self, + device_code: &str, + interval: u32, + expires_in: u32, + cancel: &AtomicBool, ) -> Result { let url = format!("{}/worker/oauth/token", self.base_url); let mut elapsed = 0u32; @@ -148,7 +161,15 @@ impl OAuthClient { while elapsed < expires_in { // Wait before polling - thread::sleep(Duration::from_secs(current_interval as u64)); + let wake = Instant::now() + Duration::from_secs(current_interval as u64); + while Instant::now() < wake { + if cancel.load(Ordering::Relaxed) { + return Err("Sign-in was cancelled".to_string()); + } + thread::sleep( + Duration::from_millis(250).min(wake.saturating_duration_since(Instant::now())), + ); + } elapsed += current_interval; let body = serde_json::json!({ diff --git a/src/commands/install_hooks.rs b/src/commands/install_hooks.rs index 5d4c19c..6c1e8a1 100644 --- a/src/commands/install_hooks.rs +++ b/src/commands/install_hooks.rs @@ -17,7 +17,7 @@ const TRACE2_EVENT_NESTING_KEY: &str = "trace2.eventNesting"; const TRACE2_EVENT_NESTING_VALUE: &str = "10"; const VISUAL_STUDIO_INSTALLER_ID: &str = "visual-studio"; -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] struct InstallOptions { dry_run: bool, verbose: bool, @@ -25,6 +25,56 @@ struct InstallOptions { include_visual_studio_extension: bool, /// When true, also write global git trace2 config and start the daemon. apply_system: bool, + /// Only these installer ids, when set (the tools chosen in onboarding). + only: Option>, + /// Running inside onboarding's step rail: skip this command's own section + /// header and "not detected" list, which onboarding already showed. + on_rail: bool, +} + +/// A tool autter can install hooks for, found on this machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedTool { + pub id: String, + pub name: String, +} + +/// The tools on this machine that `install-hooks` would set up. A tool whose +/// version check fails is still listed, so installing it reports why. +pub fn detect_tools() -> Vec { + let Ok(binary_path) = resolve_hook_binary_path() else { + return Vec::new(); + }; + let params = HookInstallerParams { binary_path }; + let options = InstallOptions::default(); + get_all_installers() + .into_iter() + .filter(|installer| should_include_installer(installer.id(), &options)) + .filter(|installer| match installer.check_hooks(¶ms) { + Ok(check) => check.tool_installed, + Err(_) => true, + }) + .map(|installer| DetectedTool { + id: installer.id().to_string(), + name: installer.name().to_string(), + }) + .collect() +} + +/// Install hooks for just `tool_ids` (from [`detect_tools`]), plus the global +/// git trace2 config and background service when `system` is set. Output +/// hangs off onboarding's step rail. +pub fn run_for_tools( + tool_ids: &[String], + system: bool, +) -> Result, AutterError> { + let _rail = crate::ui::progress::on_rail(); + run_with_options(InstallOptions { + apply_system: system, + only: Some(tool_ids.iter().cloned().collect()), + on_rail: true, + ..InstallOptions::default() + }) } /// Installation status for a tool @@ -349,8 +399,10 @@ fn ensure_daemon(dry_run: bool) { /// after the user consents — so `npm install` / bare `install-hooks` no longer /// mutate the machine silently. pub fn run(args: &[String]) -> Result, AutterError> { - let options = parse_install_options(args); + run_with_options(parse_install_options(args)) +} +fn run_with_options(options: InstallOptions) -> Result, AutterError> { if options.apply_system { // Daemon trace2 config must be in place before any install work starts. // Non-fatal: the global git config may be read-only (e.g. Nix store symlink). @@ -470,6 +522,9 @@ fn parse_install_options(args: &[String]) -> InstallOptions { } fn should_include_installer(id: &str, options: &InstallOptions) -> bool { + if let Some(only) = &options.only { + return only.contains(id); + } options.include_visual_studio_extension || id != VISUAL_STUDIO_INSTALLER_ID } @@ -587,7 +642,9 @@ async fn async_run_install( let mut detailed_results: Vec<(String, InstallResult)> = Vec::new(); // === Coding Agents === - println!("\n{}", paint("1", "Coding Agents")); + if !options.on_rail { + println!("\n{}", paint("1", "Coding Agents")); + } let installers = get_all_installers(); let mut installed_tools: HashSet = HashSet::new(); @@ -772,7 +829,7 @@ async fn async_run_install( } } - if !not_detected.is_empty() { + if !not_detected.is_empty() && !options.on_rail { println!( "{}", paint("2", &format!(" Not detected: {}", not_detected.join(", "))) @@ -793,7 +850,9 @@ async fn async_run_install( } if !any_checked { - println!("No compatible IDEs or agent configurations detected. Nothing to install."); + if !options.on_rail { + println!("No compatible IDEs or agent configurations detected. Nothing to install."); + } } else if has_changes && options.dry_run { println!( "\n{}", diff --git a/src/commands/login.rs b/src/commands/login.rs index 41a275c..57d4384 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -1,4 +1,12 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::time::Duration; + +use crate::auth::types::StoredCredentials; use crate::auth::{CredentialStore, OAuthClient}; +use crate::ui::signin::{self, SigninEnd, SigninScreen}; +use crate::ui::{self, SelectItem, rail}; /// Result of running the OAuth device-login flow. pub enum LoginOutcome { @@ -6,14 +14,55 @@ pub enum LoginOutcome { AlreadyLoggedIn, /// A fresh login completed successfully and credentials were stored. LoggedIn, + /// The user chose not to finish signing in: Esc on the sign-in screen, + /// or the last choice after a failed attempt (whose error is kept). + Declined { after_error: Option }, } -/// Run the OAuth2 device-authorization flow. +/// What stepping away from an interactive sign-in means to the caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decline { + /// `autter onboard`: set up in local mode instead. + ContinueLocally, + /// `autter login`: stop. + Cancel, +} + +impl Decline { + fn escape_label(self) -> &'static str { + match self { + Decline::ContinueLocally => "continue in local mode", + Decline::Cancel => "cancel", + } + } +} + +/// How the caller frames an interactive sign-in. +#[derive(Debug, Clone, Copy)] +pub struct LoginUi<'a> { + /// Step counter shown beside the title, e.g. `2/4`. + pub counter: Option<&'a str>, + pub decline: Decline, +} + +/// Run the OAuth2 device-authorization flow as `autter login` does. /// -/// Unlike [`handle_login`], this never calls `std::process::exit`, so it can be -/// reused by other flows (e.g. `autter onboard`). It prints user-facing -/// instructions to stderr and returns the outcome (or an error string). +/// Unlike [`handle_login`], this never calls `std::process::exit` (except on +/// Ctrl-C), so it can be reused by other flows. See [`run_device_login_with`]. pub fn run_device_login() -> Result { + run_device_login_with(LoginUi { + counter: None, + decline: Decline::Cancel, + }) +} + +/// Run the OAuth2 device-authorization flow. +/// +/// At a terminal this shows the sign-in screen (code, countdown, keys to +/// reopen the browser or copy the code) and, when an attempt fails, offers to +/// try again, paste an access token, or step away. Without a terminal it +/// prints the URL and code and waits, returning the error if it fails. +pub fn run_device_login_with(login_ui: LoginUi) -> Result { let store = CredentialStore::new(); let stored = store.load().ok().flatten(); @@ -38,12 +87,16 @@ pub fn run_device_login() -> Result { if ctx.auth_token.is_some() { crate::auth::notice::clear_sync_auth_blocked(); eprintln!("Stored credentials still work. Resuming cloud sync."); - resume_cloud_sync_after_login(); + resume_cloud_sync_after_login(false); return Ok(LoginOutcome::LoggedIn); } eprintln!("Stored credentials could not authenticate. Starting a new sign-in...\n"); } + if ui::is_interactive() { + return run_interactive(&store, login_ui); + } + let client = OAuthClient::new(); // Start device flow @@ -60,12 +113,13 @@ pub fn run_device_login() -> Result { .unwrap_or(&auth_response.verification_uri); // Display instructions + let caps = crate::ui::caps(); eprintln!("To authorize this device:"); eprintln!(" 1. Open this URL in your browser:"); - eprintln!(" \x1b[36m{}\x1b[0m", display_url); + eprintln!(" {}", caps.paint("36", display_url)); eprintln!(); eprintln!(" 2. Enter this code when prompted:"); - eprintln!(" \x1b[1m{}\x1b[0m", auth_response.user_code); + eprintln!(" {}", caps.paint("1", &auth_response.user_code)); eprintln!(); // Try to open browser automatically @@ -102,28 +156,171 @@ pub fn run_device_login() -> Result { })?; crate::auth::notice::clear_sync_auth_blocked(); - eprintln!("\x1b[1;32m✓ CLI authorization successful.\x1b[0m"); + let ok = caps.glyphs().ok; + eprintln!( + "{}", + caps.paint("1;32", &format!("{ok} CLI authorization successful.")) + ); print_login_identity(&creds.access_token); - resume_cloud_sync_after_login(); + resume_cloud_sync_after_login(false); + + Ok(LoginOutcome::LoggedIn) +} + +/// The interactive sign-in loop: browser sign-in, then on failure a choice +/// to retry, paste a token, or step away. +fn run_interactive(store: &CredentialStore, login_ui: LoginUi) -> Result { + loop { + let error = match sign_in_with_browser(login_ui) { + SigninEnd::Approved(creds) => return finish_interactive(store, &creds, login_ui), + SigninEnd::Escaped => { + if login_ui.decline == Decline::ContinueLocally { + rail::print_step("Sign in \u{00B7} skipped", login_ui.counter); + } + return Ok(LoginOutcome::Declined { after_error: None }); + } + SigninEnd::Failed(error) => error, + }; + + rail::print_warn(&format!("Sign-in didn't finish: {error}")); + match failure_choice(login_ui.decline) { + FailureChoice::Retry => {} + FailureChoice::Token => { + let Some(token) = ui::secret("Paste your access token") else { + continue; + }; + match exchange_pat(&token) { + Ok(creds) => return finish_interactive(store, &creds, login_ui), + Err(e) => rail::print_warn(&e), + } + } + FailureChoice::Decline => { + return Ok(LoginOutcome::Declined { + after_error: Some(cli_authorization_failure(&error)), + }); + } + } + } +} + +/// Start a device sign-in, open the browser, and wait on the sign-in screen +/// while a background thread polls for approval. +fn sign_in_with_browser(login_ui: LoginUi) -> SigninEnd { + let spinner = ui::progress::Spinner::new("Starting sign-in\u{2026}"); + let started = OAuthClient::new().start_device_flow(); + spinner.stop(); + let auth = match started { + Ok(auth) => auth, + Err(e) => return SigninEnd::Failed(format!("could not start the browser sign-in: {e}")), + }; + + let url = auth + .verification_uri_complete + .clone() + .unwrap_or_else(|| auth.verification_uri.clone()); + let opened = open_browser(&url).is_ok(); + + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + { + let cancel = Arc::clone(&cancel); + let device_code = auth.device_code.clone(); + let (interval, expires_in) = (auth.interval, auth.expires_in); + std::thread::spawn(move || { + let result = OAuthClient::new().poll_for_token_until( + &device_code, + interval, + expires_in, + &cancel, + ); + let _ = tx.send(result); + }); + } + + let screen = SigninScreen { + title: rail::titled(&ui::caps(), "Sign in to Autter", login_ui.counter), + code: &auth.user_code, + url: &url, + browser_opened: opened, + expires_in: Duration::from_secs(u64::from(auth.expires_in)), + escape_label: login_ui.decline.escape_label(), + }; + let end = signin::wait(&screen, rx, || open_browser(&url).is_ok()); + cancel.store(true, Ordering::Relaxed); + end +} +/// Store approved credentials and show who is signed in. +fn finish_interactive( + store: &CredentialStore, + creds: &StoredCredentials, + login_ui: LoginUi, +) -> Result { + // Authorization is only successful once the credentials are safely stored. + store.store(creds).map_err(|e| { + format!( + "CLI authorization failed: approval succeeded, but credentials could not be saved: {e}\nExisting stored credentials were not changed." + ) + })?; + crate::auth::notice::clear_sync_auth_blocked(); + rail::print_step( + &format!("Signed in as {}", identity_summary(&creds.access_token)), + login_ui.counter, + ); + resume_cloud_sync_after_login(true); Ok(LoginOutcome::LoggedIn) } +enum FailureChoice { + Retry, + Token, + Decline, +} + +fn failure_choice(decline: Decline) -> FailureChoice { + let last = match decline { + Decline::ContinueLocally => SelectItem::new( + "Continue in local mode", + "Connect later with `autter onboard --connect`.", + ), + Decline::Cancel => SelectItem::new("Cancel", "Run `autter login` again anytime."), + }; + let token_hint = format!( + "Create one at {} under any organization's Settings \u{2192} Access Tokens.", + web_app_url() + ); + let items = [ + SelectItem::new( + "Try again with a new code", + "Opens a fresh sign-in in your browser.", + ), + SelectItem::new("Paste an access token", &token_hint), + last, + ]; + match ui::select("What next?", &items, 0) { + 0 => FailureChoice::Retry, + 1 => FailureChoice::Token, + _ => FailureChoice::Decline, + } +} + fn cli_authorization_failure(detail: impl std::fmt::Display) -> String { format!("CLI authorization failed: {detail}\nNo credentials were issued or changed.") } -/// Sign in with a Personal Access Token instead of the interactive device flow. -/// -/// Validates the token by exchanging it for an access token up-front, so a bad -/// token fails immediately rather than being stored and failing later. -pub fn run_pat_login(token: &str) -> Result { +/// Exchange a Personal Access Token for credentials, validating it up-front +/// so a bad token fails immediately rather than being stored. +fn exchange_pat(token: &str) -> Result { let token = token.trim(); if token.is_empty() { return Err("No token provided. Usage: autter login --token ".to_string()); } + OAuthClient::new().exchange_pat(token) +} - let creds = OAuthClient::new().exchange_pat(token)?; +/// Sign in with a Personal Access Token instead of the interactive device flow. +pub fn run_pat_login(token: &str) -> Result { + let creds = exchange_pat(token)?; let store = CredentialStore::new(); store @@ -133,10 +330,28 @@ pub fn run_pat_login(token: &str) -> Result { eprintln!("Successfully logged in with a Personal Access Token."); print_login_identity(&creds.access_token); - resume_cloud_sync_after_login(); + resume_cloud_sync_after_login(false); Ok(LoginOutcome::LoggedIn) } +/// "Name (email) · Org" from the access token's claims, best-effort. +fn identity_summary(access_token: &str) -> String { + let identity = crate::auth::identity::extract_identity_from_access_token(access_token); + let name = identity.name.as_deref().filter(|s| !s.is_empty()); + let email = identity.email.as_deref().filter(|s| !s.is_empty()); + let mut summary = match (name, email) { + (Some(name), Some(email)) => format!("{name} ({email})"), + (Some(who), None) | (None, Some(who)) => who.to_string(), + (None, None) => "your Autter account".to_string(), + }; + if let Some(org) = identity.active_org() + && let Some(org_name) = org.org_name.as_deref().filter(|s| !s.is_empty()) + { + summary.push_str(&format!(" \u{00B7} {org_name}")); + } + summary +} + /// Print the signed-in user and active org from the access token's claims /// (best-effort — falls back gracefully). fn print_login_identity(access_token: &str) { @@ -166,30 +381,40 @@ fn print_login_identity(access_token: &str) { /// Restart the background service so it picks up fresh credentials and clears /// any in-memory sync backoff. Skipped in test harnesses, which manage their /// own daemon lifecycle. Best-effort: a failed restart must not fail login. -fn resume_cloud_sync_after_login() { +/// `on_rail` prints the notes under an interactive flow's rail. +fn resume_cloud_sync_after_login(on_rail: bool) { if std::env::var_os("AUTTER_TEST_DB_PATH").is_some() { return; } + let say = |text: &str| { + if on_rail { + rail::print_note(text); + } else { + eprintln!(" {text}"); + } + }; let pending = crate::auth::notice::pending_sync_counts(); if pending.total() > 0 { - eprintln!( - " {} queued locally — restarting background service to upload.", + say(&format!( + "{} queued locally \u{2014} restarting background service to upload.", pending.summary() - ); + )); } else { - eprintln!(" Restarting background service to resume cloud sync."); + say("Restarting background service to resume cloud sync."); } let Ok(daemon_config) = crate::daemon::DaemonConfig::from_env_or_default_paths() else { - eprintln!(" Warning: could not restart background service automatically."); - eprintln!(" Run `autter bg restart` if cloud sync does not resume."); + say("Warning: could not restart background service automatically."); + say("Run `autter bg restart` if cloud sync does not resume."); return; }; if let Err(error) = crate::commands::daemon::restart_daemon(&daemon_config) { - eprintln!(" Warning: could not restart background service: {error}"); - eprintln!(" Run `autter bg restart` if cloud sync does not resume."); + say(&format!( + "Warning: could not restart background service: {error}" + )); + say("Run `autter bg restart` if cloud sync does not resume."); } } @@ -290,7 +515,15 @@ pub fn handle_login(args: &[String]) { Ok(LoginOutcome::AlreadyLoggedIn) => { eprintln!("Already logged in. Use 'autter logout' to log out first."); } - Ok(LoginOutcome::LoggedIn) => {} + Ok(LoginOutcome::LoggedIn) => { + if ui::is_interactive() { + rail::print_end("You're signed in."); + } + } + Ok(LoginOutcome::Declined { after_error }) => { + rail::print_end("Sign-in cancelled."); + std::process::exit(if after_error.is_some() { 1 } else { 130 }); + } Err(e) => { eprintln!("{}", e); eprintln!(); diff --git a/src/commands/onboard.rs b/src/commands/onboard.rs index bb12d22..4a980a3 100644 --- a/src/commands/onboard.rs +++ b/src/commands/onboard.rs @@ -1,8 +1,10 @@ //! First-run onboarding flow (`autter onboard`). //! -//! After installation, autter walks the user through a one-time guided setup: -//! an arrow-key choice between the two modes, a telemetry consent choice, and -//! a "you're all set" summary with the first commands to try. +//! After installation, autter walks the user through a one-time guided setup +//! on a single step rail: a choice between the two modes, browser sign-in +//! when connecting, a telemetry consent choice, which integrations to +//! install, and a closing summary with the first commands to try. Each +//! question shows its place in the flow ("2/4"). //! //! - **Local mode** — everything stays on the machine (git notes + local //! storage). Nothing is uploaded. The user is told they will not get the @@ -11,7 +13,9 @@ //! - **Connected mode** — the user links their Autter account (OAuth device //! flow). Attribution is still written to local git notes exactly as before, //! and prompt/usage data additionally syncs to the org's Autter platform -//! (persisted in the org's PostgreSQL via the platform API). +//! (persisted in the org's PostgreSQL via the platform API). If sign-in is +//! skipped or fails, the user can retry, paste an access token, or continue +//! in local mode. //! //! After the mode choice (and regardless of whether the user signed in), the //! flow asks for consent to anonymous usage analytics and error tracking. The @@ -27,7 +31,8 @@ //! //! Choosing a mode is not consent to change system configuration. IDE/agent //! hooks, global git trace2 settings and the background service are applied -//! only after an interactive "yes", or with an explicit `--yes`. +//! only for the integrations the user checks interactively, or all of them +//! with an explicit `--yes`. //! //! Note: the full authorship-note → Postgres dual-write (keeping local git //! notes while also uploading the note itself) is delivered alongside the @@ -35,14 +40,13 @@ //! prompt/usage data via the existing CAS upload path. use std::collections::BTreeMap; -use std::io::IsTerminal; use serde_json::json; use crate::auth::{AuthState, collect_auth_status}; -use crate::commands::login::run_device_login; +use crate::commands::login::{Decline, LoginOutcome, LoginUi, run_device_login_with}; use crate::config::{self, NotesBackendConfig, NotesBackendKind}; -use crate::ui::{self, BOLD, CYAN, DIM, GREEN, RESET, SelectItem, YELLOW}; +use crate::ui::{self, SelectItem, TermCaps, rail}; /// Entry point for the `autter onboard` command. pub fn handle_onboard(args: &[String]) { @@ -83,9 +87,31 @@ pub fn handle_onboard(args: &[String]) { return; } + let logged_in = matches!(collect_auth_status().state, AuthState::LoggedIn); + let interactive = ui::is_interactive(); + let ask_mode = !(choose_connect || choose_local || logged_in); + + if ask_mode && !interactive { + // Non-interactive shell with no explicit choice: don't block automated + // installs and don't guess — leave onboarding incomplete so the user can + // run `autter onboard` later from a real terminal. + eprintln!("Non-interactive shell detected — skipping onboarding for now."); + eprintln!("Run `autter onboard` from your terminal to choose local or connected mode."); + return; + } + print_welcome(); - let logged_in = matches!(collect_auth_status().state, AuthState::LoggedIn); + let plan = StepPlan { + ask_mode, + sign_in: !logged_in, + ask_telemetry: telemetry_flag.is_none(), + ask_integrations: !assume_yes, + maybe_backlog: !assume_yes && interactive && backlog_exists(), + }; + // Number the questions for the recommended (connected) path until the + // user picks one. + let mut steps = Steps::new(interactive, plan.total(true)); let connect = if choose_connect { true @@ -95,16 +121,10 @@ pub fn handle_onboard(args: &[String]) { // Already authenticated (e.g. via an install nonce): treat as connected // without prompting. true - } else if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() { - prompt_mode_choice() } else { - // Non-interactive shell with no explicit choice: don't block automated - // installs and don't guess — leave onboarding incomplete so the user can - // run `autter onboard` later from a real terminal. - eprintln!("Non-interactive shell detected — skipping onboarding for now."); - eprintln!("Run `autter onboard` from your terminal to choose local or connected mode."); - return; + prompt_mode_choice(steps.next().as_deref()) }; + steps.set_total(plan.total(connect)); // Every step that does not complete is recorded here, so onboarding never // ends with "You're all set!" after something went wrong. @@ -122,7 +142,7 @@ pub fn handle_onboard(args: &[String]) { crate::upload_hold::PURGE_COMMAND, ); } - if let Err(e) = setup_connected(&mut file_config, logged_in) { + if let Err(e) = setup_connected(&mut file_config, logged_in, !ask_mode, &mut steps) { report.problem( format!( "Could not connect to the Autter platform ({e}); set up in local mode instead" @@ -131,16 +151,16 @@ pub fn handle_onboard(args: &[String]) { ); } } else { - setup_local(&mut file_config); + setup_local(&mut file_config, !ask_mode); } // Telemetry consent is asked regardless of the connected/local choice above. - let telemetry_enabled = configure_telemetry(&mut file_config, telemetry_flag); + let telemetry_enabled = configure_telemetry(&mut file_config, telemetry_flag, &mut steps); // System integrations (IDE hooks, global git trace2, daemon) require // explicit consent. Picking a mode (`--local`/`--connect`) or re-running - // (`--force`) is not consent; only an interactive "yes" or `--yes` is. - if let Err(e) = apply_system_integrations_with_consent(assume_yes) { + // (`--force`) is not consent; only interactive choices or `--yes` are. + if let Err(e) = apply_system_integrations_with_consent(assume_yes, &mut steps) { report.problem( format!("Could not install IDE/agent hooks and git capture settings ({e})"), "autter install --system", @@ -149,7 +169,7 @@ pub fn handle_onboard(args: &[String]) { // After connect: offer to keep or discard any pre-login upload backlog. if file_config.prompt_storage.as_deref() != Some("local") - && let Err(e) = offer_historical_sync_consent(assume_yes) + && let Err(e) = offer_historical_sync_consent(assume_yes, &mut steps) { report.problem( format!("Could not apply your choice for the held upload backlog ({e})"), @@ -187,10 +207,7 @@ pub fn handle_onboard(args: &[String]) { // Reflect the mode actually configured: a connect attempt can fall back // to local if the device login fails. let connected = file_config.prompt_storage.as_deref() != Some("local"); - eprint!( - "{}", - render_finished(connected, &report, std::io::stderr().is_terminal()) - ); + eprint!("{}", render_finished(connected, &report, &ui::caps())); // Fire a one-off install/onboard event so we can count installs by version // and platform. Only when the user just opted in. @@ -205,6 +222,75 @@ pub fn handle_onboard(args: &[String]) { } } +/// Which questions this run will ask, to number them. +#[derive(Debug, Clone, Copy)] +struct StepPlan { + ask_mode: bool, + sign_in: bool, + ask_telemetry: bool, + ask_integrations: bool, + maybe_backlog: bool, +} + +impl StepPlan { + fn total(&self, connect: bool) -> usize { + [ + self.ask_mode, + connect && self.sign_in, + self.ask_telemetry, + self.ask_integrations, + connect && self.maybe_backlog, + ] + .into_iter() + .filter(|&step| step) + .count() + } +} + +/// Numbers the questions actually shown ("2/4"). Without a terminal there +/// are no questions, so no numbers either. +struct Steps { + enabled: bool, + shown: usize, + total: usize, +} + +impl Steps { + fn new(enabled: bool, total: usize) -> Self { + Self { + enabled, + shown: 0, + total, + } + } + + fn set_total(&mut self, total: usize) { + self.total = total; + } + + fn next(&mut self) -> Option { + if !self.enabled { + return None; + } + self.shown += 1; + Some(format!("{}/{}", self.shown, self.total.max(self.shown))) + } +} + +/// Whether there is queued upload data a connect would ask about. +fn backlog_exists() -> bool { + if std::env::var_os("AUTTER_TEST_DB_PATH").is_some() { + return false; + } + crate::auth::notice::pending_sync_counts().total() + crate::upload_hold::held_counts().total() + > 0 +} + +/// A prompt title with its step counter. +fn titled(title: &str, counter: Option<&str>) -> String { + rail::titled(&ui::caps(), title, counter) +} + /// One onboarding step that did not complete, with the exact command that /// fixes it. #[derive(Debug, Clone, PartialEq, Eq)] @@ -252,7 +338,7 @@ fn restart_daemon_for_mode_change() -> Result<(), String> { } /// The interactive connected-vs-local choice, as an arrow-key selector. -fn prompt_mode_choice() -> bool { +fn prompt_mode_choice(counter: Option<&str>) -> bool { let items = [ SelectItem::new( "Connected — link this machine to your Autter org (recommended)", @@ -263,65 +349,79 @@ fn prompt_mode_choice() -> bool { "No account needed. Switch anytime with `autter onboard --connect`.", ), ]; - ui::select("How should Autter run on this machine?", &items, 0) == 0 + ui::select( + &titled("How should Autter run on this machine?", counter), + &items, + 0, + ) == 0 } -/// Ask whether to enable anonymous telemetry + error tracking and persist the -/// choice into `telemetry_oss` ("on"/"off"). Returns whether telemetry ended up -/// enabled. -fn configure_telemetry(cfg: &mut config::FileConfig, flag: Option) -> bool { - let local_log = config::id_file_path() +fn telemetry_log_path() -> String { + config::id_file_path() .and_then(|p| p.parent().map(|d| d.join("telemetry.log"))) .map(|p| p.display().to_string()) - .unwrap_or_else(|| "~/.autter/internal/telemetry.log".to_string()); + .unwrap_or_else(|| "~/.autter/internal/telemetry.log".to_string()) +} - eprintln!(); - eprintln!(" {DIM}One last thing — anonymous usage analytics and error reporting."); - eprintln!(" No personal data is ever collected: no code, prompts, file paths,"); - eprintln!(" repo names, usernames, or IP addresses. Only a random install ID,"); - eprintln!(" coarse device info (OS, CPU architecture, core count) and the"); - eprintln!(" Autter version. Everything sent is mirrored to a local log:"); - eprintln!(" {local_log}{RESET}"); - eprintln!(); +/// Ask whether to enable anonymous telemetry + error tracking and persist the +/// choice into `telemetry_oss` ("on"/"off"). Returns whether telemetry ended up +/// enabled. +fn configure_telemetry( + cfg: &mut config::FileConfig, + flag: Option, + steps: &mut Steps, +) -> bool { + let local_log = telemetry_log_path(); let enabled = match flag { - Some(value) => value, + Some(value) => { + rail::print_step(telemetry_summary(value), None); + value + } + None if ui::is_interactive() => { + let yes_hint = format!( + "Sends OS, CPU architecture, core count, the Autter version and a random install ID. \ + Never code, prompts, file paths, repo names, usernames or IP addresses. \ + Everything sent is mirrored to {local_log}." + ); + let items = [ + SelectItem::new("Yes — help improve Autter", &yes_hint), + SelectItem::new( + "No — send nothing", + "Change anytime with `autter telemetry on` or `autter telemetry off`.", + ), + ]; + let counter = steps.next(); + ui::select( + &titled( + "Share anonymous usage data and error reports?", + counter.as_deref(), + ), + &items, + 0, + ) == 0 + } None => { - if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() { - let items = [ - SelectItem::new( - "Enable — help improve Autter", - "Audit every event in the local log, or turn it off anytime with `autter telemetry off`.", - ), - SelectItem::new( - "Disable — send nothing", - "You can opt in later with `autter telemetry on`.", - ), - ]; - ui::select("Enable anonymous telemetry and error tracking?", &items, 0) == 0 - } else { - // Non-interactive and no explicit flag: default to disabled so we - // never collect data the user didn't actively agree to. - false - } + // Non-interactive and no explicit flag: default to disabled so we + // never collect data the user didn't actively agree to. + rail::print_step(telemetry_summary(false), None); + false } }; cfg.telemetry_oss = Some(if enabled { "on" } else { "off" }.to_string()); + if enabled { + rail::print_note(&format!("Everything sent is mirrored to {local_log}")); + } + enabled +} - eprintln!(); +fn telemetry_summary(enabled: bool) -> &'static str { if enabled { - eprintln!( - " {GREEN}\u{2713}{RESET} Telemetry enabled. Thank you — you can review what's sent at:" - ); - eprintln!(" {DIM}{local_log}{RESET}"); + "Anonymous telemetry on" } else { - eprintln!( - " {GREEN}\u{2713}{RESET} Telemetry disabled. Nothing will be collected or sent." - ); + "Anonymous telemetry off \u{00B7} nothing is collected or sent" } - - enabled } /// Send a single anonymous install/onboard event to PostHog (best-effort). @@ -337,8 +437,9 @@ fn record_install_event(connected: bool) { } } -/// Configure local-only mode and explain the trade-off. -fn setup_local(cfg: &mut config::FileConfig) { +/// Configure local-only mode and explain the trade-off. `announce` adds the +/// step line when no mode question was shown (flags, or a fallback). +fn setup_local(cfg: &mut config::FileConfig, announce: bool) { // Keep everything on the machine: prompts only in local SQLite, attribution // in local git notes, nothing uploaded. cfg.prompt_storage = Some("local".to_string()); @@ -347,17 +448,14 @@ fn setup_local(cfg: &mut config::FileConfig) { backend_url: None, }); - eprintln!(); - eprintln!("{GREEN}{BOLD}\u{2713} Autter is set up in local mode.{RESET}"); - eprintln!(); - eprintln!(" \u{2022} Attribution, blame, and stats run entirely on your machine."); - eprintln!(" \u{2022} Data stays in this repo's git notes (refs/notes/ai) and local storage."); - eprintln!(" \u{2022} Nothing is uploaded to the Autter platform."); - eprintln!(); - eprintln!(" {DIM}Heads up: in local mode you will NOT have access to the Autter platform's"); - eprintln!(" detailed prompt usage and team/user usage dashboards."); - eprintln!(); - eprintln!(" Run `autter onboard --connect` anytime to link your Autter account.{RESET}"); + if announce { + rail::print_step("Local mode \u{00B7} everything stays on this machine", None); + } + rail::print_note( + "Attribution, blame and stats run on this machine, in git notes (refs/notes/ai).\n\ + Nothing is uploaded, so the platform's prompt-usage and team dashboards aren't included.\n\ + Connect anytime with `autter onboard --connect`.", + ); // Data queued while this machine was connected (or by older versions) // stays on disk. Local mode never uploads it; reconnecting asks before @@ -366,37 +464,60 @@ fn setup_local(cfg: &mut config::FileConfig) { let pending = crate::auth::notice::pending_sync_counts(); let held = crate::upload_hold::held_counts(); if pending.total() + held.total() > 0 { - eprintln!(); - eprintln!( - " {DIM}Found {} records queued for upload earlier. They will not be", + rail::print_note(&format!( + "Found {} records queued for upload earlier. They won't be uploaded in local mode.\n\ + Delete them with `autter sync purge --force`.", pending.total() + held.total() - ); - eprintln!( - " uploaded in local mode. Delete them with `autter sync purge --force`.{RESET}" - ); + )); } } } -/// Log the user in (if needed) and configure connected mode. On a failed -/// login, falls back to local mode and returns the error so the closing -/// summary can report it. -fn setup_connected(cfg: &mut config::FileConfig, already_logged_in: bool) -> Result<(), String> { +/// Log the user in (if needed) and configure connected mode. Skipping +/// sign-in sets up local mode; a failed sign-in does too, and returns the +/// error so the closing summary can report it. +fn setup_connected( + cfg: &mut config::FileConfig, + already_logged_in: bool, + announce: bool, + steps: &mut Steps, +) -> Result<(), String> { if !already_logged_in { - eprintln!(); - eprintln!("Connecting to the Autter platform..."); - if let Err(e) = run_device_login() { - eprintln!(); - eprintln!("\u{2717} Could not connect: {e}"); + let interactive = ui::is_interactive(); + if !interactive { eprintln!(); - crate::commands::login::print_pat_fallback_instructions(); - // `autter login` alone does not switch modes: local mode never - // uploads, so the retry has to go through onboarding. - eprintln!( - " Setting up local mode for now \u{2014} run `autter onboard --connect` to retry." - ); - setup_local(cfg); - return Err(e.to_string()); + eprintln!("Connecting to the Autter platform..."); + } + let counter = steps.next(); + let outcome = run_device_login_with(LoginUi { + counter: counter.as_deref(), + decline: Decline::ContinueLocally, + }); + match outcome { + Ok(LoginOutcome::LoggedIn | LoginOutcome::AlreadyLoggedIn) => {} + Ok(LoginOutcome::Declined { after_error: None }) => { + setup_local(cfg, true); + return Ok(()); + } + Ok(LoginOutcome::Declined { + after_error: Some(e), + }) => { + setup_local(cfg, true); + return Err(e); + } + Err(e) => { + eprintln!(); + eprintln!("\u{2717} Could not connect: {e}"); + eprintln!(); + crate::commands::login::print_pat_fallback_instructions(); + // `autter login` alone does not switch modes: local mode never + // uploads, so the retry has to go through onboarding. + eprintln!( + " Setting up local mode for now \u{2014} run `autter onboard --connect` to retry." + ); + setup_local(cfg, true); + return Err(e); + } } } @@ -409,102 +530,115 @@ fn setup_connected(cfg: &mut config::FileConfig, already_logged_in: bool) -> Res backend_url: None, }); - let status = collect_auth_status(); - let who = status - .email - .or(status.name) - .unwrap_or_else(|| "your Autter account".to_string()); - - eprintln!(); - eprintln!("{GREEN}{BOLD}\u{2713} Connected to the Autter platform as {who}.{RESET}"); - eprintln!(); - eprintln!(" \u{2022} Attribution is still written locally to git notes (refs/notes/ai)."); - eprintln!(" \u{2022} Prompt and usage data also sync to your org's Autter dashboard,"); - eprintln!(" so you get detailed prompt usage and team/user analytics."); - eprintln!(); - eprintln!(" {DIM}Manage your account with `autter whoami` / `autter logout`.{RESET}"); + // A fresh sign-in already showed who is signed in. + if already_logged_in && announce { + let status = collect_auth_status(); + let who = status + .email + .or(status.name) + .unwrap_or_else(|| "your Autter account".to_string()); + rail::print_step(&format!("Connected as {who}"), None); + } + rail::print_note( + "Attribution is still written locally to git notes (refs/notes/ai).\n\ + Prompt and usage data also sync to your org's Autter dashboard.", + ); Ok(()) } fn print_welcome() { let version = env!("CARGO_PKG_VERSION"); eprintln!(); - eprintln!(" {BOLD}Welcome to Autter{RESET} \u{1F9A6} {DIM}v{version}{RESET}"); + ui::Header::new( + "Welcome to Autter", + format!("v{version}"), + "Setup \u{00B7} about a minute", + ui::header::display_cwd(), + ) + .print(); eprintln!(); eprintln!(" Autter records which lines of your code were written by AI \u{2014} tied to"); eprintln!(" the agent, model, and prompts behind them \u{2014} right in Git itself."); eprintln!(); - eprintln!(" {DIM}Setup takes about a minute: two quick choices and you're done.{RESET}"); - eprintln!(); } -/// Closing summary. With no problems: the green "all set" message plus the -/// first commands worth trying. With problems: a yellow "Set up with -/// warnings" summary listing each problem and the exact command that fixes -/// it, and no claim that everything is ready. -fn render_finished(connected: bool, report: &SetupReport, color: bool) -> String { - use std::fmt::Write as _; - let paint = |code: &'static str| if color { code } else { "" }; - let (green, yellow, bold, cyan, dim, reset) = ( - paint(GREEN), - paint(YELLOW), - paint(BOLD), - paint(CYAN), - paint(DIM), - paint(RESET), - ); - let mut out = String::new(); - let _ = writeln!(out); +/// Closing summary. With no problems: "You're all set!" plus the first +/// commands worth trying. With problems: "Set up with warnings", each problem +/// with the exact command that fixes it, and no claim that everything is +/// ready. +fn render_finished(connected: bool, report: &SetupReport, caps: &TermCaps) -> String { + let bar = caps.paint("90", caps.glyphs().bar); + let cyan = |text: &str| caps.paint("36", text); + let mut out = rail::gap(caps); if report.is_clean() { - let _ = writeln!(out, "{green}{bold}\u{2713} You're all set!{reset}"); - let _ = writeln!(out); - let _ = writeln!( - out, - " Just keep coding \u{2014} authorship is recorded automatically on every commit." - ); + out.push_str(&rail::end( + caps, + &format!( + "{} Authorship is recorded automatically on every commit.", + caps.paint("1;32", "You're all set!") + ), + )); } else { - let _ = writeln!( - out, - "{yellow}{bold}\u{26a0} Set up with warnings{reset} \u{2014} {} step{} did not complete:", - report.problems.len(), - if report.problems.len() == 1 { "" } else { "s" } - ); - let _ = writeln!(out); + let count = report.problems.len(); + out.push_str(&rail::warn( + caps, + &format!( + "Set up with warnings \u{2014} {count} step{} did not complete:", + if count == 1 { "" } else { "s" } + ), + )); for problem in &report.problems { - let _ = writeln!(out, " {yellow}\u{2022}{reset} {}", problem.what); - let _ = writeln!(out, " Fix: {cyan}{}{reset}", problem.fix); + let mut lines = problem.what.lines(); + if let Some(first) = lines.next() { + out.push_str(&format!( + "{bar} {} {first}\n", + caps.paint("33", "\u{2022}") + )); + } + for line in lines { + out.push_str(&format!("{bar} {line}\n")); + } + out.push_str(&format!("{bar} Fix: {}\n", cyan(&problem.fix))); } - let _ = writeln!(out); - let _ = writeln!( - out, - " Then run {cyan}autter doctor{reset} to confirm everything works." - ); + out.push_str(&rail::end( + caps, + &format!( + "Then run {} to confirm everything works.", + cyan("autter doctor") + ), + )); } - let _ = writeln!(out); - let _ = writeln!(out, " Try it on any repo:"); - let _ = writeln!( - out, - " {cyan}autter stats{reset} {dim}AI vs human share of your latest commit{reset}" - ); - let _ = writeln!( - out, - " {cyan}autter blame {reset} {dim}who \u{2014} you or an agent \u{2014} wrote each line{reset}" - ); - let _ = writeln!( - out, - " {cyan}autter log{reset} {dim}git log with AI authorship stats{reset}" - ); + + let command = |name: &str, what: &str| { + format!( + " {}{} {}\n", + cyan(name), + " ".repeat(20usize.saturating_sub(name.len())), + caps.paint("2", what) + ) + }; + out.push('\n'); + out.push_str(" Try it on any repo:\n"); + out.push_str(&command( + "autter stats", + "AI vs human share of your latest commit", + )); + out.push_str(&command( + "autter blame ", + "who \u{2014} you or an agent \u{2014} wrote each line", + )); + out.push_str(&command("autter log", "git log with AI authorship stats")); if connected { - let _ = writeln!( - out, - " {cyan}autter dash{reset} {dim}open your personal dashboard{reset}" - ); + out.push_str(&command("autter dash", "open your personal dashboard")); } - let _ = writeln!(out); - let _ = writeln!( - out, - " {dim}All commands: `autter help` \u{2022} Docs: https://autter.dev/docs{reset}" - ); + out.push('\n'); + out.push_str(&format!( + " {}\n", + caps.paint( + "2", + "All commands: `autter help` \u{2022} Docs: https://autter.dev/docs" + ) + )); out } @@ -552,68 +686,87 @@ fn print_telemetry_summary(cfg: &config::FileConfig) { let enabled = cfg.telemetry_oss.as_deref() != Some("off"); eprintln!(); if enabled { - let local_log = config::id_file_path() - .and_then(|p| p.parent().map(|d| d.join("telemetry.log"))) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "~/.autter/internal/telemetry.log".to_string()); eprintln!("Anonymous telemetry is ON. Everything sent is logged at:"); - eprintln!(" {local_log}"); + eprintln!(" {}", telemetry_log_path()); } else { eprintln!("Anonymous telemetry is OFF."); } } /// Install IDE hooks + global git trace2 + start the daemon after consent. -/// Returns whether the integrations were installed, or the install error. +/// Returns whether any integrations were installed, or the install error. /// -/// Only `assume_yes` (`--yes`) applies them without asking. Interactive runs -/// ask; non-interactive runs without `--yes` leave them off. -fn apply_system_integrations_with_consent(assume_yes: bool) -> Result { +/// Only `assume_yes` (`--yes`) applies them all without asking. Interactive +/// runs list the detected tools with everything checked, so Enter installs +/// everything and unchecking all is "not now"; non-interactive runs without +/// `--yes` leave them off. +fn apply_system_integrations_with_consent( + assume_yes: bool, + steps: &mut Steps, +) -> Result { if std::env::var_os("AUTTER_TEST_DB_PATH").is_some() { return Ok(false); } - let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); - let apply = match integration_decision(assume_yes, interactive) { - IntegrationDecision::Apply => true, - IntegrationDecision::Ask => { - eprintln!(); - eprintln!("{BOLD}System integrations{RESET}"); - eprintln!( - " Autter can install IDE/agent hooks, write git trace2 settings, and start a" - ); - eprintln!(" background service so terminal commits get authorship notes."); - eprintln!(); - let items = [ - SelectItem::new( - "Yes — install hooks, configure git, and start the background service", - "Required for attribution on terminal commits and AI editor sessions.", - ), - SelectItem::new( - "Not now — I'll run `autter install --system` later", - "Binary stays installed; attribution will not capture until you opt in.", - ), - ]; - matches!(ui::select("Install system integrations?", &items, 0), 0) + match integration_decision(assume_yes, ui::is_interactive()) { + IntegrationDecision::Apply => { + rail::print_step("Installing integrations", None); + let _rail = ui::progress::on_rail(); + crate::commands::install_hooks::run(&["--system".to_string()]) + .map(|_| true) + .map_err(|e| e.to_string()) } IntegrationDecision::Skip => { // Non-interactive without `--yes`: leave integrations off. eprintln!( "Skipping system integrations (non-interactive, no --yes). Run `autter install --system` to install them." ); - false + Ok(false) } - }; + IntegrationDecision::Ask => { + let spinner = + ui::progress::Spinner::new("Looking for coding tools on this machine\u{2026}"); + let tools = crate::commands::install_hooks::detect_tools(); + spinner.stop(); + + let mut items: Vec = tools + .iter() + .map(|tool| { + SelectItem::new( + &tool.name, + "Hooks record the edits this tool's agent makes.", + ) + }) + .collect(); + items.push(SelectItem::new( + "Git capture and background service", + "Global git trace2 settings and a background service, so commits from any terminal get authorship notes.", + )); + let system_item = items.len() - 1; + let all: Vec = (0..items.len()).collect(); + + let counter = steps.next(); + let question = format!( + "{}\n{}", + titled("Install integrations?", counter.as_deref()), + ui::caps().paint("2", "space toggles \u{00B7} enter confirms") + ); + let chosen = ui::multiselect(&question, &items, &all); + if chosen.is_empty() { + rail::print_note("Skipped. Run `autter install --system` when you're ready."); + return Ok(false); + } - if !apply { - return Ok(false); + let tool_ids: Vec = chosen + .iter() + .filter(|&&i| i < tools.len()) + .map(|&i| tools[i].id.clone()) + .collect(); + crate::commands::install_hooks::run_for_tools(&tool_ids, chosen.contains(&system_item)) + .map(|_| true) + .map_err(|e| e.to_string()) + } } - - eprintln!(); - eprintln!("Installing IDE/agent hooks…"); - crate::commands::install_hooks::run(&["--system".to_string()]) - .map(|_| true) - .map_err(|e| e.to_string()) } /// Parsed `autter onboard` flags. @@ -671,20 +824,20 @@ fn hold_backlog_before_connecting() -> Result<(), String> { /// without explicit consent: an interactive "Import", or `--yes` in a /// non-interactive run. Otherwise it stays held, and the commands to import /// or delete it are printed. -fn offer_historical_sync_consent(assume_yes: bool) -> Result<(), String> { +fn offer_historical_sync_consent(assume_yes: bool, steps: &mut Steps) -> Result<(), String> { let held = crate::upload_hold::held_counts(); if held.total() == 0 { return Ok(()); } - eprintln!(); - eprintln!("{BOLD}Previously collected sessions{RESET}"); - eprintln!(" Found local data queued for upload: {}.", held.summary()); - eprintln!(" It was queued before you connected this machine (or by an older autter) and"); - eprintln!(" may include sessions recorded in local mode. It is held and has not been sent."); - eprintln!(); + rail::print_note(&format!( + "Found local data queued for upload: {}.\n\ + It was queued before you connected this machine (or by an older autter) and may include\n\ + sessions recorded in local mode. It is held and has not been sent.", + held.summary() + )); - let decision = if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() { + let decision = if ui::is_interactive() { let items = [ SelectItem::new( "Import — upload this backlog to your Autter org", @@ -695,7 +848,13 @@ fn offer_historical_sync_consent(assume_yes: bool) -> Result<(), String> { "Runs `autter sync purge`. Nothing pre-dating this connect is uploaded.", ), ]; - if ui::select("What should we do with the backlog?", &items, 0) == 0 { + let counter = steps.next(); + if ui::select( + &titled("What should we do with the backlog?", counter.as_deref()), + &items, + 0, + ) == 0 + { BacklogDecision::Import } else { BacklogDecision::Discard @@ -709,21 +868,23 @@ fn offer_historical_sync_consent(assume_yes: bool) -> Result<(), String> { match decision { BacklogDecision::Import => { let released = crate::upload_hold::release_all()?; - eprintln!( - " Backlog released ({} records); it uploads in the background after setup.", + rail::print_note(&format!( + "Backlog released ({} records); it uploads in the background after setup.", released.total() - ); + )); } BacklogDecision::Discard => { let summary = crate::commands::sync::purge_sync_queues().map_err(|e| e.to_string())?; - eprintln!("{GREEN}\u{2713} Cleared local upload queue ({summary}).{RESET}"); + rail::print_step(&format!("Cleared local upload queue ({summary})"), None); } BacklogDecision::KeepHeld => { - eprintln!( - " Kept on this machine and NOT uploaded (non-interactive run without --yes)." - ); - eprintln!(" Upload it: {}", crate::upload_hold::IMPORT_COMMAND); - eprintln!(" Delete it: {}", crate::upload_hold::PURGE_COMMAND); + rail::print_note(&format!( + "Kept on this machine and NOT uploaded (non-interactive run without --yes).\n\ + Upload it: {}\n\ + Delete it: {}", + crate::upload_hold::IMPORT_COMMAND, + crate::upload_hold::PURGE_COMMAND + )); } } Ok(()) @@ -747,11 +908,21 @@ fn is_help_request(args: &[String]) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::ui::theme::{Charset, ColorLevel}; fn args(flags: &[&str]) -> Vec { flags.iter().map(|s| s.to_string()).collect() } + fn plain() -> TermCaps { + TermCaps { + tty: false, + color: ColorLevel::None, + charset: Charset::Unicode, + width: 80, + } + } + #[test] fn choosing_a_mode_is_not_consent_to_system_integrations() { for flags in [ @@ -790,9 +961,11 @@ mod tests { #[test] fn clean_setup_ends_with_all_set() { - let text = render_finished(false, &SetupReport::default(), false); + let text = render_finished(false, &SetupReport::default(), &plain()); assert!(text.contains("You're all set!"), "{text}"); assert!(!text.contains("Set up with warnings"), "{text}"); + assert!(!text.contains("autter dash"), "{text}"); + assert!(render_finished(true, &SetupReport::default(), &plain()).contains("autter dash")); } #[test] @@ -806,7 +979,7 @@ mod tests { "Could not install hooks (denied)", "autter install --system", ); - let text = render_finished(true, &report, false); + let text = render_finished(true, &report, &plain()); assert!(!text.contains("You're all set"), "{text}"); assert!(text.contains("Set up with warnings"), "{text}"); assert!(text.contains("2 steps did not complete"), "{text}"); @@ -819,6 +992,59 @@ mod tests { assert!(text.contains("Fix: autter install --system"), "{text}"); } + #[test] + fn multi_line_problems_stay_on_the_rail() { + let mut report = SetupReport::default(); + report.problem( + "Could not connect (CLI authorization failed: denied\nNo credentials were issued or changed.)", + "autter onboard --connect", + ); + let text = render_finished(false, &report, &plain()); + assert!( + text.contains( + "│ • Could not connect (CLI authorization failed: denied\n\ + │ No credentials were issued or changed.)\n\ + │ Fix: autter onboard --connect\n" + ), + "{text}" + ); + } + + #[test] + fn steps_count_only_the_questions_asked() { + let plan = StepPlan { + ask_mode: true, + sign_in: true, + ask_telemetry: true, + ask_integrations: true, + maybe_backlog: false, + }; + assert_eq!(plan.total(true), 4); + assert_eq!(plan.total(false), 3); + + let flags_given = StepPlan { + ask_mode: false, + sign_in: false, + ask_telemetry: false, + ask_integrations: true, + maybe_backlog: true, + }; + assert_eq!(flags_given.total(true), 2); + assert_eq!(flags_given.total(false), 1); + } + + #[test] + fn step_numbers_follow_the_chosen_path() { + let mut steps = Steps::new(true, 4); + assert_eq!(steps.next().as_deref(), Some("1/4")); + steps.set_total(3); + assert_eq!(steps.next().as_deref(), Some("2/3")); + assert_eq!(steps.next().as_deref(), Some("3/3")); + + let mut quiet = Steps::new(false, 4); + assert_eq!(quiet.next(), None); + } + #[test] fn help_flag_is_detected_in_any_position() { assert!(is_help_request(&args(&["--help"]))); diff --git a/src/mdm/spinner.rs b/src/mdm/spinner.rs index dc88e97..89d51e9 100644 --- a/src/mdm/spinner.rs +++ b/src/mdm/spinner.rs @@ -1,71 +1,7 @@ use crate::commands::arg_parser::paint; -use indicatif::{ProgressBar, ProgressStyle}; -/// Spinner UI component for showing progress -pub struct Spinner { - pb: ProgressBar, -} - -impl Spinner { - pub fn new(message: &str) -> Self { - let pb = ProgressBar::new_spinner(); - pb.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.green} {msg}") - .unwrap() - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), - ); - pb.set_message(message.to_string()); - pb.enable_steady_tick(std::time::Duration::from_millis(100)); - - Self { pb } - } - - pub fn start(&self) { - // Spinner starts automatically when created - } - - #[allow(dead_code)] - pub fn update_message(&self, message: &str) { - self.pb.set_message(message.to_string()); - } - - #[allow(dead_code)] - pub async fn wait_for(&self, duration_ms: u64) { - smol::Timer::after(std::time::Duration::from_millis(duration_ms)).await; - } - - /// Clear the spinner without printing a status line, for callers that - /// print their own outcome (e.g. to stderr instead of stdout). - pub fn stop(&self) { - self.pb.finish_and_clear(); - } - - pub fn success(&self, message: &str) { - // Clear spinner and show success with green checkmark and bold green text - self.pb.finish_and_clear(); - println!("{}", paint("1;32", &format!("✓ {}", message))); - } - - pub fn pending(&self, message: &str) { - // Clear spinner and show pending with yellow warning triangle and bold yellow text - self.pb.finish_and_clear(); - println!("{}", paint("1;33", &format!("⚠ {}", message))); - } - - pub fn error(&self, message: &str) { - // Clear spinner and show error with red X and bold red text - self.pb.finish_and_clear(); - println!("{}", paint("1;31", &format!("✗ {}", message))); - } - - #[allow(dead_code)] - pub fn skipped(&self, message: &str) { - // Clear spinner and show skipped with gray circle and gray text - self.pb.finish_and_clear(); - println!("{}", paint("90", &format!("○ {}", message))); - } -} +/// The spinner now lives with the rest of the terminal UI. +pub use crate::ui::progress::Spinner; /// Print a formatted diff using colors pub fn print_diff(diff_text: &str) { diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index dca977b..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! Small interactive terminal prompts shared by user-facing commands. -//! -//! Hand-rolled on crossterm (already a dependency of the `autter log` pager) -//! rather than pulling in a prompt crate: all we need is a single-choice -//! arrow-key selector with graceful fallbacks. Everything renders on stderr -//! so command output on stdout stays scriptable. - -use std::io::{IsTerminal, Write}; -use std::time::{Duration, Instant}; - -use crossterm::{ - cursor, - event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, - execute, - terminal::{self, ClearType}, -}; - -pub const BOLD: &str = "\x1b[1m"; -pub const DIM: &str = "\x1b[2m"; -pub const CYAN: &str = "\x1b[36m"; -pub const GREEN: &str = "\x1b[32m"; -pub const YELLOW: &str = "\x1b[33m"; -pub const RESET: &str = "\x1b[0m"; - -/// One selectable row: a short label plus a dimmed one-line explanation. -pub struct SelectItem { - pub label: String, - pub hint: String, -} - -impl SelectItem { - pub fn new(label: &str, hint: &str) -> Self { - Self { - label: label.to_string(), - hint: hint.to_string(), - } - } -} - -/// Ask the user to pick one of `items` with the arrow keys (j/k and 1-9 also -/// work), confirming with Enter. Returns the chosen index. -/// -/// Falls back to a numbered "type 1-N" prompt when raw mode can't be enabled, -/// and to `default` when stdin/stderr aren't terminals — callers that already -/// gate on `is_terminal()` keep their existing non-interactive behavior. -pub fn select(question: &str, items: &[SelectItem], default: usize) -> usize { - if items.is_empty() { - return 0; - } - let default = default.min(items.len() - 1); - if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { - return default; - } - match select_raw(question, items, default) { - Some(choice) => choice, - None => select_numbered(question, items, default), - } -} - -/// Restores the terminal even if the selector errors out part-way. -struct RawModeGuard; - -impl RawModeGuard { - fn enable() -> Option { - terminal::enable_raw_mode().ok()?; - Some(Self) - } -} - -impl Drop for RawModeGuard { - fn drop(&mut self) { - let _ = terminal::disable_raw_mode(); - let _ = execute!(std::io::stderr(), cursor::Show); - } -} - -/// The arrow-key selector. Returns `None` when raw mode is unavailable or a -/// terminal write fails, so the caller can fall back to line input. -fn select_raw(question: &str, items: &[SelectItem], default: usize) -> Option { - let guard = RawModeGuard::enable()?; - let mut err = std::io::stderr(); - execute!(err, cursor::Hide).ok()?; - - // In raw mode `\n` no longer implies a carriage return, so lines are - // written explicitly with `\r\n`. - write!(err, "{BOLD}{question}{RESET}\r\n").ok()?; - - // Each item renders as two lines (label + hint), plus one key-hint footer. - let block_lines = (items.len() * 2 + 1) as u16; - let mut selected = default; - draw_items(&mut err, items, selected).ok()?; - - loop { - let key = match event::read().ok()? { - Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { - key - } - _ => continue, - }; - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - selected = if selected == 0 { - items.len() - 1 - } else { - selected - 1 - }; - } - KeyCode::Down | KeyCode::Tab | KeyCode::Char('j') => { - selected = (selected + 1) % items.len(); - } - KeyCode::Char(c @ '1'..='9') => { - let idx = c as usize - '1' as usize; - if idx < items.len() { - selected = idx; - } - } - KeyCode::Enter | KeyCode::Char(' ') => break, - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - execute!( - err, - cursor::MoveUp(block_lines + 1), - terminal::Clear(ClearType::FromCursorDown) - ) - .ok()?; - drop(guard); - eprintln!("Cancelled."); - std::process::exit(130); - } - _ => {} - } - execute!( - err, - cursor::MoveUp(block_lines), - terminal::Clear(ClearType::FromCursorDown) - ) - .ok()?; - draw_items(&mut err, items, selected).ok()?; - } - - // Collapse the whole block (question included) into one confirmation line. - execute!( - err, - cursor::MoveUp(block_lines + 1), - terminal::Clear(ClearType::FromCursorDown) - ) - .ok()?; - drop(guard); - eprintln!( - "{GREEN}✓{RESET} {BOLD}{question}{RESET} {DIM}·{RESET} {}", - items[selected].label - ); - Some(selected) -} - -fn draw_items(err: &mut impl Write, items: &[SelectItem], selected: usize) -> std::io::Result<()> { - for (i, item) in items.iter().enumerate() { - if i == selected { - write!(err, " {CYAN}{BOLD}❯ {}{RESET}\r\n", item.label)?; - } else { - write!(err, " {}\r\n", item.label)?; - } - write!(err, " {DIM}{}{RESET}\r\n", item.hint)?; - } - write!(err, " {DIM}↑/↓ move · enter confirm{RESET}\r\n")?; - err.flush() -} - -/// Line-input fallback for terminals where raw mode isn't available. -fn select_numbered(question: &str, items: &[SelectItem], default: usize) -> usize { - eprintln!("{BOLD}{question}{RESET}"); - for (i, item) in items.iter().enumerate() { - eprintln!(" {}) {} {DIM}— {}{RESET}", i + 1, item.label, item.hint); - } - eprint!("Choose [1-{}] (default {}): ", items.len(), default + 1); - let _ = std::io::stderr().flush(); - - let mut input = String::new(); - if std::io::stdin().read_line(&mut input).is_err() { - return default; - } - match input.trim().parse::() { - Ok(n) if (1..=items.len()).contains(&n) => n - 1, - _ => default, - } -} - -/// How long a yes/no prompt waits for a keystroke before giving up and taking -/// the default. Callers run these from git's post-command path, so a prompt -/// must never be able to wedge a `git` invocation indefinitely. -const CONFIRM_TIMEOUT: Duration = Duration::from_secs(60); - -/// Yes/no prompt in the style of a shell framework's update prompt -/// (`[Y/n]`), answered with a single keystroke: `y`/`n`, Enter for the -/// default, Esc or Ctrl-C to decline. -/// -/// Returns `default` without reading stdin when stdin or stderr isn't a -/// terminal, when raw mode can't be enabled, or when nothing is typed within -/// [`CONFIRM_TIMEOUT`]. Unlike [`select`], a decline never exits the process: -/// this runs after git has already succeeded, so a non-zero exit here would -/// misreport git's status. -pub fn confirm(question: &str, default: bool) -> bool { - confirm_with_timeout(question, default, CONFIRM_TIMEOUT) -} - -fn confirm_with_timeout(question: &str, default: bool, timeout: Duration) -> bool { - if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { - return default; - } - let Some(guard) = RawModeGuard::enable() else { - return default; - }; - - let mut err = std::io::stderr(); - let hint = if default { "[Y/n]" } else { "[y/N]" }; - if write!(err, "{BOLD}{question}{RESET} {DIM}{hint}{RESET}").is_err() || err.flush().is_err() { - drop(guard); - return default; - } - - let deadline = Instant::now() + timeout; - let mut answer = None; - let mut timed_out = false; - while answer.is_none() { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - timed_out = true; - break; - } - match event::poll(remaining) { - Ok(true) => {} - Ok(false) => { - timed_out = true; - break; - } - Err(_) => break, - } - let Ok(Event::Key(key)) = event::read() else { - continue; - }; - if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { - continue; - } - answer = match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => Some(true), - KeyCode::Char('n') | KeyCode::Char('N') => Some(false), - KeyCode::Enter => Some(default), - KeyCode::Esc => Some(false), - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(false), - _ => None, - }; - } - - let answer = answer.unwrap_or(default); - // Collapse the in-progress question into a single answered line. - let _ = execute!( - err, - cursor::MoveToColumn(0), - terminal::Clear(ClearType::CurrentLine) - ); - drop(guard); - - let (mark, label) = if answer { - (format!("{GREEN}✓{RESET}"), "yes") - } else { - (format!("{DIM}·{RESET}"), "no") - }; - let suffix = if timed_out { - format!(" {DIM}(no answer, assuming {label}){RESET}") - } else { - String::new() - }; - eprintln!("{mark} {BOLD}{question}{RESET} {DIM}{label}{RESET}{suffix}"); - answer -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn select_returns_default_when_not_a_terminal() { - // Under `cargo test` stdin is not a terminal, so this exercises the - // non-interactive path. - let items = [ - SelectItem::new("a", "first"), - SelectItem::new("b", "second"), - ]; - assert_eq!(select("pick", &items, 1), 1); - } - - #[test] - fn select_clamps_default_and_handles_empty() { - let items = [SelectItem::new("only", "one")]; - assert_eq!(select("pick", &items, 5), 0); - assert_eq!(select("pick", &[], 3), 0); - } - - #[test] - fn confirm_returns_default_when_not_a_terminal() { - assert!(confirm_with_timeout("go?", true, Duration::from_millis(10))); - assert!(!confirm_with_timeout( - "go?", - false, - Duration::from_millis(10) - )); - } -} diff --git a/src/ui/clipboard.rs b/src/ui/clipboard.rs new file mode 100644 index 0000000..b0c6247 --- /dev/null +++ b/src/ui/clipboard.rs @@ -0,0 +1,46 @@ +//! Copy text to the clipboard through the terminal (OSC 52), with no +//! platform clipboard dependency. The terminal never reports whether it +//! honored the request, so callers should say "copy requested", not "copied". + +use std::io::Write; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; + +/// Ask the terminal behind stderr to put `text` on the clipboard. Returns +/// whether the request could be written. +pub fn request_copy(text: &str) -> bool { + let inside_tmux = std::env::var_os("TMUX").is_some(); + let mut err = std::io::stderr(); + err.write_all(osc52(text, inside_tmux).as_bytes()).is_ok() && err.flush().is_ok() +} + +/// The OSC 52 sequence. Inside tmux it is wrapped in tmux's passthrough so it +/// reaches the outer terminal (tmux needs `allow-passthrough` or +/// `set-clipboard on` for this to land). +fn osc52(text: &str, inside_tmux: bool) -> String { + let sequence = format!("\x1b]52;c;{}\x07", STANDARD.encode(text)); + if inside_tmux { + format!("\x1bPtmux;{}\x1b\\", sequence.replace('\x1b', "\x1b\x1b")) + } else { + sequence + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_text_as_osc52() { + assert_eq!(osc52("WDJB-MJHT", false), "\x1b]52;c;V0RKQi1NSkhU\x07"); + } + + #[test] + fn wraps_for_tmux_passthrough() { + assert_eq!( + osc52("WDJB-MJHT", true), + "\x1bPtmux;\x1b\x1b]52;c;V0RKQi1NSkhU\x07\x1b\\" + ); + } +} diff --git a/src/ui/confirm.rs b/src/ui/confirm.rs new file mode 100644 index 0000000..cf5d135 --- /dev/null +++ b/src/ui/confirm.rs @@ -0,0 +1,180 @@ +//! Single-keystroke yes/no prompt for git's post-command path. +//! +//! Hand-rolled on crossterm rather than cliclack: it must give up after a +//! deadline so a prompt can never wedge a `git` invocation, and cliclack +//! prompts have no timeout. It is drawn in the same theme. + +use std::io::{IsTerminal, Write}; +use std::time::{Duration, Instant}; + +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + execute, + terminal::{self, ClearType}, +}; + +use super::theme; + +/// How long a yes/no prompt waits for a keystroke before giving up and taking +/// the default. Callers run these from git's post-command path, so a prompt +/// must never be able to wedge a `git` invocation indefinitely. +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(60); + +/// Restores the terminal even if a hand-drawn prompt errors out part-way. +pub(super) struct RawModeGuard; + +impl RawModeGuard { + pub(super) fn enable() -> Option { + terminal::enable_raw_mode().ok()?; + Some(Self) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let _ = execute!(std::io::stderr(), cursor::Show); + } +} + +/// Yes/no prompt in the style of a shell framework's update prompt +/// (`[Y/n]`), answered with a single keystroke: `y`/`n`, Enter for the +/// default, Esc or Ctrl-C to decline. +/// +/// Returns `default` without reading stdin when stdin or stderr isn't a +/// terminal, when raw mode can't be enabled, or when nothing is typed within +/// [`CONFIRM_TIMEOUT`]. Unlike [`super::select`], a decline never exits the +/// process: this runs after git has already succeeded, so a non-zero exit +/// here would misreport git's status. +pub fn confirm(question: &str, default: bool) -> bool { + confirm_with_timeout(question, default, CONFIRM_TIMEOUT) +} + +fn confirm_with_timeout(question: &str, default: bool, timeout: Duration) -> bool { + if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { + return default; + } + let Some(guard) = RawModeGuard::enable() else { + return default; + }; + + let caps = theme::caps(); + let glyphs = caps.glyphs(); + let mut err = std::io::stderr(); + let hint = if default { "[Y/n]" } else { "[y/N]" }; + if write!( + err, + "{} {} {}", + caps.accent(glyphs.active), + caps.paint("1", question), + caps.paint("2", hint) + ) + .is_err() + || err.flush().is_err() + { + drop(guard); + return default; + } + + let deadline = Instant::now() + timeout; + let mut answer = None; + let mut timed_out = false; + while answer.is_none() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + timed_out = true; + break; + } + match event::poll(remaining) { + Ok(true) => {} + Ok(false) => { + timed_out = true; + break; + } + Err(_) => break, + } + let Ok(Event::Key(key)) = event::read() else { + continue; + }; + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + continue; + } + answer = match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => Some(true), + KeyCode::Char('n') | KeyCode::Char('N') => Some(false), + KeyCode::Enter => Some(default), + KeyCode::Esc => Some(false), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(false), + _ => None, + }; + } + + let answer = answer.unwrap_or(default); + // Collapse the in-progress question into a single answered line. + let _ = execute!( + err, + cursor::MoveToColumn(0), + terminal::Clear(ClearType::CurrentLine) + ); + drop(guard); + + eprintln!("{}", answered_line(&caps, question, answer, timed_out)); + answer +} + +fn answered_line(caps: &theme::TermCaps, question: &str, answer: bool, timed_out: bool) -> String { + let glyphs = caps.glyphs(); + let (mark, label) = if answer { + (caps.paint("32", glyphs.submit), "yes") + } else { + (caps.paint("2", glyphs.submit), "no") + }; + let suffix = if timed_out { + format!( + " {}", + caps.paint("2", &format!("(no answer, assuming {label})")) + ) + } else { + String::new() + }; + format!( + "{mark} {} {}{suffix}", + caps.paint("1", question), + caps.paint("2", &format!("· {label}")) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::theme::{Charset, ColorLevel, TermCaps}; + + #[test] + fn confirm_returns_default_when_not_a_terminal() { + assert!(confirm_with_timeout("go?", true, Duration::from_millis(10))); + assert!(!confirm_with_timeout( + "go?", + false, + Duration::from_millis(10) + )); + } + + #[test] + fn answered_line_collapses_to_one_line() { + let caps = TermCaps { + tty: true, + color: ColorLevel::None, + charset: Charset::Unicode, + width: 80, + }; + assert_eq!( + answered_line(&caps, "Update autter now?", true, false), + "◇ Update autter now? · yes" + ); + assert_eq!( + answered_line(&caps, "Update autter now?", false, true), + "◇ Update autter now? · no (no answer, assuming no)" + ); + } +} diff --git a/src/ui/header.rs b/src/ui/header.rs new file mode 100644 index 0000000..3d92b87 --- /dev/null +++ b/src/ui/header.rs @@ -0,0 +1,198 @@ +//! The brand header: the "a." logo drawn in block characters, with three +//! lines of context beside it. Shown at the top of commands that set autter +//! up or show the user's own setup — never on hot paths or piped output. + +use std::path::Path; + +use super::theme::{self, Charset, TermCaps}; + +/// Below this width the logo and text would wrap, so the header goes plain. +const MIN_WIDTH: u16 = 50; + +/// Quadrant blocks for modern terminals. +const FULL_ART: [&str; 3] = ["▗▟▀▀▙▟▌", "▐▌ ▐█▌", "▝▜▄▄▛▜▌"]; +/// Half and full blocks only, for fonts without quadrants (legacy conhost). +const LEGACY_ART: [&str; 3] = ["▄█▀▀█▄█", "█ ██", "▀█▄▄█▀█"]; +const DOT: &str = "██"; + +pub struct Header { + title: String, + subtitle: String, + lines: [String; 2], +} + +impl Header { + /// `title` renders bold and `subtitle` dimmed on the first line, beside + /// the top of the logo; `line2` and `line3` are dimmed context lines. + pub fn new( + title: impl Into, + subtitle: impl Into, + line2: impl Into, + line3: impl Into, + ) -> Self { + Self { + title: title.into(), + subtitle: subtitle.into(), + lines: [line2.into(), line3.into()], + } + } + + /// Print to stderr for the current terminal. + pub fn print(&self) { + eprint!("{}", self.render(&theme::caps())); + } + + pub fn render(&self, caps: &TermCaps) -> String { + let first = if self.subtitle.is_empty() { + caps.paint("1", &self.title) + } else { + format!( + "{} {}", + caps.paint("1", &self.title), + caps.paint("2", &self.subtitle) + ) + }; + + if !caps.tty || caps.width < MIN_WIDTH || caps.charset == Charset::Ascii { + let mut out = format!("{first}\n"); + for line in self.lines.iter().filter(|line| !line.is_empty()) { + out.push_str(&caps.paint("2", line)); + out.push('\n'); + } + return out; + } + + let art = if caps.charset == Charset::Unicode { + FULL_ART + } else { + LEGACY_ART + }; + let row = |left: String, text: &str| { + if text.is_empty() { + format!(" {}\n", left.trim_end()) + } else { + format!(" {left} {text}\n") + } + }; + let mut out = row(format!("{} ", art[0]), &first); + out.push_str(&row( + format!("{} ", art[1]), + &caps.paint("2", &self.lines[0]), + )); + out.push_str(&row( + format!("{} {}", art[2], caps.accent(DOT)), + &caps.paint("2", &self.lines[1]), + )); + out + } +} + +/// The current directory for display, with the home directory as `~`. +pub fn display_cwd() -> String { + let Ok(cwd) = std::env::current_dir() else { + return String::new(); + }; + display_path(&cwd, dirs::home_dir().as_deref()) +} + +fn display_path(path: &Path, home: Option<&Path>) -> String { + if let Some(home) = home + && let Ok(rest) = path.strip_prefix(home) + { + if rest.as_os_str().is_empty() { + return "~".to_string(); + } + return format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display()); + } + path.display().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::theme::ColorLevel; + + fn header() -> Header { + Header::new( + "Autter", + "v2.3.0", + "Connected · you@company.com · acme", + "~/code/app · capturing in 3 tools", + ) + } + + fn caps(color: ColorLevel, charset: Charset, width: u16) -> TermCaps { + TermCaps { + tty: true, + color, + charset, + width, + } + } + + #[test] + fn full_truecolor() { + insta::assert_snapshot!(header().render(&caps( + ColorLevel::TrueColor, + Charset::Unicode, + 120 + ))); + } + + #[test] + fn full_256_color() { + insta::assert_snapshot!(header().render(&caps(ColorLevel::Ansi256, Charset::Unicode, 120))); + } + + #[test] + fn full_16_color() { + insta::assert_snapshot!(header().render(&caps(ColorLevel::Ansi16, Charset::Unicode, 120))); + } + + #[test] + fn full_without_color() { + insta::assert_snapshot!(header().render(&caps(ColorLevel::None, Charset::Unicode, 120))); + } + + #[test] + fn legacy_console() { + insta::assert_snapshot!(header().render(&caps(ColorLevel::None, Charset::Legacy, 120))); + } + + #[test] + fn narrow_terminal_is_plain() { + insta::assert_snapshot!(header().render(&caps(ColorLevel::None, Charset::Unicode, 49))); + } + + #[test] + fn not_a_terminal_is_plain() { + let mut caps = caps(ColorLevel::None, Charset::Unicode, 120); + caps.tty = false; + assert_eq!( + header().render(&caps), + "Autter v2.3.0\nConnected · you@company.com · acme\n~/code/app · capturing in 3 tools\n" + ); + } + + #[test] + fn empty_lines_leave_only_the_logo() { + let text = Header::new("Autter", "", "", "").render(&caps( + ColorLevel::None, + Charset::Unicode, + 120, + )); + assert_eq!(text, " ▗▟▀▀▙▟▌ Autter\n ▐▌ ▐█▌\n ▝▜▄▄▛▜▌ ██\n"); + } + + #[test] + fn home_directory_shows_as_tilde() { + let home = Path::new("/home/me"); + assert_eq!(display_path(Path::new("/home/me"), Some(home)), "~"); + assert_eq!( + display_path(&home.join("code").join("app"), Some(home)), + format!("~{0}code{0}app", std::path::MAIN_SEPARATOR) + ); + assert_eq!(display_path(Path::new("/srv/app"), Some(home)), "/srv/app"); + assert_eq!(display_path(Path::new("/srv/app"), None), "/srv/app"); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..26f0c15 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,58 @@ +//! Interactive terminal UI shared by user-facing commands: terminal +//! detection and the theme, the brand header, the step rail, prompts, the +//! browser sign-in screen, and spinners. +//! +//! Everything renders on stderr so command output on stdout stays +//! scriptable, and every prompt returns its default without a terminal. +//! Linear flows use cliclack in autter's theme (see `theme`); call sites go +//! through this module rather than importing cliclack directly. Color goes +//! through `caps().paint(..)`, which honors `NO_COLOR` and `--no-color`. + +pub mod clipboard; +pub mod confirm; +pub mod header; +pub mod progress; +pub mod prompt; +pub mod rail; +pub mod signin; +pub mod theme; + +use std::io::IsTerminal; + +pub use confirm::confirm; +pub use header::Header; +pub use prompt::{SelectItem, multiselect, secret, select}; +pub use theme::{TermCaps, caps}; + +/// Whether prompts can be shown: both stdin and stderr must be terminals. +pub fn is_interactive() -> bool { + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() +} + +#[cfg(test)] +mod tests { + /// Setup flows color through the theme so `NO_COLOR` and `--no-color` + /// are honored; a raw escape in them bypasses both. + #[test] + fn setup_flows_have_no_raw_ansi_escapes() { + for (file, source) in [ + ("commands/login.rs", include_str!("../commands/login.rs")), + ( + "commands/onboard.rs", + include_str!("../commands/onboard.rs"), + ), + ("mdm/spinner.rs", include_str!("../mdm/spinner.rs")), + ("ui/progress.rs", include_str!("progress.rs")), + ("ui/prompt.rs", include_str!("prompt.rs")), + ("ui/confirm.rs", include_str!("confirm.rs")), + ("ui/header.rs", include_str!("header.rs")), + ("ui/rail.rs", include_str!("rail.rs")), + ("ui/signin.rs", include_str!("signin.rs")), + ] { + assert!( + !source.contains("\\x1b["), + "{file} writes a raw ANSI escape; use ui::caps().paint(..) instead" + ); + } + } +} diff --git a/src/ui/progress.rs b/src/ui/progress.rs new file mode 100644 index 0000000..0ca83f9 --- /dev/null +++ b/src/ui/progress.rs @@ -0,0 +1,148 @@ +//! Progress spinner whose outcome lines follow the stream rules: progress +//! and outcomes go to stderr for a person at a terminal, while piped stdout +//! keeps the plain one-line-per-step results scripts already read. + +use std::io::IsTerminal; +use std::sync::atomic::{AtomicBool, Ordering}; + +use indicatif::{ProgressBar, ProgressStyle}; + +use super::theme; +use crate::commands::arg_parser::paint; + +/// Whether spinners currently hang off a flow's rail (see [`on_rail`]). +static ON_RAIL: AtomicBool = AtomicBool::new(false); + +/// While the returned guard lives, spinners and their stderr outcome lines +/// are indented under the step rail, so a flow that runs another command's +/// work (onboarding installing hooks) keeps one continuous rail. +pub fn on_rail() -> RailGuard { + ON_RAIL.store(true, Ordering::Relaxed); + RailGuard +} + +pub struct RailGuard; + +impl Drop for RailGuard { + fn drop(&mut self) { + ON_RAIL.store(false, Ordering::Relaxed); + } +} + +fn rail_prefix(caps: &theme::TermCaps) -> String { + if ON_RAIL.load(Ordering::Relaxed) { + format!("{} ", caps.paint("90", caps.glyphs().bar)) + } else { + String::new() + } +} + +/// Spinner UI component for showing progress +pub struct Spinner { + pb: ProgressBar, +} + +impl Spinner { + pub fn new(message: &str) -> Self { + // indicatif draws on stderr and hides itself when that isn't a terminal. + let caps = theme::caps(); + let template = format!("{}{{spinner:.green}} {{msg}}", rail_prefix(&caps)); + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template(&template) + .unwrap() + .tick_strings(caps.glyphs().spinner), + ); + pb.set_message(message.to_string()); + pb.enable_steady_tick(std::time::Duration::from_millis(100)); + + Self { pb } + } + + pub fn start(&self) { + // Spinner starts automatically when created + } + + #[allow(dead_code)] + pub fn update_message(&self, message: &str) { + self.pb.set_message(message.to_string()); + } + + #[allow(dead_code)] + pub async fn wait_for(&self, duration_ms: u64) { + smol::Timer::after(std::time::Duration::from_millis(duration_ms)).await; + } + + /// Clear the spinner without printing a status line, for callers that + /// print their own outcome. + pub fn stop(&self) { + self.pb.finish_and_clear(); + } + + pub fn success(&self, message: &str) { + self.finish(Outcome::Success, message); + } + + pub fn pending(&self, message: &str) { + self.finish(Outcome::Pending, message); + } + + pub fn error(&self, message: &str) { + self.finish(Outcome::Error, message); + } + + #[allow(dead_code)] + pub fn skipped(&self, message: &str) { + self.finish(Outcome::Skipped, message); + } + + fn finish(&self, outcome: Outcome, message: &str) { + self.pb.finish_and_clear(); + let caps = theme::caps(); + let stdout_tty = std::io::stdout().is_terminal(); + if caps.tty { + let glyph = outcome.glyph(caps.glyphs()); + eprintln!( + "{}{}", + rail_prefix(&caps), + caps.paint(outcome.sgr(), &format!("{glyph} {message}")) + ); + } + // Unchanged stdout line for anything reading it: piped stdout, or a + // terminal on stdout when stderr is redirected. + // Its glyphs stay Unicode whatever the terminal, as they always were. + if !(stdout_tty && caps.tty) { + let glyph = outcome.glyph(&theme::UNICODE_GLYPHS); + println!("{}", paint(outcome.sgr(), &format!("{glyph} {message}"))); + } + } +} + +#[derive(Clone, Copy)] +enum Outcome { + Success, + Pending, + Error, + Skipped, +} + +impl Outcome { + fn glyph(self, glyphs: &theme::Glyphs) -> &'static str { + match self { + Outcome::Success => glyphs.ok, + Outcome::Pending => glyphs.warn, + Outcome::Error => glyphs.fail, + Outcome::Skipped => glyphs.skip, + } + } + + fn sgr(self) -> &'static str { + match self { + Outcome::Success => "1;32", + Outcome::Pending => "1;33", + Outcome::Error => "1;31", + Outcome::Skipped => "90", + } + } +} diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs new file mode 100644 index 0000000..c61950a --- /dev/null +++ b/src/ui/prompt.rs @@ -0,0 +1,156 @@ +//! Single-choice prompt, drawn by cliclack in autter's theme. + +use std::io::Write; + +use super::is_interactive; +use super::theme::{self, install_prompt_theme}; + +/// One selectable row: a short label plus a dimmed one-line explanation. +pub struct SelectItem { + pub label: String, + pub hint: String, +} + +impl SelectItem { + pub fn new(label: &str, hint: &str) -> Self { + Self { + label: label.to_string(), + hint: hint.to_string(), + } + } +} + +/// Ask the user to pick one of `items` with the arrow keys (or j/k), +/// confirming with Enter. Returns the chosen index. Esc or Ctrl-C cancels +/// and exits with status 130. +/// +/// Falls back to a numbered "type 1-N" prompt when the terminal can't be +/// put in raw mode, and to `default` when stdin/stderr aren't terminals — +/// callers that already gate on `is_terminal()` keep their existing +/// non-interactive behavior. +pub fn select(question: &str, items: &[SelectItem], default: usize) -> usize { + if items.is_empty() { + return 0; + } + let default = default.min(items.len() - 1); + if !is_interactive() { + return default; + } + + install_prompt_theme(); + let mut prompt = cliclack::select(question).initial_value(default); + for (i, item) in items.iter().enumerate() { + prompt = prompt.item(i, &item.label, &item.hint); + } + match prompt.interact() { + Ok(choice) => choice, + // The prompt has already collapsed to "Cancelled.". + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => std::process::exit(130), + Err(_) => select_numbered(question, items, default), + } +} + +/// Let the user check any of `items` (space toggles, Enter confirms), with +/// the indices in `checked` checked to start. Returns the checked indices; +/// an empty result means "none". Esc or Ctrl-C cancels (exit 130). +/// +/// Without a terminal this returns `checked` unchanged, so only pass +/// pre-checked items that are safe to apply without asking. Where raw mode +/// isn't available it asks a plain yes/no about the pre-checked set. +pub fn multiselect(question: &str, items: &[SelectItem], checked: &[usize]) -> Vec { + if items.is_empty() { + return Vec::new(); + } + if !is_interactive() { + return checked.to_vec(); + } + + install_prompt_theme(); + let mut prompt = cliclack::multiselect(question) + .required(false) + .initial_values(checked.to_vec()); + for (i, item) in items.iter().enumerate() { + prompt = prompt.item(i, &item.label, &item.hint); + } + match prompt.interact() { + Ok(chosen) => chosen, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => std::process::exit(130), + Err(_) => multiselect_line(question, items, checked), + } +} + +fn multiselect_line(question: &str, items: &[SelectItem], checked: &[usize]) -> Vec { + let caps = theme::caps(); + eprintln!("{}", caps.paint("1", question)); + for &i in checked { + eprintln!(" - {}", items[i].label); + } + eprint!("All of these? [Y/n]: "); + let _ = std::io::stderr().flush(); + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + return Vec::new(); + } + match input.trim().to_ascii_lowercase().as_str() { + "" | "y" | "yes" => checked.to_vec(), + _ => Vec::new(), + } +} + +/// Masked single-line input, for secrets such as an access token. Returns +/// `None` without a terminal, or when the user backs out with Esc. +pub fn secret(question: &str) -> Option { + if !is_interactive() { + return None; + } + install_prompt_theme(); + cliclack::password(question) + .interact() + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Line-input fallback for terminals where raw mode isn't available. +fn select_numbered(question: &str, items: &[SelectItem], default: usize) -> usize { + let caps = theme::caps(); + eprintln!("{}", caps.paint("1", question)); + for (i, item) in items.iter().enumerate() { + let hint = caps.paint("2", &format!("— {}", item.hint)); + eprintln!(" {}) {} {hint}", i + 1, item.label); + } + eprint!("Choose [1-{}] (default {}): ", items.len(), default + 1); + let _ = std::io::stderr().flush(); + + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + return default; + } + match input.trim().parse::() { + Ok(n) if (1..=items.len()).contains(&n) => n - 1, + _ => default, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_returns_default_when_not_a_terminal() { + // Under `cargo test` stdin is not a terminal, so this exercises the + // non-interactive path. + let items = [ + SelectItem::new("a", "first"), + SelectItem::new("b", "second"), + ]; + assert_eq!(select("pick", &items, 1), 1); + } + + #[test] + fn select_clamps_default_and_handles_empty() { + let items = [SelectItem::new("only", "one")]; + assert_eq!(select("pick", &items, 5), 0); + assert_eq!(select("pick", &[], 3), 0); + } +} diff --git a/src/ui/rail.rs b/src/ui/rail.rs new file mode 100644 index 0000000..85dae34 --- /dev/null +++ b/src/ui/rail.rs @@ -0,0 +1,134 @@ +//! The left rail that ties a multi-step flow together, in the same layout as +//! the cliclack prompts between them: finished steps as `◇ …`, notes as +//! `│ …`, warnings as `▲ …`, and the closing `└ …`. +//! +//! Each function renders for the given [`TermCaps`] so flows can be tested; +//! the `print_*` forms write to stderr for the current terminal. + +use super::theme::{self, TermCaps}; + +/// A finished step: `◇ text`, with a dimmed counter such as `2/4` after it. +pub fn step(caps: &TermCaps, text: &str, counter: Option<&str>) -> String { + let glyphs = caps.glyphs(); + match counter { + Some(counter) => format!( + "{} {text} {}\n", + caps.paint("32", glyphs.submit), + caps.paint("2", counter) + ), + None => format!("{} {text}\n", caps.paint("32", glyphs.submit)), + } +} + +/// Dimmed lines hanging off the rail: `│ text`. Multi-line text keeps the +/// rail on every line. +pub fn note(caps: &TermCaps, text: &str) -> String { + let bar = caps.paint("90", caps.glyphs().bar); + text.lines() + .map(|line| { + if line.is_empty() { + format!("{bar}\n") + } else { + format!("{bar} {}\n", caps.paint("2", line)) + } + }) + .collect() +} + +/// An empty rail line, to separate groups. +pub fn gap(caps: &TermCaps) -> String { + format!("{}\n", caps.paint("90", caps.glyphs().bar)) +} + +/// A step that did not complete: `▲ text` in yellow. +pub fn warn(caps: &TermCaps, text: &str) -> String { + format!( + "{} {}\n", + caps.paint("33", caps.glyphs().error), + caps.paint("33", text) + ) +} + +/// The closing line: `└ text`. +pub fn end(caps: &TermCaps, text: &str) -> String { + format!("{} {text}\n", caps.paint("90", caps.glyphs().bar_end)) +} + +/// A prompt title with its dimmed step counter, e.g. `Sign in 2/4`. +pub fn titled(caps: &TermCaps, title: &str, counter: Option<&str>) -> String { + match counter { + Some(counter) => format!("{title} {}", caps.paint("2", counter)), + None => title.to_string(), + } +} + +pub fn print_step(text: &str, counter: Option<&str>) { + eprint!("{}", step(&theme::caps(), text, counter)); +} + +pub fn print_note(text: &str) { + eprint!("{}", note(&theme::caps(), text)); +} + +pub fn print_gap() { + eprint!("{}", gap(&theme::caps())); +} + +pub fn print_warn(text: &str) { + eprint!("{}", warn(&theme::caps(), text)); +} + +pub fn print_end(text: &str) { + eprint!("{}", end(&theme::caps(), text)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::theme::{Charset, ColorLevel}; + + fn plain(charset: Charset) -> TermCaps { + TermCaps { + tty: true, + color: ColorLevel::None, + charset, + width: 80, + } + } + + #[test] + fn renders_the_rail_in_unicode() { + let caps = plain(Charset::Unicode); + let text = [ + step(&caps, "Signed in as you@company.com", Some("2/4")), + note(&caps, "First line\n\nThird line"), + warn(&caps, "Sign-in didn't finish"), + end(&caps, "You're all set!"), + ] + .concat(); + assert_eq!( + text, + "◇ Signed in as you@company.com 2/4\n\ + │ First line\n\ + │\n\ + │ Third line\n\ + ▲ Sign-in didn't finish\n\ + └ You're all set!\n" + ); + } + + #[test] + fn falls_back_to_ascii() { + let caps = plain(Charset::Legacy); + assert_eq!(step(&caps, "Done", None), "o Done\n"); + assert_eq!(note(&caps, "Hi"), "| Hi\n"); + assert_eq!(end(&caps, "Bye"), "\\ Bye\n"); + } + + #[test] + fn counter_is_optional() { + let caps = plain(Charset::Unicode); + assert_eq!(titled(&caps, "Sign in", Some("2/4")), "Sign in 2/4"); + assert_eq!(titled(&caps, "Sign in", None), "Sign in"); + } +} diff --git a/src/ui/signin.rs b/src/ui/signin.rs new file mode 100644 index 0000000..f4896a7 --- /dev/null +++ b/src/ui/signin.rs @@ -0,0 +1,304 @@ +//! The browser sign-in screen: shows the one-time code and URL, then waits +//! for approval while a background poll runs. Enter reopens the browser, `c` +//! copies the code, Esc steps away. Hand-drawn on crossterm like `confirm`, +//! because it redraws a countdown while it waits. + +use std::io::Write; +use std::sync::mpsc::{Receiver, RecvTimeoutError, TryRecvError}; +use std::time::{Duration, Instant}; + +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + execute, + terminal::{self, ClearType}, +}; + +use super::clipboard; +use super::confirm::RawModeGuard; +use super::theme::{self, TermCaps}; + +pub struct SigninScreen<'a> { + /// Prompt title, already carrying its step counter. + pub title: String, + pub code: &'a str, + pub url: &'a str, + pub browser_opened: bool, + pub expires_in: Duration, + /// What Esc does, e.g. "continue in local mode" or "cancel". + pub escape_label: &'a str, +} + +pub enum SigninEnd { + Approved(T), + /// The user pressed Esc. + Escaped, + Failed(String), +} + +/// Show the screen and wait for the poll's result on `results`. `reopen` +/// opens the browser again and reports whether it could. The screen is +/// cleared when this returns, so the caller can print the outcome in its +/// place. Ctrl-C cancels the whole command (exit 130). +pub fn wait( + screen: &SigninScreen, + results: Receiver>, + reopen: impl Fn() -> bool, +) -> SigninEnd { + let caps = theme::caps(); + let lines = static_lines(screen, &caps); + let Some(guard) = RawModeGuard::enable() else { + return wait_without_keys(&lines, screen.expires_in, results); + }; + + let mut err = std::io::stderr(); + let _ = execute!(err, cursor::Hide); + for line in &lines { + let _ = write!(err, "{line}\r\n"); + } + let width = terminal::size().map(|(cols, _)| cols).unwrap_or(caps.width); + let rows: u16 = lines.iter().map(|line| screen_rows(line, width)).sum(); + + let glyphs = caps.glyphs(); + let frames = &glyphs.spinner[..glyphs.spinner.len() - 1]; + let deadline = Instant::now() + screen.expires_in; + let mut status = String::new(); + let mut tick = 0usize; + + let end = loop { + match results.try_recv() { + Ok(Ok(value)) => break SigninEnd::Approved(value), + Ok(Err(error)) => break SigninEnd::Failed(error), + Err(TryRecvError::Disconnected) => { + break SigninEnd::Failed("sign-in stopped unexpectedly".to_string()); + } + Err(TryRecvError::Empty) => {} + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break SigninEnd::Failed("the code expired".to_string()); + } + + let frame = frames[(tick / 2) % frames.len()]; + let live = waiting_line(&caps, frame, remaining, &status); + let _ = execute!( + err, + cursor::MoveToColumn(0), + terminal::Clear(ClearType::CurrentLine) + ); + let _ = write!(err, "{live}"); + let _ = err.flush(); + tick += 1; + + if !event::poll(Duration::from_millis(100)).unwrap_or(false) { + continue; + } + let Ok(Event::Key(key)) = event::read() else { + continue; + }; + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + continue; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('c') if ctrl => { + clear(&mut err, rows); + drop(guard); + eprint!("{}", super::rail::end(&caps, "Cancelled.")); + std::process::exit(130); + } + KeyCode::Enter => { + status = if reopen() { + "browser reopened".to_string() + } else { + "couldn't open the browser".to_string() + }; + } + KeyCode::Char('c') => { + status = if clipboard::request_copy(screen.code) { + "copy requested".to_string() + } else { + "couldn't copy".to_string() + }; + } + KeyCode::Esc => break SigninEnd::Escaped, + _ => {} + } + }; + + clear(&mut err, rows); + drop(guard); + end +} + +/// Remove the live line and the static block above it. +fn clear(err: &mut std::io::Stderr, rows: u16) { + let _ = execute!( + err, + cursor::MoveToColumn(0), + terminal::Clear(ClearType::CurrentLine), + cursor::MoveUp(rows), + terminal::Clear(ClearType::FromCursorDown) + ); +} + +/// Raw mode is unavailable: print the code and URL and wait for the poll, +/// with no keys and no countdown. +fn wait_without_keys( + lines: &[String], + expires_in: Duration, + results: Receiver>, +) -> SigninEnd { + // The last line lists keys that won't work here. + for line in &lines[..lines.len() - 1] { + eprintln!("{line}"); + } + match results.recv_timeout(expires_in) { + Ok(Ok(value)) => SigninEnd::Approved(value), + Ok(Err(error)) => SigninEnd::Failed(error), + Err(RecvTimeoutError::Timeout) => SigninEnd::Failed("the code expired".to_string()), + Err(RecvTimeoutError::Disconnected) => { + SigninEnd::Failed("sign-in stopped unexpectedly".to_string()) + } + } +} + +fn static_lines(screen: &SigninScreen, caps: &TermCaps) -> Vec { + let glyphs = caps.glyphs(); + let bar = caps.paint("90", glyphs.bar); + let url = caps.paint("36", screen.url); + let browser = if screen.browser_opened { + format!("{bar} Opened {url} in your browser") + } else { + format!("{bar} Open {url} in your browser") + }; + let keys = format!( + "enter reopen browser \u{00B7} c copy code \u{00B7} esc {}", + screen.escape_label + ); + vec![ + format!("{} {}", caps.accent(glyphs.active), screen.title), + format!("{bar} Code {}", caps.paint("1", screen.code)), + browser, + bar.clone(), + format!("{bar} {}", caps.paint("2", &keys)), + ] +} + +fn waiting_line(caps: &TermCaps, frame: &str, remaining: Duration, status: &str) -> String { + let mut line = format!( + "{} Waiting for approval\u{2026} {}", + caps.accent(frame), + caps.paint("2", &format!("expires in {}", countdown(remaining))) + ); + if !status.is_empty() { + line.push_str(&caps.paint("2", &format!(" \u{00B7} {status}"))); + } + line +} + +/// `m:ss`, rounded up so the display never shows 0:00 while time remains. +fn countdown(remaining: Duration) -> String { + let secs = remaining.as_millis().div_ceil(1000); + format!("{}:{:02}", secs / 60, secs % 60) +} + +/// How many terminal rows `line` occupies at `width` columns, ignoring SGR +/// color sequences. +fn screen_rows(line: &str, width: u16) -> u16 { + let width = usize::from(width.max(1)); + let visible = visible_width(line); + visible.div_ceil(width).max(1) as u16 +} + +fn visible_width(text: &str) -> usize { + let mut count = 0; + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + // Skip "ESC [ ... m". + for c in chars.by_ref() { + if c == 'm' { + break; + } + } + } else { + count += 1; + } + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::theme::{Charset, ColorLevel}; + + fn caps(color: ColorLevel) -> TermCaps { + TermCaps { + tty: true, + color, + charset: Charset::Unicode, + width: 80, + } + } + + fn screen(opened: bool) -> SigninScreen<'static> { + SigninScreen { + title: "Sign in 2/4".to_string(), + code: "WDJB-MJHT", + url: "https://app.autter.dev/cli/authorize?code=WDJB-MJHT", + browser_opened: opened, + expires_in: Duration::from_secs(900), + escape_label: "continue in local mode", + } + } + + #[test] + fn lays_out_code_url_and_keys() { + assert_eq!( + static_lines(&screen(true), &caps(ColorLevel::None)), + [ + "◆ Sign in 2/4", + "│ Code WDJB-MJHT", + "│ Opened https://app.autter.dev/cli/authorize?code=WDJB-MJHT in your browser", + "│", + "│ enter reopen browser · c copy code · esc continue in local mode", + ] + ); + assert_eq!( + static_lines(&screen(false), &caps(ColorLevel::None))[2], + "│ Open https://app.autter.dev/cli/authorize?code=WDJB-MJHT in your browser" + ); + } + + #[test] + fn waiting_line_shows_countdown_and_status() { + let line = waiting_line( + &caps(ColorLevel::None), + "⠋", + Duration::from_secs(872), + "copy requested", + ); + assert_eq!( + line, + "⠋ Waiting for approval… expires in 14:32 · copy requested" + ); + } + + #[test] + fn countdown_rounds_up() { + assert_eq!(countdown(Duration::from_millis(59_001)), "1:00"); + assert_eq!(countdown(Duration::from_millis(500)), "0:01"); + assert_eq!(countdown(Duration::from_secs(900)), "15:00"); + } + + #[test] + fn rows_ignore_color_and_wrap_long_lines() { + let colored = caps(ColorLevel::TrueColor).accent("abcd"); + assert_eq!(visible_width(&colored), 4); + assert_eq!(screen_rows(&"x".repeat(80), 80), 1); + assert_eq!(screen_rows(&"x".repeat(81), 80), 2); + assert_eq!(screen_rows("", 80), 1); + } +} diff --git a/src/ui/snapshots/autter__ui__header__tests__full_16_color.snap b/src/ui/snapshots/autter__ui__header__tests__full_16_color.snap new file mode 100644 index 0000000..ea0f8ad --- /dev/null +++ b/src/ui/snapshots/autter__ui__header__tests__full_16_color.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/header.rs +expression: "header().render(&caps(ColorLevel::Ansi16, Charset::Unicode, 120))" +--- + ▗▟▀▀▙▟▌ Autter v2.3.0 + ▐▌ ▐█▌ Connected · you@company.com · acme + ▝▜▄▄▛▜▌ ██ ~/code/app · capturing in 3 tools diff --git a/src/ui/snapshots/autter__ui__header__tests__full_256_color.snap b/src/ui/snapshots/autter__ui__header__tests__full_256_color.snap new file mode 100644 index 0000000..ae8c23a --- /dev/null +++ b/src/ui/snapshots/autter__ui__header__tests__full_256_color.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/header.rs +expression: "header().render(&caps(ColorLevel::Ansi256, Charset::Unicode, 120))" +--- + ▗▟▀▀▙▟▌ Autter v2.3.0 + ▐▌ ▐█▌ Connected · you@company.com · acme + ▝▜▄▄▛▜▌ ██ ~/code/app · capturing in 3 tools diff --git a/src/ui/snapshots/autter__ui__header__tests__full_truecolor.snap b/src/ui/snapshots/autter__ui__header__tests__full_truecolor.snap new file mode 100644 index 0000000..2f342ce --- /dev/null +++ b/src/ui/snapshots/autter__ui__header__tests__full_truecolor.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/header.rs +expression: "header().render(&caps(ColorLevel::TrueColor, Charset::Unicode, 120))" +--- + ▗▟▀▀▙▟▌ Autter v2.3.0 + ▐▌ ▐█▌ Connected · you@company.com · acme + ▝▜▄▄▛▜▌ ██ ~/code/app · capturing in 3 tools diff --git a/src/ui/snapshots/autter__ui__header__tests__full_without_color.snap b/src/ui/snapshots/autter__ui__header__tests__full_without_color.snap new file mode 100644 index 0000000..ca43c6a --- /dev/null +++ b/src/ui/snapshots/autter__ui__header__tests__full_without_color.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/header.rs +expression: "header().render(&caps(ColorLevel::None, Charset::Unicode, 120))" +--- + ▗▟▀▀▙▟▌ Autter v2.3.0 + ▐▌ ▐█▌ Connected · you@company.com · acme + ▝▜▄▄▛▜▌ ██ ~/code/app · capturing in 3 tools diff --git a/src/ui/snapshots/autter__ui__header__tests__legacy_console.snap b/src/ui/snapshots/autter__ui__header__tests__legacy_console.snap new file mode 100644 index 0000000..2a29344 --- /dev/null +++ b/src/ui/snapshots/autter__ui__header__tests__legacy_console.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/header.rs +expression: "header().render(&caps(ColorLevel::None, Charset::Legacy, 120))" +--- + ▄█▀▀█▄█ Autter v2.3.0 + █ ██ Connected · you@company.com · acme + ▀█▄▄█▀█ ██ ~/code/app · capturing in 3 tools diff --git a/src/ui/snapshots/autter__ui__header__tests__narrow_terminal_is_plain.snap b/src/ui/snapshots/autter__ui__header__tests__narrow_terminal_is_plain.snap new file mode 100644 index 0000000..2d66441 --- /dev/null +++ b/src/ui/snapshots/autter__ui__header__tests__narrow_terminal_is_plain.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/header.rs +expression: "header().render(&caps(ColorLevel::None, Charset::Unicode, 49))" +--- +Autter v2.3.0 +Connected · you@company.com · acme +~/code/app · capturing in 3 tools diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..6d6f8ee --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,513 @@ +//! Terminal capabilities and the shared visual theme. +//! +//! Every interactive surface (prompts, spinners, the brand header) takes its +//! glyphs and colors from here, so autter looks the same everywhere and +//! degrades the same way: no color under `NO_COLOR`/`--no-color` or when +//! stderr isn't a terminal, and ASCII glyphs where Unicode won't render. + +use std::io::IsTerminal; +use std::sync::{Once, OnceLock}; + +use cliclack::{Theme, ThemeState}; +use console::Style; + +/// The brand teal of the dot in the "a." logo. +const BRAND_TEAL: (u8, u8, u8) = (46, 230, 197); +/// Nearest xterm-256 color to [`BRAND_TEAL`]. +const BRAND_TEAL_256: u8 = 43; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorLevel { + None, + Ansi16, + Ansi256, + TrueColor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Charset { + /// Modern terminal: box drawing, geometric shapes and quadrant blocks. + Unicode, + /// Legacy Windows console or Linux VT: half and full blocks only, so + /// glyphs fall back to ASCII and the logo uses its legacy variant. + Legacy, + /// No reliable Unicode at all. + Ascii, +} + +/// What the terminal behind stderr can display. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TermCaps { + pub tty: bool, + pub color: ColorLevel, + pub charset: Charset, + pub width: u16, +} + +/// Status and prompt symbols for one [`Charset`]. +pub struct Glyphs { + pub active: &'static str, + pub submit: &'static str, + pub cancel: &'static str, + pub error: &'static str, + pub bar: &'static str, + pub bar_end: &'static str, + pub radio_on: &'static str, + pub radio_off: &'static str, + pub checkbox_on: &'static str, + pub checkbox_off: &'static str, + pub mask: char, + pub ok: &'static str, + pub warn: &'static str, + pub fail: &'static str, + pub skip: &'static str, + /// Spinner frames; indicatif shows the last one when finished. + pub spinner: &'static [&'static str], +} + +pub(crate) const UNICODE_GLYPHS: Glyphs = Glyphs { + active: "◆", + submit: "◇", + cancel: "■", + error: "▲", + bar: "│", + bar_end: "└", + radio_on: "●", + radio_off: "○", + checkbox_on: "◼", + checkbox_off: "◻", + mask: '▪', + ok: "✓", + warn: "⚠", + fail: "✗", + skip: "○", + spinner: &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], +}; + +const ASCII_GLYPHS: Glyphs = Glyphs { + active: "*", + submit: "o", + cancel: "x", + error: "!", + bar: "|", + bar_end: "\\", + radio_on: ">", + radio_off: " ", + checkbox_on: "[x]", + checkbox_off: "[ ]", + mask: '*', + ok: "OK", + warn: "!", + fail: "x", + skip: "-", + spinner: &["|", "/", "-", "\\", "|"], +}; + +impl TermCaps { + /// Detect the capabilities of stderr in this process. + pub fn detect() -> Self { + let tty = std::io::stderr().is_terminal(); + let colors_on = crate::commands::arg_parser::use_color_stderr(); + let width = console::Term::stderr() + .size_checked() + .map(|(_, cols)| cols) + .unwrap_or(80); + Self::from_env(cfg!(windows), tty, colors_on, width, |key| { + std::env::var(key).ok() + }) + } + + /// Detection from explicit inputs, so every terminal can be tested. + /// `colors_on` is the project-wide stderr color decision. + pub(crate) fn from_env( + windows: bool, + tty: bool, + colors_on: bool, + width: u16, + var: impl Fn(&str) -> Option, + ) -> Self { + let color = if colors_on { + color_level(windows, &var) + } else { + ColorLevel::None + }; + Self { + tty, + color, + charset: charset(windows, &var), + width, + } + } + + pub fn glyphs(&self) -> &'static Glyphs { + match self.charset { + Charset::Unicode => &UNICODE_GLYPHS, + Charset::Legacy | Charset::Ascii => &ASCII_GLYPHS, + } + } + + /// Wrap `text` in an SGR sequence (e.g. `"1"` bold, `"2"` dim) when + /// color is on; otherwise return it unchanged. + pub fn paint(&self, sgr: &str, text: &str) -> String { + if self.color == ColorLevel::None || text.is_empty() { + text.to_string() + } else { + format!("\x1b[{sgr}m{text}\x1b[0m") + } + } + + /// Paint `text` in the brand teal, at the best depth the terminal has. + pub fn accent(&self, text: &str) -> String { + let (r, g, b) = BRAND_TEAL; + match self.color { + ColorLevel::None => text.to_string(), + ColorLevel::Ansi16 => self.paint("36", text), + ColorLevel::Ansi256 => self.paint(&format!("38;5;{BRAND_TEAL_256}"), text), + ColorLevel::TrueColor => self.paint(&format!("38;2;{r};{g};{b}"), text), + } + } + + fn accent_style(&self) -> Style { + let (r, g, b) = BRAND_TEAL; + match self.color { + ColorLevel::TrueColor => Style::new().true_color(r, g, b), + ColorLevel::Ansi256 => Style::new().color256(BRAND_TEAL_256), + ColorLevel::Ansi16 | ColorLevel::None => Style::new().cyan(), + } + } +} + +fn color_level(windows: bool, var: &impl Fn(&str) -> Option) -> ColorLevel { + if matches!(var("COLORTERM").as_deref(), Some("truecolor" | "24bit")) { + return ColorLevel::TrueColor; + } + // Every Windows console that passed the VT check behind `colors_on` + // (Windows 10 1703+) renders 24-bit color. + if windows { + return ColorLevel::TrueColor; + } + if var("TERM").is_some_and(|term| term.contains("256color")) { + ColorLevel::Ansi256 + } else { + ColorLevel::Ansi16 + } +} + +fn charset(windows: bool, var: &impl Fn(&str) -> Option) -> Charset { + if var("TERM").as_deref() == Some("dumb") { + return Charset::Ascii; + } + if windows { + // Windows Terminal sets WT_SESSION; other modern hosts (VS Code, the + // Claude desktop panel) set TERM_PROGRAM or COLORTERM instead. Only a + // console with none of them is the legacy conhost. + let modern = var("WT_SESSION").is_some() + || var("TERM_PROGRAM").is_some() + || matches!(var("COLORTERM").as_deref(), Some("truecolor" | "24bit")); + return if modern { + Charset::Unicode + } else { + Charset::Legacy + }; + } + if var("TERM").as_deref() == Some("linux") { + return Charset::Legacy; + } + // POSIX precedence: the first locale variable that is set decides. + let locale = ["LC_ALL", "LC_CTYPE", "LANG"] + .iter() + .find_map(|key| var(key).filter(|value| !value.is_empty())); + match locale { + Some(locale) + if !locale + .to_ascii_uppercase() + .replace('-', "") + .contains("UTF8") => + { + Charset::Ascii + } + _ => Charset::Unicode, + } +} + +/// The capabilities of stderr, detected once per process (after global flags +/// such as `--no-color` have been parsed). +pub fn caps() -> TermCaps { + static CAPS: OnceLock = OnceLock::new(); + *CAPS.get_or_init(TermCaps::detect) +} + +/// Point cliclack (and the `console` crate under it) at autter's theme and +/// color decision. Called before every cliclack prompt; runs once. +pub(crate) fn install_prompt_theme() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + let caps = caps(); + console::set_colors_enabled_stderr(caps.color != ColorLevel::None); + console::set_true_colors_enabled_stderr(caps.color == ColorLevel::TrueColor); + cliclack::set_theme(PromptTheme { + glyphs: caps.glyphs(), + accent: caps.accent_style(), + }); + }); +} + +/// cliclack's clack layout with autter's glyphs and brand accent. cliclack +/// picks its own glyphs from `WT_SESSION` alone, which shows ASCII in modern +/// Windows hosts, so every method that draws a glyph is overridden here. +struct PromptTheme { + glyphs: &'static Glyphs, + accent: Style, +} + +impl Theme for PromptTheme { + fn bar_color(&self, state: &ThemeState) -> Style { + match state { + ThemeState::Active => self.accent.clone(), + ThemeState::Cancel => Style::new().red(), + ThemeState::Submit => Style::new().bright().black(), + ThemeState::Error(_) => Style::new().yellow(), + } + } + + fn state_symbol(&self, state: &ThemeState) -> String { + let symbol = match state { + ThemeState::Active => self.glyphs.active, + ThemeState::Cancel => self.glyphs.cancel, + ThemeState::Submit => self.glyphs.submit, + ThemeState::Error(_) => self.glyphs.error, + }; + self.state_symbol_color(state).apply_to(symbol).to_string() + } + + fn radio_symbol(&self, state: &ThemeState, selected: bool) -> String { + match state { + ThemeState::Active if selected => { + self.accent.apply_to(self.glyphs.radio_on).to_string() + } + ThemeState::Active => Style::new() + .dim() + .apply_to(self.glyphs.radio_off) + .to_string(), + _ => String::new(), + } + } + + fn format_header(&self, state: &ThemeState, prompt: &str) -> String { + let bar = self.bar_color(state).apply_to(self.glyphs.bar); + prompt + .lines() + .enumerate() + .map(|(i, line)| { + if i == 0 { + format!("{} {line}\n", self.state_symbol(state)) + } else { + format!("{bar} {line}\n") + } + }) + .collect() + } + + fn format_footer_with_message(&self, state: &ThemeState, message: &str) -> String { + let (bar, end) = (self.glyphs.bar, self.glyphs.bar_end); + let line = match state { + ThemeState::Active => format!("{end} {message}"), + ThemeState::Cancel => format!("{end} Cancelled."), + ThemeState::Submit => bar.to_string(), + ThemeState::Error(err) => format!("{end} {err}"), + }; + // Styling swallows a trailing newline, so it goes outside. + format!("{}\n", self.bar_color(state).apply_to(line)) + } + + /// One row per option. Unlike clack, which shows the hint inline in + /// parentheses, the focused option's hint gets its own dimmed line: our + /// hints are full sentences. + fn format_select_item( + &self, + state: &ThemeState, + selected: bool, + label: &str, + hint: &str, + ) -> String { + if matches!(state, ThemeState::Cancel | ThemeState::Submit) && !selected { + return String::new(); + } + let bar = self.bar_color(state).apply_to(self.glyphs.bar); + let mut row = format!("{bar} {}\n", self.radio_item(state, selected, label, "")); + if matches!(state, ThemeState::Active | ThemeState::Error(_)) + && selected + && !hint.is_empty() + { + row.push_str(&format!("{bar} {}\n", Style::new().dim().apply_to(hint))); + } + row + } + + fn checkbox_symbol(&self, state: &ThemeState, selected: bool, active: bool) -> String { + if !matches!(state, ThemeState::Active | ThemeState::Error(_)) { + return String::new(); + } + if selected { + self.accent.apply_to(self.glyphs.checkbox_on).to_string() + } else if active { + self.glyphs.checkbox_off.to_string() + } else { + Style::new() + .dim() + .apply_to(self.glyphs.checkbox_off) + .to_string() + } + } + + /// Same layout as [`Self::format_select_item`]: the focused row's hint + /// goes on its own dimmed line. + fn format_multiselect_item( + &self, + state: &ThemeState, + selected: bool, + active: bool, + label: &str, + hint: &str, + ) -> String { + if matches!(state, ThemeState::Cancel | ThemeState::Submit) && !selected { + return String::new(); + } + let bar = self.bar_color(state).apply_to(self.glyphs.bar); + let mut row = format!( + "{bar} {}\n", + self.checkbox_item(state, selected, active, label, "") + ); + if matches!(state, ThemeState::Active | ThemeState::Error(_)) && active && !hint.is_empty() + { + let indent = " ".repeat(self.glyphs.checkbox_on.chars().count() + 3); + row.push_str(&format!( + "{bar}{indent}{}\n", + Style::new().dim().apply_to(hint) + )); + } + row + } + + fn format_input(&self, state: &ThemeState, cursor: &cliclack::StringCursor) -> String { + let style = self.input_style(state); + let mut input = match state { + ThemeState::Active | ThemeState::Error(_) => self.cursor_with_style(cursor, &style), + _ => cursor.to_string(), + }; + if input.ends_with('\n') { + input.push('\n'); + } + let bar = self.bar_color(state).apply_to(self.glyphs.bar); + input + .lines() + .map(|line| format!("{bar} {}\n", style.apply_to(line))) + .collect() + } + + fn password_mask(&self) -> char { + self.glyphs.mask + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn detect(windows: bool, env: &[(&str, &str)]) -> TermCaps { + let env: HashMap = env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + TermCaps::from_env(windows, true, true, 120, |key| env.get(key).cloned()) + } + + #[test] + fn claude_desktop_panel_is_modern_without_wt_session() { + let caps = detect( + true, + &[ + ("TERM_PROGRAM", "claude-desktop"), + ("COLORTERM", "truecolor"), + ], + ); + assert_eq!(caps.charset, Charset::Unicode); + assert_eq!(caps.color, ColorLevel::TrueColor); + } + + #[test] + fn windows_terminal_is_modern() { + assert_eq!( + detect(true, &[("WT_SESSION", "abc")]).charset, + Charset::Unicode + ); + } + + #[test] + fn bare_conhost_is_legacy() { + let caps = detect(true, &[]); + assert_eq!(caps.charset, Charset::Legacy); + assert_eq!(caps.glyphs().ok, "OK"); + } + + #[test] + fn unix_locale_decides_unicode() { + assert_eq!( + detect(false, &[("LANG", "en_US.UTF-8")]).charset, + Charset::Unicode + ); + assert_eq!( + detect(false, &[("LANG", "C.utf8")]).charset, + Charset::Unicode + ); + assert_eq!(detect(false, &[("LANG", "C")]).charset, Charset::Ascii); + // LC_ALL wins over LANG. + assert_eq!( + detect(false, &[("LC_ALL", "C"), ("LANG", "en_US.UTF-8")]).charset, + Charset::Ascii + ); + // No locale at all (common on macOS): assume a modern terminal. + assert_eq!(detect(false, &[]).charset, Charset::Unicode); + } + + #[test] + fn linux_vt_is_legacy_and_dumb_is_ascii() { + assert_eq!(detect(false, &[("TERM", "linux")]).charset, Charset::Legacy); + assert_eq!(detect(false, &[("TERM", "dumb")]).charset, Charset::Ascii); + } + + #[test] + fn color_depth_follows_colorterm_and_term() { + assert_eq!( + detect(false, &[("COLORTERM", "24bit")]).color, + ColorLevel::TrueColor + ); + assert_eq!( + detect(false, &[("TERM", "xterm-256color")]).color, + ColorLevel::Ansi256 + ); + assert_eq!( + detect(false, &[("TERM", "xterm")]).color, + ColorLevel::Ansi16 + ); + } + + #[test] + fn colors_off_paints_nothing() { + let caps = TermCaps::from_env(false, true, false, 120, |_| Some("truecolor".to_string())); + assert_eq!(caps.color, ColorLevel::None); + assert_eq!(caps.paint("1", "x"), "x"); + assert_eq!(caps.accent("x"), "x"); + } + + #[test] + fn accent_uses_the_deepest_available_teal() { + let mut caps = detect(false, &[("COLORTERM", "truecolor")]); + assert_eq!(caps.accent("x"), "\x1b[38;2;46;230;197mx\x1b[0m"); + caps.color = ColorLevel::Ansi256; + assert_eq!(caps.accent("x"), "\x1b[38;5;43mx\x1b[0m"); + caps.color = ColorLevel::Ansi16; + assert_eq!(caps.accent("x"), "\x1b[36mx\x1b[0m"); + } +} From 540a9e8851e7506aad547336b87c8d798d857bca Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Sun, 25 Oct 2026 18:19:19 +0530 Subject: [PATCH 2/2] Add status screens and interactive stats/blame views; release 2.3.0 Bare `autter` at a terminal shows a status card (mode and account, the latest commit's AI share and tool, pending uploads, commands to try) built from local state only; `autter help` still lists every command. `whoami` shows a card, `doctor` runs each check under a spinner that resolves in place, and `install-hooks` lists tools on a rail. Piped and --json output of all of these is unchanged. `autter stats -i` browses the last 200 commits by AI share with a per-commit breakdown by tool and model, computed on a background thread so the list opens at once. `autter blame -i ` shows the file with an AI/human/untracked gutter, line details, n/N between AI changes, and the prompt transcript on Enter. Both use ratatui (already a dependency), need a terminal, and fall back to the plain command. Bump to 2.3.0 with the CHANGELOG entry covering this and the onboarding and sign-in redesign; merging to main tags and publishes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 + Cargo.lock | 2 +- Cargo.toml | 2 +- .../status-screens-and-interactive-views.md | 19 + npm/package.json | 2 +- specs/roadmap/tui-onboarding-design.md | 11 + src/commands/arg_parser.rs | 12 +- src/commands/autter_handlers.rs | 48 +- src/commands/blame.rs | 25 + src/commands/doctor.rs | 86 +++- src/commands/home.rs | 324 +++++++++++++ src/commands/install_hooks.rs | 46 +- src/commands/interactive/blame.rs | 436 ++++++++++++++++++ src/commands/interactive/mod.rs | 212 +++++++++ src/commands/interactive/stats.rs | 362 +++++++++++++++ src/commands/mod.rs | 2 + src/commands/onboard.rs | 12 +- ...commands__home__tests__connected_home.snap | 14 + ...mmands__whoami__tests__signed_in_card.snap | 12 + src/commands/whoami.rs | 290 ++++++++++++ src/ui/progress.rs | 11 +- 21 files changed, 1917 insertions(+), 34 deletions(-) create mode 100644 changelog/status-screens-and-interactive-views.md create mode 100644 src/commands/home.rs create mode 100644 src/commands/interactive/blame.rs create mode 100644 src/commands/interactive/mod.rs create mode 100644 src/commands/interactive/stats.rs create mode 100644 src/commands/snapshots/autter__commands__home__tests__connected_home.snap create mode 100644 src/commands/snapshots/autter__commands__whoami__tests__signed_in_card.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index 772007d..55ee49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,29 @@ This changelog is automatically generated by AI when releases are published. For detailed design rationale and implementation notes, see `docs/superpowers/specs/2026-04-29-telemetry-streams-design.md`. +## [2.3.0] - 2026-10-25 + +### Added + +- **Redesigned setup**: `autter onboard` opens with the autter logo and walks one step rail with numbered questions ("2/4"). Each answer collapses to a single line, and the closing summary lists anything that needs attention with the command that fixes it. See [changelog/redesigned-onboarding-and-sign-in.md](changelog/redesigned-onboarding-and-sign-in.md). +- **Sign-in screen** (`autter onboard`, `autter login`): the one-time code and a live "expires in m:ss" countdown. Enter reopens the browser, `c` copies the code, and Esc continues in local mode (or cancels `autter login`). If sign-in fails you can try again, paste an access token (masked and checked before it's saved), or continue locally. +- **Per-tool integrations**: onboarding lists the coding tools it finds plus git capture, all checked, and installs only what stays checked. `--yes` still installs everything. +- **`autter stats -i`**: browse the last 200 commits by AI share, with each commit's AI, human and untracked lines and its breakdown by tool and model. See [changelog/status-screens-and-interactive-views.md](changelog/status-screens-and-interactive-views.md). +- **`autter blame -i `**: read a file with a colored mark beside every line (AI, human, untracked), details for the line under the cursor, `n`/`N` to jump between AI changes, and Enter to see the prompt behind an AI line. + +### Changed + +- **Bare `autter`** at a terminal shows a status card: mode and account, the latest commit's AI share and tool, pending uploads, and commands to try. `autter help` lists every command. +- **`autter whoami`** at a terminal shows a card with your account, how long the session stays valid, organizations, cloud sync and API. **`autter doctor`** shows a spinner per check that resolves in place. **`autter install-hooks`** lists tools on a rail with a closing count. Piped and `--json` output of all three is unchanged. +- **One look everywhere**: prompts, spinners, the update prompt and every screen above share one theme in the autter teal, with ASCII and half-block fallbacks for legacy Windows consoles, plain lines for narrow or non-interactive terminals, and `NO_COLOR` / `--no-color` honored throughout. +- **Prompts**: arrow keys, j/k and Enter as before. Esc now cancels like Ctrl-C, and the 1–9, Tab and Space shortcuts in single-choice prompts were removed. +- **Spinner outcome lines** from `autter install-hooks` go to stderr at a terminal. Piped stdout keeps the same lines as before. + +### Fixed + +- `autter login` and `autter onboard` printed color codes even with `NO_COLOR` or `--no-color`. +- The onboarding welcome promised "two quick choices"; setup can ask up to five questions. + ## [2.2.1] - 2026-10-18 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 45aa69a..ba3be79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,7 +258,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "autter" -version = "2.2.1" +version = "2.3.0" dependencies = [ "autter", "base64", diff --git a/Cargo.toml b/Cargo.toml index ffa9856..65c9ba6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "3" [package] name = "autter" -version = "2.2.1" +version = "2.3.0" edition = "2024" default-run = "autter" diff --git a/changelog/status-screens-and-interactive-views.md b/changelog/status-screens-and-interactive-views.md new file mode 100644 index 0000000..126ca22 --- /dev/null +++ b/changelog/status-screens-and-interactive-views.md @@ -0,0 +1,19 @@ +# Status screens and interactive views + +## Summary + +The terminal redesign (`specs/roadmap/tui-onboarding-design.md`) reaches the commands people use day to day. Bare `autter` shows your setup at a glance, `whoami` and `doctor` get readable layouts, and two new full-screen views let you browse authorship: `autter stats -i` and `autter blame -i`. + +## Added + +- **`autter stats -i [commit]`**: browse the last 200 commits by AI share, with each commit's AI, human and untracked lines and its breakdown by tool and model. The list opens immediately and fills in as each commit is counted. ↑/↓ or j/k to move, g/G for first/last, q to quit. +- **`autter blame -i `**: read a file with a colored mark beside every line (AI, human, untracked) and details for the line under the cursor: tool, model, commit, author and age. `n`/`N` jump between AI changes, and Enter shows the prompt transcript behind an AI line. Accepts the same git blame options as `autter blame`. + +## Changed + +- **Bare `autter`** at a terminal shows a status card instead of the command list: mode and account, the latest commit's AI share with the tool behind it, pending uploads, and commands to try. It reads only local state, so it's instant. `autter help` still lists every command, and piped output is unchanged. +- **`autter whoami`** at a terminal shows a card with your account, sign-in state (with how long the session stays valid), organizations, cloud sync status and API. Piped output keeps the `Key: value` lines. +- **`autter doctor`** at a terminal shows each check with a spinner while it runs, resolving in place, under bold section titles. `--json` and piped output are unchanged. +- **`autter install-hooks`** at a terminal lists each tool on a rail with a closing "N coding tools set up". Piped output and `-v` are unchanged. + +Both interactive views need a terminal. Elsewhere they print a note and show the plain command. They honor `NO_COLOR` and restore the terminal on exit, Ctrl-C or a crash. diff --git a/npm/package.json b/npm/package.json index cdf3473..43290d3 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@autter/cli", - "version": "2.2.1", + "version": "2.3.0", "description": "Git-native AI authorship tracking: records which lines were written by AI agents and links them to the agent, model, and prompt. This package is a thin bootstrapper that installs the native autter binary.", "license": "Apache-2.0", "homepage": "https://autter.dev", diff --git a/specs/roadmap/tui-onboarding-design.md b/specs/roadmap/tui-onboarding-design.md index ac2184a..0748f50 100644 --- a/specs/roadmap/tui-onboarding-design.md +++ b/specs/roadmap/tui-onboarding-design.md @@ -293,6 +293,17 @@ Migration rules: - Onboarding no longer uses unconditional color codes, so `NO_COLOR` and `--no-color` apply to it too. - `cargo run --example ui_preview` walks the same screens with sample data, including a sign-in that approves itself after six seconds. +**Phase 3 notes (as built):** +- **Bare `autter`** (`commands::home`) on a terminal: header with mode and account, the latest commit's AI share as a 20-column bar with the top tool, pending uploads when connected, and suggested commands. Only local state is read: config, stored credentials, the local notes cache, and one `git diff`. Bare `autter` with no terminal still prints the overview. +- **`whoami`**: when stdout is a terminal, a card (header plus Organizations, Cloud sync, API and User ID rows, and a note about held uploads). Piped output and exit codes are unchanged. +- **`doctor`**: when both streams are terminals, the header replaces the banner, section titles are bold, and each check shows an indented spinner (`Reporter::begin`) that resolves to ✓/⚠/✗ in place. `--json` and piped output are unchanged. +- **`install-hooks`** run directly at a terminal (without `-v`): the tools sit on a rail opened by "Coding agents" and closed with "N coding tools set up", with "Not detected" as a note. `--yes` onboarding now installs through `run_for_tools(None, true)` so it doesn't draw a second rail. + +**Phase 4 notes (as built):** `commands::interactive`, with a shared `View` trait, runner and palette. +- **`autter stats -i [commit]`**: the last 200 commits from `HEAD` (or the given commit) with time, author, an AI-share bar and percentage, and the subject. Beside or below the list, the selected commit's AI/human/untracked counts and its breakdown by tool and model. Stats are computed newest-first on a background thread, so the list opens immediately. +- **`autter blame -i `**: the file with a gutter mark per line (teal for AI, plain for human, gray for untracked). A detail strip shows the line's tool, model, commit, author and age. `n`/`N` jump between AI changes, and Enter opens the prompt transcript in an overlay (resolved locally via `cas_bridge`). git blame options such as `-C` still apply. +- Both views need a terminal on stdin and stdout and fall back to the plain command with a note otherwise. They use the theme's teal and glyphs, honor `NO_COLOR`, and restore the terminal on exit, Ctrl-C or panic (`ratatui::init`). + Phases 1 and 2 are the signup improvement. Phase 3 is the usage improvement. Phase 4 adds new interactive features. Each phase can ship as its own PR. --- diff --git a/src/commands/arg_parser.rs b/src/commands/arg_parser.rs index d22d189..a92beb2 100644 --- a/src/commands/arg_parser.rs +++ b/src/commands/arg_parser.rs @@ -322,7 +322,9 @@ autter blame Git blame with an AI authorship overlay. Accepts git blame arguments (-L, -C, --porcelain, etc.). - --json Output in JSON format", + --json Output in JSON format + -i, --interactive Browse the file full-screen: AI, human and untracked + lines, and the prompt behind each AI change", }, HelpEntry { name: "diff", @@ -349,9 +351,11 @@ autter stats [commit] Show AI authorship statistics for a commit or range. - --json Output in JSON format - --ignore Ignore files matching the given pattern(s) - --wait[=secs] Wait for the authorship note (default 30s); exit 1 if still pending", + --json Output in JSON format + --ignore Ignore files matching the given pattern(s) + --wait[=secs] Wait for the authorship note (default 30s); exit 1 if still pending + -i, --interactive Browse recent commits full-screen by AI share, with each + commit's breakdown by tool and model", }, HelpEntry { name: "file-changes", diff --git a/src/commands/autter_handlers.rs b/src/commands/autter_handlers.rs index c3db5ce..1cbbe44 100644 --- a/src/commands/autter_handlers.rs +++ b/src/commands/autter_handlers.rs @@ -25,6 +25,12 @@ pub fn handle_autter(args: &[String]) { }; if args.is_empty() { + // A person at a terminal gets their setup at a glance; `autter help` + // still lists every command. + if crate::ui::caps().tty { + commands::home::print_home(); + return; + } print_help(); return; } @@ -900,7 +906,19 @@ fn handle_ai_blame(args: &[String]) { return; } commands::arg_parser::merge_global_flags(&pp.flags); - let args = &pp.rest; + // `-i`/`--interactive` is autter's own (git blame has no `-i`), so it is + // taken out before the git-compatible flags are parsed. + let interactive = pp + .rest + .iter() + .any(|arg| arg == "-i" || arg == "--interactive"); + let args: Vec = pp + .rest + .iter() + .filter(|arg| *arg != "-i" && *arg != "--interactive") + .cloned() + .collect(); + let args = &args; if args.is_empty() { let current_dir = env::current_dir() @@ -996,6 +1014,17 @@ fn handle_ai_blame(args: &[String]) { file_path }; + if interactive { + if commands::interactive::available() && !options.json { + if let Err(e) = commands::interactive::blame::run(&repo, &file_path, &options) { + eprintln!("Blame failed: {}", e); + std::process::exit(1); + } + return; + } + eprintln!("autter blame -i needs a terminal; showing plain blame."); + } + if let Err(e) = repo.blame(&file_path, &options) { eprintln!("Blame failed: {}", e); std::process::exit(1); @@ -1069,10 +1098,15 @@ fn handle_stats(args: &[String]) { let mut commit_range: Option = None; let mut ignore_patterns: Vec = Vec::new(); let mut wait_secs: Option = None; + let mut interactive = false; let mut i = 0; while i < args.len() { match args[i].as_str() { + "-i" | "--interactive" => { + interactive = true; + i += 1; + } "--ignore" => { // Collect all arguments after --ignore until we hit another flag or commit SHA // This supports shell glob expansion: `--ignore *.lock` expands to `--ignore Cargo.lock package.lock` @@ -1154,6 +1188,18 @@ fn handle_stats(args: &[String]) { } } + // `-i`: browse recent commits full-screen, starting from the given commit. + if interactive { + if commands::interactive::available() && !json_output { + if let Err(e) = commands::interactive::stats::run(&repo, commit_sha.as_deref()) { + eprintln!("{}", e); + std::process::exit(1); + } + return; + } + eprintln!("autter stats -i needs a terminal; showing the plain summary."); + } + let effective_patterns = effective_ignore_patterns(&repo, &ignore_patterns, &[]); // Handle commit range if detected diff --git a/src/commands/blame.rs b/src/commands/blame.rs index cdc372a..50680ea 100644 --- a/src/commands/blame.rs +++ b/src/commands/blame.rs @@ -648,6 +648,31 @@ impl Repository { Ok(analysis) } + /// The file's lines with their attribution, for the interactive blame + /// browser: the repo-relative path, the lines blamed, and the analysis + /// with AI lines keyed by prompt hash (see [`Self::blame_analysis`]). + pub fn blame_for_browser( + &self, + file_path: &str, + options: &AutterBlameOptions, + ) -> Result<(String, Vec, BlameAnalysisResult), AutterError> { + let options = AutterBlameOptions { + use_prompt_hashes_as_names: true, + mark_unknown: true, + no_output: true, + ..options.clone() + }; + let request = self.prepare_blame_request(file_path, &options)?; + let (analysis, _authorship_logs, _prompt_commits, _commits_with_notes) = self + .run_blame_analysis_pipeline( + &request.relative_file_path, + &request.line_ranges, + &request.options, + )?; + let lines = request.file_content.lines().map(str::to_string).collect(); + Ok((request.relative_file_path, lines, analysis)) + } + pub fn blame_hunks( &self, file_path: &str, diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index fe82db6..84f03e0 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -15,6 +15,7 @@ use crate::config::{self, Config}; use crate::diagnostics::{DiagnosticCheckResult, DiagnosticStatus, GitDiagnosticTarget}; use crate::process_timeout::run_command_with_timeout; use serde::Serialize; +use std::io::IsTerminal; use std::time::Duration; const SKIP_TRACE2_CHECKS_FLAG: &str = "--skip-trace2-checks"; @@ -125,7 +126,16 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { let json = arg_parser::json(); let mut reporter = Reporter::new(json); - if !json { + if reporter.live { + eprintln!(); + crate::ui::Header::new( + "Autter doctor", + format!("v{}", version_string()), + "Checking your setup \u{00B7} the end-to-end checks take a few seconds", + crate::ui::header::display_cwd(), + ) + .print(); + } else if !json { println!("autter doctor (autter {})", version_string()); println!("Validating your autter setup -- the end-to-end checks take a few seconds."); } @@ -133,14 +143,18 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { let git_cmd = Config::get().git_cmd().to_string(); // Setup: git binary/version, config file parse, repository eligibility. + reporter.begin(SECTION_SETUP, "checking git"); let (git_check, terminal_git_ok) = check_git_versions(&git_cmd); reporter.add(git_check); + reporter.begin(SECTION_SETUP, "reading the config file"); reporter.add(check_config_file()); + reporter.begin(SECTION_SETUP, "checking this repository"); reporter.add(check_repository()); // Background service health uses the same probe as `autter bg status`, so // the two commands always agree. This starts (or restarts) the service // when needed, so it must run before the end-to-end checkpoint check. + reporter.begin(SECTION_SERVICE, "checking the background service"); let daemon_check = check_from_diagnostic( SECTION_SERVICE, "background service", @@ -152,6 +166,7 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { // Separately: do real git trace2 events reach the service? A failure here // is usually missing trace2 config, not an unhealthy service. if daemon_ok { + reporter.begin(SECTION_SERVICE, "sending a git event to the service"); reporter.add(check_from_diagnostic( SECTION_SERVICE, "trace2 ingestion", @@ -174,6 +189,7 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { ("trace2 config (configured git)", &configured_target), ("trace2 config (terminal git)", &terminal_target), ] { + reporter.begin(SECTION_TRACE2, &format!("checking {name}")); let check = check_from_diagnostic( SECTION_TRACE2, name, @@ -191,6 +207,7 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { format!("skipped ({})", SKIP_TRACE2_CHECKS_FLAG), )); } else { + reporter.begin(SECTION_TRACE2, "capturing a real git event"); let capture = check_from_diagnostic( SECTION_TRACE2, "trace2 event capture", @@ -212,6 +229,7 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { } else { &configured_target }; + reporter.begin(SECTION_E2E, "running a checkpoint through a test commit"); let mut e2e_check = check_from_diagnostic( SECTION_E2E, "checkpoint round-trip", @@ -242,19 +260,25 @@ fn run_doctor(options: &DoctorOptions) -> DoctorOutput { } // AI agent hooks: would an edit made right now actually checkpoint? + reporter.begin(SECTION_AGENTS, "checking agent hooks"); for check in agent_hook_checks() { reporter.add(check); } + reporter.begin(SECTION_AGENTS, "checking VS Code"); if let Some(check) = vscode_native_hooks_check() { reporter.add(check); } // Account & sync: auth state and API reachability. Uploads silently // no-op when unauthenticated, so doctor is where a broken login surfaces. + reporter.begin(SECTION_ACCOUNT, "checking your sign-in"); let (auth_check, authenticated) = check_auth(); reporter.add(auth_check); + reporter.begin(SECTION_ACCOUNT, "reaching the Autter API"); reporter.add(check_connectivity(authenticated)); + reporter.begin(SECTION_ACCOUNT, "checking your org's data plane"); reporter.add(check_org_data_plane()); + reporter.begin(SECTION_ACCOUNT, "checking the upload queue"); reporter.add(check_sync_queue()); reporter.finish() @@ -1173,32 +1197,70 @@ fn summarize(checks: &[DoctorCheck]) -> DoctorSummary { /// document at the end. struct Reporter { json: bool, + /// A person is watching both streams: each check shows a spinner while + /// it runs and resolves in place, in the terminal's glyphs. + live: bool, current_section: Option<&'static str>, + running: Option, checks: Vec, } impl Reporter { fn new(json: bool) -> Self { + let live = !json && std::io::stdout().is_terminal() && crate::ui::caps().tty; Self { json, + live, current_section: None, + running: None, checks: Vec::new(), } } + /// Announce a check that is about to run. Only shows anything live; the + /// result still arrives through [`Self::add`]. + fn begin(&mut self, section: &'static str, activity: &str) { + if !self.live { + return; + } + self.enter_section(section); + if let Some(spinner) = self.running.take() { + spinner.stop(); + } + self.running = Some(crate::ui::progress::Spinner::indented( + &format!("{activity}\u{2026}"), + 2, + )); + } + fn add(&mut self, check: DoctorCheck) { + if let Some(spinner) = self.running.take() { + spinner.stop(); + } if !self.json { - if self.current_section != Some(check.section) { - println!(); - println!("{}", check.section); - self.current_section = Some(check.section); - } - print_human_check(&check); + self.enter_section(check.section); + let glyphs = if self.live { + crate::ui::caps().glyphs() + } else { + &crate::ui::theme::UNICODE_GLYPHS + }; + print_human_check(&check, glyphs); } self.checks.push(check); } - fn finish(self) -> DoctorOutput { + fn enter_section(&mut self, section: &'static str) { + if self.current_section != Some(section) { + println!(); + println!("{}", arg_parser::paint("1", section)); + self.current_section = Some(section); + } + } + + fn finish(mut self) -> DoctorOutput { + if let Some(spinner) = self.running.take() { + spinner.stop(); + } let summary = summarize(&self.checks); let next_step = next_step_from_checks(&self.checks); let output = DoctorOutput { @@ -1266,11 +1328,11 @@ impl Reporter { } } -fn print_human_check(check: &DoctorCheck) { +fn print_human_check(check: &DoctorCheck, glyphs: &crate::ui::theme::Glyphs) { let (symbol, color) = match check.status { - DoctorStatus::Passed => ("✓", "1;32"), - DoctorStatus::Warning => ("⚠", "1;33"), - DoctorStatus::Failed => ("✗", "1;31"), + DoctorStatus::Passed => (glyphs.ok, "1;32"), + DoctorStatus::Warning => (glyphs.warn, "1;33"), + DoctorStatus::Failed => (glyphs.fail, "1;31"), DoctorStatus::Skipped => ("-", "90"), }; println!( diff --git a/src/commands/home.rs b/src/commands/home.rs new file mode 100644 index 0000000..5bd2848 --- /dev/null +++ b/src/commands/home.rs @@ -0,0 +1,324 @@ +//! What bare `autter` shows at a terminal: the brand header with this +//! machine's mode and account, the latest commit's AI share, and the +//! commands worth trying. It reads only local state (config, stored +//! credentials, the local notes cache and one `git diff`), so it stays quick; +//! a row that can't be computed is left out. `autter help` lists everything. + +use crate::auth::{AuthState, collect_auth_status}; +use crate::authorship::stats::{CommitStats, stats_for_commit_stats}; +use crate::config; +use crate::ui::{self, TermCaps}; + +/// Width of the compact authorship bar. +const BAR_WIDTH: usize = 20; + +pub fn print_home() { + eprint!("{}", render(&gather(), &ui::caps())); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Mode { + NotSetUp, + Local, + Connected { account: String }, + SignedOut, +} + +#[derive(Debug, Clone)] +struct LastCommit { + /// `abc1234: subject`. + label: String, + stats: CommitStats, +} + +struct HomeView { + version: &'static str, + mode: Mode, + cwd: String, + /// `None` outside a git repository (or with no commits yet). + last_commit: Option, + pending_uploads: Option, +} + +fn gather() -> HomeView { + let file_config = config::load_file_config_public().unwrap_or_default(); + let mode = if file_config.onboarding_completed != Some(true) { + Mode::NotSetUp + } else if !config::Config::fresh().platform_sync_enabled() { + Mode::Local + } else { + let auth = collect_auth_status(); + if matches!(auth.state, AuthState::LoggedIn) { + let who = auth + .email + .clone() + .or(auth.name.clone()) + .unwrap_or_else(|| "your account".to_string()); + let org = auth + .orgs + .iter() + .find_map(|org| org.org_slug.clone().or(org.org_name.clone())); + Mode::Connected { + account: match org { + Some(org) => format!("{who} \u{00B7} {org}"), + None => who, + }, + } + } else { + Mode::SignedOut + } + }; + let pending_uploads = matches!(mode, Mode::Connected { .. }) + .then(|| crate::auth::notice::pending_sync_counts().total()); + + HomeView { + version: env!("CARGO_PKG_VERSION"), + mode, + cwd: ui::header::display_cwd(), + last_commit: last_commit(), + pending_uploads, + } +} + +fn last_commit() -> Option { + let repo = crate::git::repository::find_repository_in_path(".").ok()?; + let sha = repo.head().ok()?.target().ok()?; + let patterns = crate::authorship::ignore::effective_ignore_patterns(&repo, &[], &[]); + let stats = stats_for_commit_stats(&repo, &sha, &patterns).ok()?; + let label = crate::commands::usage_hints::head_commit_hint(&repo) + .unwrap_or_else(|| sha.chars().take(7).collect()); + Some(LastCommit { label, stats }) +} + +fn render(view: &HomeView, caps: &TermCaps) -> String { + let mode = match &view.mode { + Mode::NotSetUp => format!( + "Not set up yet \u{00B7} run {}", + caps.paint("36", "autter onboard") + ), + Mode::Local => "Local mode \u{00B7} nothing leaves this machine".to_string(), + Mode::Connected { account } => format!("Connected \u{00B7} {account}"), + Mode::SignedOut => format!( + "Connected mode, signed out \u{00B7} run {}", + caps.paint("36", "autter login") + ), + }; + let mut out = String::from("\n"); + out.push_str( + &ui::Header::new("Autter", format!("v{}", view.version), mode, &view.cwd).render(caps), + ); + out.push('\n'); + + let label = |text: &str| caps.paint("2", &format!("{text:<13}")); + match &view.last_commit { + Some(commit) => { + let (sha, subject) = commit + .label + .split_once(": ") + .unwrap_or((commit.label.as_str(), "")); + out.push_str(&format!( + " {}{} {}\n", + label("Last commit"), + caps.paint("36", sha), + share_summary(&commit.stats, caps) + )); + if !subject.is_empty() { + out.push_str(&format!(" {:13}{}\n", "", caps.paint("2", subject))); + } + } + None => out.push_str(&format!( + " {}\n", + caps.paint( + "2", + "Run autter inside a git repository to see its latest commit's authorship." + ) + )), + } + if let Some(pending) = view.pending_uploads { + let value = if pending == 0 { + "none pending".to_string() + } else { + format!("{pending} pending") + }; + out.push_str(&format!(" {}{value}\n", label("Uploads"))); + } + + let commands = if view.mode == Mode::NotSetUp { + "autter onboard \u{00B7} autter help".to_string() + } else { + let mut commands = vec!["autter stats", "autter blame "]; + if matches!(view.mode, Mode::Connected { .. }) { + commands.push("autter dash"); + } + commands.push("autter help"); + commands.join(" \u{00B7} ") + }; + out.push('\n'); + out.push_str(&format!(" {}\n\n", caps.paint("2", &commands))); + out +} + +/// `████░░░░ 62% AI · claude`, or why there's no share to show. +fn share_summary(stats: &CommitStats, caps: &TermCaps) -> String { + let added = stats.git_diff_added_lines; + if added == 0 { + return caps.paint("2", "no added lines"); + } + let ai = stats.ai_additions.min(added); + let human = stats.human_additions.min(added - ai); + let untracked = added - ai - human; + if untracked == added { + return caps.paint("2", "not tracked \u{00B7} run autter doctor"); + } + + let [human_cols, untracked_cols, ai_cols] = bar_columns(human, untracked, ai, BAR_WIDTH); + let bar = format!( + "{}{}{}", + "\u{2588}".repeat(human_cols), + caps.paint("2", &"\u{00B7}".repeat(untracked_cols)), + caps.accent(&"\u{2591}".repeat(ai_cols)) + ); + let percent = (f64::from(ai) * 100.0 / f64::from(added)).round() as u32; + let mut summary = format!("{bar} {percent}% AI"); + if let Some(tool) = top_tool(stats) { + summary.push_str(&format!(" \u{00B7} {tool}")); + } + summary +} + +/// Split `width` columns between human, untracked and AI lines, giving every +/// non-empty part at least one column. +pub(crate) fn bar_columns(human: u32, untracked: u32, ai: u32, width: usize) -> [usize; 3] { + let parts = [human, untracked, ai]; + let total: u32 = parts.iter().sum(); + if total == 0 { + return [0, 0, 0]; + } + let mut columns = parts.map(|part| { + if part == 0 { + 0 + } else { + ((f64::from(part) * width as f64 / f64::from(total)).round() as usize).max(1) + } + }); + // Rounding can overshoot or undershoot; settle the difference on the + // widest part. + let widest = (0..3).max_by_key(|&i| columns[i]).unwrap_or(0); + let sum: usize = columns.iter().sum(); + if sum > width { + columns[widest] -= sum - width; + } else { + columns[widest] += width - sum; + } + columns +} + +/// The tool behind most of the commit's AI lines. +fn top_tool(stats: &CommitStats) -> Option { + stats + .tool_model_breakdown + .iter() + .max_by_key(|(_, headline)| headline.ai_additions) + .filter(|(_, headline)| headline.ai_additions > 0) + .map(|(key, _)| key.split("::").next().unwrap_or(key).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authorship::stats::ToolModelHeadlineStats; + use crate::ui::theme::{Charset, ColorLevel}; + + fn caps() -> TermCaps { + TermCaps { + tty: true, + color: ColorLevel::None, + charset: Charset::Unicode, + width: 100, + } + } + + fn stats(human: u32, untracked: u32, ai: u32) -> CommitStats { + let mut breakdown = std::collections::BTreeMap::new(); + if ai > 0 { + breakdown.insert( + "claude::opus".to_string(), + ToolModelHeadlineStats { + ai_additions: ai, + ai_accepted: ai, + }, + ); + } + CommitStats { + human_additions: human, + unknown_additions: untracked, + ai_additions: ai, + ai_accepted: ai, + git_diff_deleted_lines: 0, + git_diff_added_lines: human + untracked + ai, + tool_model_breakdown: breakdown, + } + } + + fn view(mode: Mode, last_commit: Option) -> HomeView { + HomeView { + version: "2.3.0", + pending_uploads: matches!(mode, Mode::Connected { .. }).then_some(0), + mode, + cwd: "~/code/app".to_string(), + last_commit, + } + } + + #[test] + fn connected_home() { + let commit = LastCommit { + label: "a1b2c3d: Add retry to the uploader".to_string(), + stats: stats(30, 8, 62), + }; + insta::assert_snapshot!(render( + &view( + Mode::Connected { + account: "you@company.com \u{00B7} acme".to_string() + }, + Some(commit) + ), + &caps() + )); + } + + #[test] + fn outside_a_repo_and_not_set_up() { + let text = render(&view(Mode::NotSetUp, None), &caps()); + assert!( + text.contains("Not set up yet · run autter onboard"), + "{text}" + ); + assert!(text.contains("inside a git repository"), "{text}"); + assert!(text.contains("autter onboard · autter help"), "{text}"); + assert!(!text.contains("Uploads"), "{text}"); + } + + #[test] + fn share_handles_empty_and_untracked_commits() { + assert_eq!(share_summary(&stats(0, 0, 0), &caps()), "no added lines"); + assert_eq!( + share_summary(&stats(0, 12, 0), &caps()), + "not tracked · run autter doctor" + ); + assert_eq!( + share_summary(&stats(10, 0, 0), &caps()), + format!("{} 0% AI", "\u{2588}".repeat(BAR_WIDTH)) + ); + } + + #[test] + fn bar_always_fills_its_width_and_shows_small_parts() { + for parts in [(1, 0, 999), (5, 3, 2), (1, 1, 1), (100, 0, 0)] { + let columns = bar_columns(parts.0, parts.1, parts.2, BAR_WIDTH); + assert_eq!(columns.iter().sum::(), BAR_WIDTH, "{parts:?}"); + } + assert_eq!(bar_columns(1, 0, 999, BAR_WIDTH)[0], 1); + assert_eq!(bar_columns(0, 0, 0, BAR_WIDTH), [0, 0, 0]); + } +} diff --git a/src/commands/install_hooks.rs b/src/commands/install_hooks.rs index 6c1e8a1..0b531f2 100644 --- a/src/commands/install_hooks.rs +++ b/src/commands/install_hooks.rs @@ -9,6 +9,7 @@ use crate::mdm::spinner::{Spinner, print_diff}; use crate::mdm::utils::resolve_hook_binary_path; use std::collections::{HashMap, HashSet}; use std::fs; +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -30,6 +31,9 @@ struct InstallOptions { /// Running inside onboarding's step rail: skip this command's own section /// header and "not detected" list, which onboarding already showed. on_rail: bool, + /// Run directly by a person at a terminal: lay the tools out on a rail of + /// their own, opened by a step line and closed with a summary. + rail_layout: bool, } /// A tool autter can install hooks for, found on this machine. @@ -61,17 +65,17 @@ pub fn detect_tools() -> Vec { .collect() } -/// Install hooks for just `tool_ids` (from [`detect_tools`]), plus the global -/// git trace2 config and background service when `system` is set. Output -/// hangs off onboarding's step rail. +/// Install hooks for `tool_ids` (from [`detect_tools`]; `None` for every +/// detected tool), plus the global git trace2 config and background service +/// when `system` is set. Output hangs off onboarding's step rail. pub fn run_for_tools( - tool_ids: &[String], + tool_ids: Option<&[String]>, system: bool, ) -> Result, AutterError> { let _rail = crate::ui::progress::on_rail(); run_with_options(InstallOptions { apply_system: system, - only: Some(tool_ids.iter().cloned().collect()), + only: tool_ids.map(|ids| ids.iter().cloned().collect()), on_rail: true, ..InstallOptions::default() }) @@ -399,7 +403,12 @@ fn ensure_daemon(dry_run: bool) { /// after the user consents — so `npm install` / bare `install-hooks` no longer /// mutate the machine silently. pub fn run(args: &[String]) -> Result, AutterError> { - run_with_options(parse_install_options(args)) + let mut options = parse_install_options(args); + // Verbose output interleaves diffs, which don't belong on the rail. + options.rail_layout = + !options.verbose && std::io::stdout().is_terminal() && crate::ui::caps().tty; + let _rail = options.rail_layout.then(crate::ui::progress::on_rail); + run_with_options(options) } fn run_with_options(options: InstallOptions) -> Result, AutterError> { @@ -642,7 +651,10 @@ async fn async_run_install( let mut detailed_results: Vec<(String, InstallResult)> = Vec::new(); // === Coding Agents === - if !options.on_rail { + if options.rail_layout { + eprintln!(); + crate::ui::rail::print_step("Coding agents", None); + } else if !options.on_rail { println!("\n{}", paint("1", "Coding Agents")); } @@ -829,7 +841,25 @@ async fn async_run_install( } } - if !not_detected.is_empty() && !options.on_rail { + if options.rail_layout { + if !not_detected.is_empty() { + crate::ui::rail::print_note(&format!("Not detected: {}", not_detected.join(", "))); + } + let ready = statuses + .values() + .filter(|status| { + matches!( + status, + InstallStatus::Installed | InstallStatus::AlreadyInstalled + ) + }) + .count(); + crate::ui::rail::print_end(&match ready { + 0 => "No coding tools set up".to_string(), + 1 => "1 coding tool set up".to_string(), + n => format!("{n} coding tools set up"), + }); + } else if !not_detected.is_empty() && !options.on_rail { println!( "{}", paint("2", &format!(" Not detected: {}", not_detected.join(", "))) diff --git a/src/commands/interactive/blame.rs b/src/commands/interactive/blame.rs new file mode 100644 index 0000000..be00f90 --- /dev/null +++ b/src/commands/interactive/blame.rs @@ -0,0 +1,436 @@ +//! `autter blame -i `: the file with a colored gutter per line (AI, +//! human, untracked), details for the line under the cursor, and the prompt +//! transcript behind an AI line on Enter. + +use std::collections::HashMap; + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + +use super::{Palette, View, ago, scroll_to_keep_visible, step}; +use crate::authorship::authorship_log::PromptRecord; +use crate::authorship::transcript::Message; +use crate::commands::blame::{AutterBlameOptions, BlameAnalysisResult, BlameHunk}; +use crate::error::AutterError; +use crate::git::repository::Repository; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Kind { + /// Keyed by prompt hash. + Ai(String), + Human, + Untracked, +} + +/// What the prompt overlay is showing. +enum Overlay { + None, + /// Enter was pressed; the transcript loads on the next tick, after a + /// frame with "Loading…" has been drawn. + Loading(String), + Prompt { + lines: Vec>, + scroll: u16, + }, +} + +struct BlameView { + path: String, + lines: Vec, + kinds: Vec, + prompts: HashMap, + hunks: Vec, + cursor: usize, + offset: usize, + overlay: Overlay, + now: i64, + palette: Palette, +} + +pub fn run( + repo: &Repository, + file_path: &str, + options: &AutterBlameOptions, +) -> Result<(), AutterError> { + let (path, lines, analysis) = repo.blame_for_browser(file_path, options)?; + if lines.is_empty() { + return Err(AutterError::Generic(format!("{path} is empty."))); + } + let kinds = classify(lines.len(), &analysis); + let mut view = BlameView { + path, + lines, + kinds, + prompts: analysis.prompt_records, + hunks: analysis.blame_hunks, + cursor: 0, + offset: 0, + overlay: Overlay::None, + now: chrono::Utc::now().timestamp(), + palette: Palette::new(), + }; + super::run(&mut view)?; + Ok(()) +} + +/// One [`Kind`] per line (index 0 is line 1). AI lines carry their prompt +/// hash; `h_` hashes are known human edits; everything else is untracked. +fn classify(line_count: usize, analysis: &BlameAnalysisResult) -> Vec { + (1..=line_count as u32) + .map(|line| match analysis.line_authors.get(&line) { + Some(author) if analysis.prompt_records.contains_key(author) => { + Kind::Ai(author.clone()) + } + Some(author) if author.starts_with("h_") => Kind::Human, + _ => Kind::Untracked, + }) + .collect() +} + +impl BlameView { + fn hunk_for(&self, line: usize) -> Option<&BlameHunk> { + let line = line as u32 + 1; + self.hunks + .iter() + .find(|hunk| hunk.range.0 <= line && line <= hunk.range.1) + } + + fn ai_share(&self) -> u32 { + let ai = self + .kinds + .iter() + .filter(|kind| matches!(kind, Kind::Ai(_))) + .count(); + (ai as f64 * 100.0 / self.kinds.len() as f64).round() as u32 + } + + /// The next (or previous) AI line after the cursor that starts a new run. + fn next_ai(&self, forward: bool) -> Option { + let is_ai = |i: usize| matches!(self.kinds[i], Kind::Ai(_)); + let starts_run = |i: usize| is_ai(i) && (i == 0 || !is_ai(i - 1)); + if forward { + (self.cursor + 1..self.lines.len()).find(|&i| starts_run(i)) + } else { + (0..self.cursor).rev().find(|&i| starts_run(i)) + } + } + + fn style_for(&self, kind: &Kind) -> ratatui::style::Style { + match kind { + Kind::Ai(_) => self.palette.ai, + Kind::Human => self.palette.human, + Kind::Untracked => self.palette.untracked, + } + } + + fn draw_file(&mut self, frame: &mut Frame, area: Rect) { + let p = &self.palette; + let height = area.height.saturating_sub(2) as usize; + self.offset = scroll_to_keep_visible(self.offset, self.cursor, height); + let number_width = self.lines.len().to_string().len(); + + let rows: Vec = (self.offset..self.lines.len().min(self.offset + height)) + .map(|i| { + let kind = &self.kinds[i]; + let text = self.lines[i].replace('\t', " "); + let mut line = Line::from(vec![ + Span::styled(p.gutter, self.style_for(kind)), + Span::styled(format!(" {:>number_width$} ", i + 1), p.dim), + Span::raw(text), + ]); + if i == self.cursor { + line = line.style(p.selected); + } + line + }) + .collect(); + + let title = Line::from(vec![ + Span::styled(format!(" {} ", self.path), p.title), + Span::styled( + format!( + "\u{00B7} {} lines \u{00B7} {}% AI ", + self.lines.len(), + self.ai_share() + ), + p.dim, + ), + ]); + frame.render_widget( + Paragraph::new(rows).block( + Block::default() + .borders(Borders::ALL) + .border_style(p.dim) + .title(title), + ), + area, + ); + } + + fn draw_detail(&self, frame: &mut Frame, area: Rect) { + let p = &self.palette; + let kind = &self.kinds[self.cursor]; + let mut first = match kind { + Kind::Ai(hash) => { + let mut spans = vec![Span::styled("AI", p.ai)]; + if let Some(prompt) = self.prompts.get(hash) { + spans.push(Span::raw(format!( + " \u{00B7} {} \u{00B7} {}", + prompt.agent_id.tool, prompt.agent_id.model + ))); + } + spans + } + Kind::Human => vec![Span::raw("Human \u{00B7} typed in an editor")], + Kind::Untracked => vec![Span::styled( + "Untracked \u{00B7} no attribution recorded", + p.untracked, + )], + }; + if let Some(hunk) = self.hunk_for(self.cursor) { + first.push(Span::styled( + format!( + " commit {} \u{00B7} {} \u{00B7} {}", + hunk.abbrev_sha, + hunk.original_author, + ago(self.now - hunk.author_time) + ), + p.dim, + )); + } + let mut lines = vec![Line::from(first)]; + if let Kind::Ai(hash) = kind + && let Some(prompt) = self.prompts.get(hash) + { + let short: String = hash.chars().take(12).collect(); + let mut second = vec![Span::styled( + format!( + "prompt {short} \u{00B7} {} lines accepted", + prompt.accepted_lines + ), + p.dim, + )]; + if prompt.messages_url.is_some() { + second.push(Span::styled(" enter", p.title)); + second.push(Span::styled(" show the prompt", p.dim)); + } + lines.push(Line::from(second)); + } + frame.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(p.dim) + .title(Span::styled(format!(" Line {} ", self.cursor + 1), p.title)), + ), + area, + ); + } + + fn draw_overlay(&self, frame: &mut Frame) { + let p = &self.palette; + let (lines, scroll) = match &self.overlay { + Overlay::None => return, + Overlay::Loading(_) => ( + vec![Line::from(Span::styled( + "Loading the prompt\u{2026}", + p.dim, + ))], + 0, + ), + Overlay::Prompt { lines, scroll } => (lines.clone(), *scroll), + }; + let area = frame.area(); + let popup = Rect { + x: area.width / 10, + y: area.height / 10, + width: area.width - area.width / 5, + height: area.height - area.height / 5, + }; + frame.render_widget(Clear, popup); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(p.ai) + .title(Span::styled(" Prompt ", p.title)) + .title_bottom(p.help(&[("\u{2191}\u{2193}", "scroll"), ("esc", "close")])), + ), + popup, + ); + } + + fn open_prompt(&mut self) { + if let Kind::Ai(hash) = &self.kinds[self.cursor] + && self + .prompts + .get(hash) + .is_some_and(|prompt| prompt.messages_url.is_some()) + { + self.overlay = Overlay::Loading(hash.clone()); + } + } + + fn load_prompt(&self, hash: &str) -> Vec> { + let p = &self.palette; + let Some(url) = self + .prompts + .get(hash) + .and_then(|prompt| prompt.messages_url.clone()) + else { + return vec![Line::from("No prompt transcript is linked to this line.")]; + }; + let messages = match crate::authorship::cas_bridge::resolve_cas_messages(&url) { + Ok(Some(messages)) if !messages.is_empty() => messages, + Ok(_) => { + return vec![Line::from(format!( + "The transcript isn't available on this machine ({url})." + ))]; + } + Err(e) => return vec![Line::from(format!("Couldn't load the transcript: {e}"))], + }; + let mut lines = Vec::new(); + for message in &messages { + let (who, text, style) = match message { + Message::User { text, .. } => ("You", text, p.title), + Message::Assistant { text, .. } => ("Agent", text, p.ai), + Message::Plan { text, .. } => ("Plan", text, p.ai), + // Thinking and tool calls are noise for "why is this line here". + Message::Thinking { .. } | Message::ToolUse { .. } => continue, + }; + let text = text.trim(); + if text.is_empty() { + continue; + } + lines.push(Line::from(Span::styled(who, style))); + lines.extend(text.lines().map(|line| Line::from(line.to_string()))); + lines.push(Line::from("")); + } + if lines.is_empty() { + lines.push(Line::from("The transcript has no messages to show.")); + } + lines + } +} + +impl View for BlameView { + fn tick(&mut self) { + if let Overlay::Loading(hash) = &self.overlay { + let hash = hash.clone(); + self.overlay = Overlay::Prompt { + lines: self.load_prompt(&hash), + scroll: 0, + }; + } + } + + fn draw(&mut self, frame: &mut Frame) { + let [file, detail, help] = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(4), + Constraint::Length(1), + ]) + .areas(frame.area()); + self.draw_file(frame, file); + self.draw_detail(frame, detail); + frame.render_widget( + Paragraph::new(self.palette.help(&[ + ("\u{2191}\u{2193}", "move"), + ("n/N", "next/prev AI change"), + ("enter", "prompt"), + ("q", "quit"), + ])), + help, + ); + self.draw_overlay(frame); + } + + fn on_key(&mut self, key: KeyEvent) -> bool { + if let Overlay::Prompt { scroll, .. } = &mut self.overlay { + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => self.overlay = Overlay::None, + KeyCode::Down | KeyCode::Char('j') => *scroll = scroll.saturating_add(1), + KeyCode::Up | KeyCode::Char('k') => *scroll = scroll.saturating_sub(1), + KeyCode::PageDown => *scroll = scroll.saturating_add(10), + KeyCode::PageUp => *scroll = scroll.saturating_sub(10), + _ => {} + } + return true; + } + + let len = self.lines.len(); + self.cursor = match key.code { + KeyCode::Char('q') | KeyCode::Esc => return false, + KeyCode::Down | KeyCode::Char('j') => step(self.cursor, 1, len), + KeyCode::Up | KeyCode::Char('k') => step(self.cursor, -1, len), + KeyCode::PageDown => step(self.cursor, 20, len), + KeyCode::PageUp => step(self.cursor, -20, len), + KeyCode::Home | KeyCode::Char('g') => 0, + KeyCode::End | KeyCode::Char('G') => len.saturating_sub(1), + KeyCode::Char('n') => self.next_ai(true).unwrap_or(self.cursor), + KeyCode::Char('N') => self.next_ai(false).unwrap_or(self.cursor), + KeyCode::Enter => { + self.open_prompt(); + self.cursor + } + _ => self.cursor, + }; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authorship::working_log::AgentId; + use std::collections::BTreeMap; + + fn prompt() -> PromptRecord { + PromptRecord { + agent_id: AgentId { + tool: "claude".to_string(), + id: "s1".to_string(), + model: "opus".to_string(), + }, + human_author: None, + messages_url: None, + total_additions: 2, + total_deletions: 0, + accepted_lines: 2, + overriden_lines: 0, + custom_attributes: None, + } + } + + #[test] + fn classifies_ai_human_and_untracked_lines() { + let analysis = BlameAnalysisResult { + line_authors: HashMap::from([ + (1, "p_abc".to_string()), + (2, "h_def".to_string()), + (3, "Unknown".to_string()), + ]), + prompt_records: HashMap::from([("p_abc".to_string(), prompt())]), + session_records: HashMap::new(), + blame_hunks: Vec::new(), + humans: BTreeMap::new(), + }; + assert_eq!( + classify(4, &analysis), + [ + Kind::Ai("p_abc".to_string()), + Kind::Human, + Kind::Untracked, + Kind::Untracked, + ] + ); + } +} diff --git a/src/commands/interactive/mod.rs b/src/commands/interactive/mod.rs new file mode 100644 index 0000000..5840106 --- /dev/null +++ b/src/commands/interactive/mod.rs @@ -0,0 +1,212 @@ +//! Full-screen views built on ratatui: `autter stats -i` to browse recent +//! commits by AI share, and `autter blame -i ` to read a file with its +//! authorship and the prompt behind each AI line. +//! +//! They need a terminal on both stdin and stdout, take over the alternate +//! screen, and give it back on exit or panic (`ratatui::init` installs the +//! restore hook). The plain commands are unchanged. + +pub mod blame; +pub mod stats; + +use std::io::IsTerminal; +use std::time::Duration; + +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::{DefaultTerminal, Frame}; + +use crate::ui::theme::{Charset, ColorLevel}; + +/// Whether a full-screen view can run here. +pub fn available() -> bool { + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() +} + +/// A full-screen view: drawn every frame, fed keys, and polled for +/// background results between frames. +pub(crate) trait View { + fn draw(&mut self, frame: &mut Frame); + /// Handle a key press. Returns `false` to close the view. + fn on_key(&mut self, key: KeyEvent) -> bool; + /// Pick up work finished in the background. + fn tick(&mut self) {} +} + +/// Run `view` until it closes (or Ctrl-C), restoring the terminal after. +pub(crate) fn run(view: &mut impl View) -> std::io::Result<()> { + let mut terminal = ratatui::init(); + let result = event_loop(&mut terminal, view); + ratatui::restore(); + result +} + +fn event_loop(terminal: &mut DefaultTerminal, view: &mut impl View) -> std::io::Result<()> { + loop { + view.tick(); + terminal.draw(|frame| view.draw(frame))?; + if !event::poll(Duration::from_millis(100))? { + continue; + } + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(()); + } + if !view.on_key(key) { + return Ok(()); + } + } +} + +/// The theme's colors and glyphs as ratatui styles. With color off (for +/// `NO_COLOR`, `--no-color`) everything is plain except the selection, which +/// stays visible in reverse video. +pub(crate) struct Palette { + pub ai: Style, + pub human: Style, + pub untracked: Style, + pub dim: Style, + pub title: Style, + pub selected: Style, + /// Gutter mark beside each line in the blame view. + pub gutter: &'static str, + pub bar_full: &'static str, + pub bar_ai: &'static str, + pub bar_untracked: &'static str, +} + +impl Palette { + pub fn new() -> Self { + let caps = crate::ui::caps(); + let unicode = caps.charset == Charset::Unicode; + let (gutter, bar_full, bar_ai, bar_untracked) = if unicode { + ("\u{258C}", "\u{2588}", "\u{2591}", "\u{00B7}") + } else { + ("|", "#", "=", ".") + }; + if caps.color == ColorLevel::None { + let plain = Style::default(); + return Self { + ai: plain, + human: plain, + untracked: plain, + dim: plain, + title: plain.add_modifier(Modifier::BOLD), + selected: plain.add_modifier(Modifier::REVERSED), + gutter, + bar_full, + bar_ai, + bar_untracked, + }; + } + let teal = match caps.color { + ColorLevel::TrueColor => Color::Rgb(46, 230, 197), + ColorLevel::Ansi256 => Color::Indexed(43), + _ => Color::Cyan, + }; + Self { + ai: Style::default().fg(teal), + human: Style::default(), + untracked: Style::default().fg(Color::DarkGray), + dim: Style::default().fg(Color::DarkGray), + title: Style::default().add_modifier(Modifier::BOLD), + selected: Style::default() + .bg(Color::Indexed(236)) + .add_modifier(Modifier::BOLD), + gutter, + bar_full, + bar_ai, + bar_untracked, + } + } + + /// The key hints shown on the bottom row. + pub fn help(&self, hints: &[(&str, &str)]) -> Line<'static> { + let mut spans = vec![Span::raw(" ")]; + for (i, (key, what)) in hints.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" \u{00B7} ", self.dim)); + } + spans.push(Span::styled(key.to_string(), self.title)); + spans.push(Span::styled(format!(" {what}"), self.dim)); + } + Line::from(spans) + } +} + +/// "just now", "5 minutes ago", "3 days ago", "2 years ago". +pub(crate) fn ago(seconds: i64) -> String { + const MINUTE: i64 = 60; + const HOUR: i64 = 60 * MINUTE; + const DAY: i64 = 24 * HOUR; + let (n, unit) = match seconds { + s if s < MINUTE => return "just now".to_string(), + s if s < HOUR => (s / MINUTE, "minute"), + s if s < DAY => (s / HOUR, "hour"), + s if s < 30 * DAY => (s / DAY, "day"), + s if s < 365 * DAY => (s / (30 * DAY), "month"), + s => (s / (365 * DAY), "year"), + }; + if n == 1 { + format!("1 {unit} ago") + } else { + format!("{n} {unit}s ago") + } +} + +/// Keep `selected` inside a window of `height` rows starting at `offset`. +pub(crate) fn scroll_to_keep_visible(offset: usize, selected: usize, height: usize) -> usize { + let height = height.max(1); + if selected < offset { + selected + } else if selected >= offset + height { + selected + 1 - height + } else { + offset + } +} + +/// Move a cursor by `delta` within `0..len`. +pub(crate) fn step(selected: usize, delta: isize, len: usize) -> usize { + if len == 0 { + return 0; + } + (selected as isize + delta).clamp(0, len as isize - 1) as usize +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ago_reads_naturally() { + assert_eq!(ago(5), "just now"); + assert_eq!(ago(60), "1 minute ago"); + assert_eq!(ago(3 * 3600), "3 hours ago"); + assert_eq!(ago(86_400), "1 day ago"); + assert_eq!(ago(65 * 86_400), "2 months ago"); + assert_eq!(ago(800 * 86_400), "2 years ago"); + } + + #[test] + fn scrolling_keeps_the_cursor_on_screen() { + assert_eq!(scroll_to_keep_visible(0, 3, 10), 0); + assert_eq!(scroll_to_keep_visible(0, 12, 10), 3); + assert_eq!(scroll_to_keep_visible(5, 2, 10), 2); + assert_eq!(scroll_to_keep_visible(0, 0, 0), 0); + } + + #[test] + fn cursor_stays_in_bounds() { + assert_eq!(step(0, -1, 5), 0); + assert_eq!(step(4, 1, 5), 4); + assert_eq!(step(2, 10, 5), 4); + assert_eq!(step(0, 1, 0), 0); + } +} diff --git a/src/commands/interactive/stats.rs b/src/commands/interactive/stats.rs new file mode 100644 index 0000000..c20c862 --- /dev/null +++ b/src/commands/interactive/stats.rs @@ -0,0 +1,362 @@ +//! `autter stats -i`: recent commits with their AI share, and the selected +//! commit's breakdown by tool and model. Stats are computed one commit at a +//! time on a background thread, newest first, so the list opens at once. + +use std::sync::mpsc::{self, Receiver}; + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Row, Table, TableState}; + +use super::{Palette, View, scroll_to_keep_visible, step}; +use crate::authorship::stats::{CommitStats, stats_for_commit_stats}; +use crate::commands::home::bar_columns; +use crate::error::AutterError; +use crate::git::repository::{Repository, exec_git, find_repository_in_path}; + +/// How many commits the view lists. +const COMMIT_LIMIT: usize = 200; +const BAR_WIDTH: usize = 12; + +struct CommitRow { + sha: String, + short: String, + author: String, + when: String, + subject: String, +} + +enum Load { + Pending, + Ready(CommitStats), + Failed, +} + +struct StatsView { + rows: Vec, + stats: Vec, + results: Receiver<(usize, Load)>, + table: TableState, + palette: Palette, +} + +/// Open the view on the commits reachable from `start` (default `HEAD`). +pub fn run(repo: &Repository, start: Option<&str>) -> Result<(), AutterError> { + let rows = recent_commits(repo, start.unwrap_or("HEAD"))?; + if rows.is_empty() { + return Err(AutterError::Generic("No commits to show yet.".to_string())); + } + + let (tx, results) = mpsc::channel(); + let workdir = repo.workdir()?; + let shas: Vec = rows.iter().map(|row| row.sha.clone()).collect(); + std::thread::spawn(move || { + let Ok(repo) = find_repository_in_path(&workdir.to_string_lossy()) else { + return; + }; + let patterns = crate::authorship::ignore::effective_ignore_patterns(&repo, &[], &[]); + for (i, sha) in shas.iter().enumerate() { + let load = match stats_for_commit_stats(&repo, sha, &patterns) { + Ok(stats) => Load::Ready(stats), + Err(_) => Load::Failed, + }; + // The view closed: stop computing. + if tx.send((i, load)).is_err() { + return; + } + } + }); + + let mut view = StatsView { + stats: rows.iter().map(|_| Load::Pending).collect(), + rows, + results, + table: TableState::default().with_selected(Some(0)), + palette: Palette::new(), + }; + super::run(&mut view)?; + Ok(()) +} + +fn recent_commits(repo: &Repository, start: &str) -> Result, AutterError> { + let mut args = repo.global_args_for_exec(); + args.extend( + [ + "log", + "--no-color", + "-n", + &COMMIT_LIMIT.to_string(), + "--format=%H%x1f%h%x1f%an%x1f%ar%x1f%s", + start, + "--", + ] + .map(String::from), + ); + let output = exec_git(&args)?; + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout + .lines() + .filter_map(|line| { + let mut fields = line.split('\u{1f}'); + Some(CommitRow { + sha: fields.next()?.to_string(), + short: fields.next()?.to_string(), + author: fields.next()?.to_string(), + when: fields.next()?.to_string(), + subject: fields.next().unwrap_or("").to_string(), + }) + }) + .collect()) +} + +/// AI, human and untracked shares of a commit's added lines. +fn shares(stats: &CommitStats) -> Option<(u32, u32, u32)> { + let added = stats.git_diff_added_lines; + if added == 0 { + return None; + } + let ai = stats.ai_additions.min(added); + let human = stats.human_additions.min(added - ai); + Some((human, added - ai - human, ai)) +} + +fn percent(part: u32, total: u32) -> u32 { + if total == 0 { + 0 + } else { + (f64::from(part) * 100.0 / f64::from(total)).round() as u32 + } +} + +impl StatsView { + fn selected(&self) -> usize { + self.table.selected().unwrap_or(0) + } + + fn bar(&self, load: &Load) -> Line<'static> { + let p = &self.palette; + match load { + Load::Pending => Line::from(Span::styled("\u{2026}", p.dim)), + Load::Failed => Line::from(Span::styled("unavailable", p.dim)), + Load::Ready(stats) => match shares(stats) { + None => Line::from(Span::styled("no additions", p.dim)), + Some((human, untracked, ai)) => { + let [h, u, a] = bar_columns(human, untracked, ai, BAR_WIDTH); + Line::from(vec![ + Span::styled(p.bar_full.repeat(h), p.human), + Span::styled(p.bar_untracked.repeat(u), p.untracked), + Span::styled(p.bar_ai.repeat(a), p.ai), + ]) + } + }, + } + } + + fn percent_cell(&self, load: &Load) -> Span<'static> { + match load { + Load::Ready(stats) if stats.git_diff_added_lines > 0 => Span::styled( + format!( + "{:>3}%", + percent(stats.ai_additions, stats.git_diff_added_lines) + ), + self.palette.ai, + ), + _ => Span::raw(""), + } + } + + fn draw_list(&mut self, frame: &mut Frame, area: Rect) { + let p = &self.palette; + let rows: Vec = self + .rows + .iter() + .zip(&self.stats) + .map(|(row, load)| { + Row::new(vec![ + Line::from(Span::styled(row.short.clone(), p.ai)), + Line::from(Span::styled(row.when.clone(), p.dim)), + Line::from(row.author.clone()), + self.bar(load), + Line::from(self.percent_cell(load)), + Line::from(row.subject.clone()), + ]) + }) + .collect(); + let table = Table::new( + rows, + [ + Constraint::Length(9), + Constraint::Length(15), + Constraint::Length(16), + Constraint::Length(BAR_WIDTH as u16), + Constraint::Length(4), + Constraint::Min(10), + ], + ) + .column_spacing(1) + .row_highlight_style(p.selected) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(p.dim) + .title(Span::styled(" Recent commits ", p.title)), + ); + // Keep the selection on screen as it moves. + let visible = area.height.saturating_sub(2) as usize; + let offset = scroll_to_keep_visible(self.table.offset(), self.selected(), visible); + *self.table.offset_mut() = offset; + frame.render_stateful_widget(table, area, &mut self.table); + } + + fn draw_detail(&self, frame: &mut Frame, area: Rect) { + let p = &self.palette; + let row = &self.rows[self.selected()]; + let mut lines = vec![ + Line::from(Span::styled(row.subject.clone(), p.title)), + Line::from(Span::styled( + format!("{} \u{00B7} {}", row.author, row.when), + p.dim, + )), + Line::from(""), + ]; + match &self.stats[self.selected()] { + Load::Pending => lines.push(Line::from(Span::styled("Counting lines\u{2026}", p.dim))), + Load::Failed => lines.push(Line::from(Span::styled( + "Couldn't read this commit's authorship.", + p.dim, + ))), + Load::Ready(stats) => lines.extend(self.breakdown(stats)), + } + let detail = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(p.dim) + .title(Span::styled(format!(" {} ", row.short), p.title)), + ); + frame.render_widget(detail, area); + } + + fn breakdown(&self, stats: &CommitStats) -> Vec> { + let p = &self.palette; + let Some((human, untracked, ai)) = shares(stats) else { + return vec![Line::from(Span::styled( + format!( + "No added lines \u{00B7} {} deleted", + stats.git_diff_deleted_lines + ), + p.dim, + ))]; + }; + let added = stats.git_diff_added_lines; + let row = |label: &str, count: u32, style| { + Line::from(vec![ + Span::styled(format!("{label:<11}"), style), + Span::raw(format!("{count:>6} lines {:>3}%", percent(count, added))), + ]) + }; + let mut lines = vec![ + row("AI", ai, p.ai), + row("Human", human, p.human), + row("Untracked", untracked, p.untracked), + Line::from(Span::styled( + format!( + "{added} added \u{00B7} {} deleted", + stats.git_diff_deleted_lines + ), + p.dim, + )), + ]; + if !stats.tool_model_breakdown.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("By tool", p.title))); + let mut tools: Vec<_> = stats.tool_model_breakdown.iter().collect(); + tools.sort_by_key(|(_, headline)| std::cmp::Reverse(headline.ai_additions)); + for (key, headline) in tools { + let label = key.replace("::", " \u{00B7} "); + lines.push(Line::from(vec![ + Span::styled(format!("{label:<24}"), p.ai), + Span::raw(format!("{:>6}", headline.ai_additions)), + ])); + } + } + lines + } +} + +impl View for StatsView { + fn tick(&mut self) { + while let Ok((i, load)) = self.results.try_recv() { + if let Some(slot) = self.stats.get_mut(i) { + *slot = load; + } + } + } + + fn draw(&mut self, frame: &mut Frame) { + let [body, help] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(1)]) + .areas(frame.area()); + let wide = body.width >= 110; + let [list, detail] = Layout::default() + .direction(if wide { + Direction::Horizontal + } else { + Direction::Vertical + }) + .constraints(if wide { + [Constraint::Min(70), Constraint::Length(40)] + } else { + [Constraint::Min(6), Constraint::Length(14)] + }) + .areas(body); + self.draw_list(frame, list); + self.draw_detail(frame, detail); + frame.render_widget( + Paragraph::new(self.palette.help(&[ + ("\u{2191}\u{2193}", "move"), + ("g/G", "first/last"), + ("q", "quit"), + ])), + help, + ); + } + + fn on_key(&mut self, key: KeyEvent) -> bool { + let len = self.rows.len(); + let selected = self.selected(); + let next = match key.code { + KeyCode::Char('q') | KeyCode::Esc => return false, + KeyCode::Down | KeyCode::Char('j') => step(selected, 1, len), + KeyCode::Up | KeyCode::Char('k') => step(selected, -1, len), + KeyCode::PageDown => step(selected, 10, len), + KeyCode::PageUp => step(selected, -10, len), + KeyCode::Home | KeyCode::Char('g') => 0, + KeyCode::End | KeyCode::Char('G') => len.saturating_sub(1), + _ => selected, + }; + self.table.select(Some(next)); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shares_split_added_lines() { + let stats = CommitStats { + human_additions: 30, + ai_additions: 62, + git_diff_added_lines: 100, + ..CommitStats::default() + }; + assert_eq!(shares(&stats), Some((30, 8, 62))); + assert_eq!(shares(&CommitStats::default()), None); + assert_eq!(percent(62, 100), 62); + assert_eq!(percent(1, 0), 0); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6b6e568..dd4eef7 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -15,8 +15,10 @@ pub mod file_changes; pub mod flush_metrics_db; pub mod git_handlers; pub mod git_hook_handlers; +pub mod home; pub mod hooks; pub mod install_hooks; +pub mod interactive; pub mod log; pub mod login; pub mod logout; diff --git a/src/commands/onboard.rs b/src/commands/onboard.rs index 4a980a3..ca3b816 100644 --- a/src/commands/onboard.rs +++ b/src/commands/onboard.rs @@ -711,8 +711,7 @@ fn apply_system_integrations_with_consent( match integration_decision(assume_yes, ui::is_interactive()) { IntegrationDecision::Apply => { rail::print_step("Installing integrations", None); - let _rail = ui::progress::on_rail(); - crate::commands::install_hooks::run(&["--system".to_string()]) + crate::commands::install_hooks::run_for_tools(None, true) .map(|_| true) .map_err(|e| e.to_string()) } @@ -762,9 +761,12 @@ fn apply_system_integrations_with_consent( .filter(|&&i| i < tools.len()) .map(|&i| tools[i].id.clone()) .collect(); - crate::commands::install_hooks::run_for_tools(&tool_ids, chosen.contains(&system_item)) - .map(|_| true) - .map_err(|e| e.to_string()) + crate::commands::install_hooks::run_for_tools( + Some(&tool_ids), + chosen.contains(&system_item), + ) + .map(|_| true) + .map_err(|e| e.to_string()) } } } diff --git a/src/commands/snapshots/autter__commands__home__tests__connected_home.snap b/src/commands/snapshots/autter__commands__home__tests__connected_home.snap new file mode 100644 index 0000000..afe2ba5 --- /dev/null +++ b/src/commands/snapshots/autter__commands__home__tests__connected_home.snap @@ -0,0 +1,14 @@ +--- +source: src/commands/home.rs +expression: "render(&view(Mode::Connected\n{ account: \"you@company.com \\u{00B7} acme\".to_string() }, Some(commit)),\n&caps())" +--- + + ▗▟▀▀▙▟▌ Autter v2.3.0 + ▐▌ ▐█▌ Connected · you@company.com · acme + ▝▜▄▄▛▜▌ ██ ~/code/app + + Last commit a1b2c3d ██████··░░░░░░░░░░░░ 62% AI · claude + Add retry to the uploader + Uploads none pending + + autter stats · autter blame · autter dash · autter help diff --git a/src/commands/snapshots/autter__commands__whoami__tests__signed_in_card.snap b/src/commands/snapshots/autter__commands__whoami__tests__signed_in_card.snap new file mode 100644 index 0000000..7800327 --- /dev/null +++ b/src/commands/snapshots/autter__commands__whoami__tests__signed_in_card.snap @@ -0,0 +1,12 @@ +--- +source: src/commands/whoami.rs +expression: "render_card(&view(&auth, CloudSyncState::Healthy, 0), &caps())" +--- + ▗▟▀▀▙▟▌ Sam (you@company.com) + ▐▌ ▐█▌ Signed in · session valid for 89 days + ▝▜▄▄▛▜▌ ██ Credentials in file + + Organizations acme (admin) · Personal + Cloud sync ✓ up to date + API https://api.autter.dev + User ID usr_123 diff --git a/src/commands/whoami.rs b/src/commands/whoami.rs index a8d96ab..46149f1 100644 --- a/src/commands/whoami.rs +++ b/src/commands/whoami.rs @@ -1,6 +1,11 @@ +use std::io::IsTerminal; + use crate::api::client::ApiContext; +use crate::auth::notice::CloudSyncState; +use crate::auth::state::AuthStatus; use crate::auth::{AuthState, collect_auth_status, format_unix_timestamp}; use crate::config; +use crate::ui::{self, TermCaps}; pub fn handle_whoami(args: &[String]) { if args @@ -22,6 +27,28 @@ pub fn handle_whoami(args: &[String]) { let auth = collect_auth_status(); let api_ctx = ApiContext::new(None); + // A person at a terminal gets a card; anything reading stdout keeps the + // `Key: value` lines below. + if std::io::stdout().is_terminal() && ui::caps().tty { + let report = crate::auth::notice::collect_cloud_sync_status(); + let view = WhoamiView { + auth: &auth, + api_base_url: &api_base_url, + api_key: api_ctx.api_key.as_deref().map(mask_api_key), + sync: SyncSummary { + state: report.state, + pending: report.pending.total, + held: report.held.total(), + }, + now: chrono::Utc::now().timestamp(), + }; + print!("{}", render_card(&view, &ui::caps())); + if !matches!(auth.state, AuthState::LoggedIn) && api_ctx.api_key.is_none() { + std::process::exit(1); + } + return; + } + println!("API Base URL: {}", api_base_url); println!("Credential backend: {}", auth.backend); @@ -131,6 +158,173 @@ pub fn handle_whoami(args: &[String]) { } } +struct SyncSummary { + state: CloudSyncState, + pending: i64, + held: i64, +} + +struct WhoamiView<'a> { + auth: &'a AuthStatus, + api_base_url: &'a str, + /// Already masked. + api_key: Option, + sync: SyncSummary, + /// Unix seconds, for relative expiry times. + now: i64, +} + +/// The terminal card: the brand header (account, sign-in state, credential +/// backend) and one row each for organizations, cloud sync and the API. +fn render_card(view: &WhoamiView, caps: &TermCaps) -> String { + let auth = view.auth; + let who = account_label(auth); + let fix = |text: &str| format!("run {}", caps.paint("36", text)); + let (title, state) = match &auth.state { + AuthState::LoggedIn => { + let valid = auth + .refresh_token_expires_at + .map(|at| format!(" \u{00B7} session valid {}", relative_until(at - view.now))) + .unwrap_or_default(); + (who, format!("Signed in{valid}")) + } + AuthState::LoggedOut if view.api_key.is_some() => { + ("Signed in with an API key".to_string(), String::new()) + } + AuthState::LoggedOut => ( + "Not signed in".to_string(), + format!("Sign in: {}", fix("autter login")), + ), + AuthState::SyncBlocked => ( + who, + format!( + "Cloud sync blocked \u{00B7} {}, then {}", + fix("autter login"), + caps.paint("36", "autter bg restart") + ), + ), + AuthState::RefreshExpired => ( + who, + format!("Session expired \u{00B7} {}", fix("autter login")), + ), + AuthState::LoginRefused(reason) => ( + who, + format!( + "Sign-in refused ({reason}) \u{00B7} {}", + fix("autter login") + ), + ), + AuthState::Error(err) => ("Couldn't read your sign-in".to_string(), err.clone()), + }; + let mut out = + ui::Header::new(title, "", state, format!("Credentials in {}", auth.backend)).render(caps); + out.push('\n'); + + let row = |label: &str, value: &str| { + format!(" {}{value}\n", caps.paint("2", &format!("{label:<15}"))) + }; + let orgs = if auth.orgs.is_empty() { + caps.paint("2", "none") + } else { + auth.orgs + .iter() + .map(|org| { + let name = org + .org_slug + .as_deref() + .or(org.org_name.as_deref()) + .unwrap_or("unnamed"); + match org.role.as_deref() { + Some(role) => format!("{name} {}", caps.paint("2", &format!("({role})"))), + None => name.to_string(), + } + }) + .collect::>() + .join(" \u{00B7} ") + }; + out.push_str(&row("Organizations", &orgs)); + out.push_str(&row("Cloud sync", &sync_label(&view.sync, caps))); + let api = match &view.api_key { + Some(key) => format!("{} \u{00B7} key {key}", view.api_base_url), + None => view.api_base_url.to_string(), + }; + out.push_str(&row("API", &api)); + if let Some(user_id) = &auth.user_id { + out.push_str(&row("User ID", &caps.paint("2", user_id))); + } + if view.sync.held > 0 { + out.push('\n'); + out.push_str(&format!( + " {} records from before you connected are held and won't upload.\n Upload: {} \u{00B7} Delete: {}\n", + view.sync.held, + caps.paint("36", crate::upload_hold::IMPORT_COMMAND), + caps.paint("36", crate::upload_hold::PURGE_COMMAND) + )); + } + out +} + +fn account_label(auth: &AuthStatus) -> String { + let name = auth.name.as_deref().filter(|s| !s.is_empty()); + let email = auth.email.as_deref().filter(|s| !s.is_empty()); + match (name, email) { + (Some(name), Some(email)) => format!("{name} ({email})"), + (Some(who), None) | (None, Some(who)) => who.to_string(), + (None, None) => "Unknown account".to_string(), + } +} + +fn sync_label(sync: &SyncSummary, caps: &TermCaps) -> String { + let glyphs = caps.glyphs(); + let fix = |text: &str| caps.paint("36", text); + match sync.state { + CloudSyncState::Healthy => format!("{} up to date", caps.paint("32", glyphs.ok)), + CloudSyncState::Draining => format!("{} records uploading", sync.pending), + CloudSyncState::Disabled => caps.paint("2", "off \u{00B7} local mode, nothing uploads"), + CloudSyncState::AuthBlocked => format!( + "{} blocked \u{00B7} run {}", + caps.paint("33", glyphs.warn), + fix("autter login") + ), + CloudSyncState::UploadFailing => format!( + "{} upload failing \u{00B7} run {}", + caps.paint("31", glyphs.fail), + fix("autter doctor") + ), + CloudSyncState::DaemonNotRunning => format!( + "{} background service stopped \u{00B7} run {}", + caps.paint("33", glyphs.warn), + fix("autter bg restart") + ), + CloudSyncState::StatusUnavailable => { + format!("status unavailable \u{00B7} run {}", fix("autter doctor")) + } + } +} + +/// "for 6 days", "for 3 hours", "for 12 minutes", or "expired". +fn relative_until(seconds: i64) -> String { + const MINUTE: i64 = 60; + const HOUR: i64 = 60 * MINUTE; + const DAY: i64 = 24 * HOUR; + let plural = |n: i64, unit: &str| { + if n == 1 { + format!("for 1 {unit}") + } else { + format!("for {n} {unit}s") + } + }; + if seconds <= 0 { + "expired".to_string() + } else if seconds < HOUR { + plural((seconds / MINUTE).max(1), "minute") + } else if seconds < DAY { + plural(seconds / HOUR, "hour") + } else { + plural(seconds / DAY, "day") + } +} + fn mask_api_key(key: &str) -> String { let chars: Vec = key.chars().collect(); if chars.len() <= 8 { @@ -148,3 +342,99 @@ fn print_help() { eprintln!(" autter whoami"); eprintln!(" autter whoami --help"); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::identity::TokenOrg; + use crate::ui::theme::{Charset, ColorLevel}; + + fn caps() -> TermCaps { + TermCaps { + tty: true, + color: ColorLevel::None, + charset: Charset::Unicode, + width: 100, + } + } + + fn signed_in() -> AuthStatus { + AuthStatus { + backend: "file".to_string(), + state: AuthState::LoggedIn, + access_token_expires_at: Some(1_000_000 + 3600), + refresh_token_expires_at: Some(1_000_000 + 89 * 86_400 + 10), + user_id: Some("usr_123".to_string()), + email: Some("you@company.com".to_string()), + name: Some("Sam".to_string()), + personal_org_id: None, + orgs: vec![ + TokenOrg { + org_id: Some("org_1".to_string()), + org_name: Some("Acme".to_string()), + org_slug: Some("acme".to_string()), + role: Some("admin".to_string()), + }, + TokenOrg { + org_id: None, + org_name: Some("Personal".to_string()), + org_slug: None, + role: None, + }, + ], + } + } + + fn view(auth: &AuthStatus, state: CloudSyncState, held: i64) -> WhoamiView<'_> { + WhoamiView { + auth, + api_base_url: "https://api.autter.dev", + api_key: None, + sync: SyncSummary { + state, + pending: 3, + held, + }, + now: 1_000_000, + } + } + + #[test] + fn signed_in_card() { + let auth = signed_in(); + insta::assert_snapshot!(render_card( + &view(&auth, CloudSyncState::Healthy, 0), + &caps() + )); + } + + #[test] + fn signed_out_card_points_at_login() { + let auth = AuthStatus { + state: AuthState::LoggedOut, + email: None, + name: None, + user_id: None, + orgs: Vec::new(), + ..signed_in() + }; + let text = render_card(&view(&auth, CloudSyncState::Disabled, 2), &caps()); + assert!(text.contains("Not signed in"), "{text}"); + assert!(text.contains("Sign in: run autter login"), "{text}"); + assert!(text.contains("off · local mode"), "{text}"); + assert!( + text.contains("2 records from before you connected"), + "{text}" + ); + assert!(!text.contains("User ID"), "{text}"); + } + + #[test] + fn relative_expiry() { + assert_eq!(relative_until(0), "expired"); + assert_eq!(relative_until(30), "for 1 minute"); + assert_eq!(relative_until(3 * 3600 + 5), "for 3 hours"); + assert_eq!(relative_until(86_400), "for 1 day"); + assert_eq!(relative_until(89 * 86_400 + 10), "for 89 days"); + } +} diff --git a/src/ui/progress.rs b/src/ui/progress.rs index 0ca83f9..9cdab7f 100644 --- a/src/ui/progress.rs +++ b/src/ui/progress.rs @@ -44,9 +44,18 @@ pub struct Spinner { impl Spinner { pub fn new(message: &str) -> Self { + Self::indented(message, 0) + } + + /// A spinner drawn `indent` columns in, to sit under a heading. + pub fn indented(message: &str, indent: usize) -> Self { // indicatif draws on stderr and hides itself when that isn't a terminal. let caps = theme::caps(); - let template = format!("{}{{spinner:.green}} {{msg}}", rail_prefix(&caps)); + let template = format!( + "{}{}{{spinner:.green}} {{msg}}", + rail_prefix(&caps), + " ".repeat(indent) + ); let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner()