Use historical typewriter names for agent branch names (#50611)

Richard Feldman created

Replace the auto-generated `agent-[hash]` branch naming scheme with
names based on historical typewriter models (e.g. `olivetti-a3f9b2c1`,
`selectric-7d2e4f01`).

A static list of 49 typewriter names is used as the pool. When selecting
a name, existing branch names are checkedโ€”each branch is split on its
last `-` to extract the typewriter prefix, and any taken prefixes are
excluded from the candidate set. A random name is then picked from the
remaining candidates and suffixed with an 8-character alphanumeric hash.

The name selection logic accepts injected RNG and a list of disallowed
names, making it straightforward to test deterministically with
`#[gpui::test]` and `StdRng`.

Closes AI-71

(No release notes because this is feature-flagged.)

Release Notes:

- N/A

Change summary

crates/agent_ui/src/agent_panel.rs  |  99 ++-
crates/agent_ui/src/agent_ui.rs     |   1 
crates/agent_ui/src/branch_names.rs | 847 +++++++++++++++++++++++++++++++
typos.toml                          |   3 
4 files changed, 915 insertions(+), 35 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs ๐Ÿ”—

@@ -67,7 +67,6 @@ use language_model::{ConfigurationError, LanguageModelRegistry};
 use project::project_settings::ProjectSettings;
 use project::{Project, ProjectPath, Worktree};
 use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
-use rand::Rng as _;
 use rules_library::{RulesLibrary, open_rules_library};
 use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, update_settings_file};
@@ -2042,21 +2041,6 @@ impl AgentPanel {
         }
     }
 
-    fn generate_agent_branch_name() -> String {
-        let mut rng = rand::rng();
-        let id: String = (0..8)
-            .map(|_| {
-                let idx: u8 = rng.random_range(0..36);
-                if idx < 10 {
-                    (b'0' + idx) as char
-                } else {
-                    (b'a' + idx - 10) as char
-                }
-            })
-            .collect();
-        format!("agent-{id}")
-    }
-
     /// Partitions the project's visible worktrees into git-backed repositories
     /// and plain (non-git) paths. Git repos will have worktrees created for
     /// them; non-git paths are carried over to the new workspace as-is.
@@ -2256,8 +2240,6 @@ impl AgentPanel {
         self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
         cx.notify();
 
-        let branch_name = Self::generate_agent_branch_name();
-
         let (git_repos, non_git_paths) = self.classify_worktrees(cx);
 
         if git_repos.is_empty() {
@@ -2269,28 +2251,18 @@ impl AgentPanel {
             return;
         }
 
+        // Kick off branch listing as early as possible so it can run
+        // concurrently with the remaining synchronous setup work.
+        let branch_receivers: Vec<_> = git_repos
+            .iter()
+            .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
+            .collect();
+
         let worktree_directory_setting = ProjectSettings::get_global(cx)
             .git
             .worktree_directory
             .clone();
 
-        let (creation_infos, path_remapping) = match Self::start_worktree_creations(
-            &git_repos,
-            &branch_name,
-            &worktree_directory_setting,
-            cx,
-        ) {
-            Ok(result) => result,
-            Err(err) => {
-                self.set_worktree_creation_error(
-                    format!("Failed to validate worktree directory: {err}").into(),
-                    window,
-                    cx,
-                );
-                return;
-            }
-        };
-
         let (dock_structure, open_file_paths) = self
             .workspace
             .upgrade()
@@ -2307,6 +2279,63 @@ impl AgentPanel {
             .downcast::<workspace::MultiWorkspace>();
 
         let task = cx.spawn_in(window, async move |this, cx| {
+            // Await the branch listings we kicked off earlier.
+            let mut existing_branches = Vec::new();
+            for result in futures::future::join_all(branch_receivers).await {
+                match result {
+                    Ok(Ok(branches)) => {
+                        for branch in branches {
+                            existing_branches.push(branch.name().to_string());
+                        }
+                    }
+                    Ok(Err(err)) => {
+                        Err::<(), _>(err).log_err();
+                    }
+                    Err(_) => {}
+                }
+            }
+
+            let existing_branch_refs: Vec<&str> =
+                existing_branches.iter().map(|s| s.as_str()).collect();
+            let mut rng = rand::rng();
+            let branch_name =
+                match crate::branch_names::generate_branch_name(&existing_branch_refs, &mut rng) {
+                    Some(name) => name,
+                    None => {
+                        this.update_in(cx, |this, window, cx| {
+                            this.set_worktree_creation_error(
+                                "Failed to generate a branch name: all typewriter names are taken"
+                                    .into(),
+                                window,
+                                cx,
+                            );
+                        })?;
+                        return anyhow::Ok(());
+                    }
+                };
+
+            let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
+                Self::start_worktree_creations(
+                    &git_repos,
+                    &branch_name,
+                    &worktree_directory_setting,
+                    cx,
+                )
+            }) {
+                Ok(Ok(result)) => result,
+                Ok(Err(err)) | Err(err) => {
+                    this.update_in(cx, |this, window, cx| {
+                        this.set_worktree_creation_error(
+                            format!("Failed to validate worktree directory: {err}").into(),
+                            window,
+                            cx,
+                        );
+                    })
+                    .log_err();
+                    return anyhow::Ok(());
+                }
+            };
+
             let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
             {
                 Ok(paths) => paths,

crates/agent_ui/src/agent_ui.rs ๐Ÿ”—

@@ -3,6 +3,7 @@ mod agent_diff;
 mod agent_model_selector;
 mod agent_panel;
 mod agent_registry_ui;
+mod branch_names;
 mod buffer_codegen;
 mod completion_provider;
 mod config_options;

crates/agent_ui/src/branch_names.rs ๐Ÿ”—

@@ -0,0 +1,847 @@
+use collections::HashSet;
+use rand::Rng;
+
+/// Names of historical typewriter brands, for use in auto-generated branch names.
+/// (Hyphens and parens have been dropped so that the branch names are one-word.)
+///
+/// Thanks to https://typewriterdatabase.com/alph.0.brands for the names!
+const TYPEWRITER_NAMES: &[&str] = &[
+    "abeille",
+    "acme",
+    "addo",
+    "adler",
+    "adlerette",
+    "adlerita",
+    "admiral",
+    "agamli",
+    "agar",
+    "agidel",
+    "agil",
+    "aguia",
+    "aguila",
+    "ahram",
+    "aigle",
+    "ajax",
+    "aktiv",
+    "ala",
+    "alba",
+    "albus",
+    "alexander",
+    "alexis",
+    "alfa",
+    "allen",
+    "alonso",
+    "alpina",
+    "amata",
+    "amaya",
+    "amka",
+    "anavi",
+    "anderson",
+    "andina",
+    "antares",
+    "apex",
+    "apsco",
+    "aquila",
+    "archo",
+    "ardita",
+    "argyle",
+    "aristocrat",
+    "aristokrat",
+    "arlington",
+    "armstrong",
+    "arpha",
+    "artus",
+    "astoria",
+    "atlantia",
+    "atlantic",
+    "atlas",
+    "augusta",
+    "aurora",
+    "austro",
+    "automatic",
+    "avanti",
+    "avona",
+    "azzurra",
+    "bajnok",
+    "baldwin",
+    "balkan",
+    "baltica",
+    "baltimore",
+    "barlock",
+    "barr",
+    "barrat",
+    "bartholomew",
+    "bashkiriya",
+    "bavaria",
+    "beaucourt",
+    "beko",
+    "belka",
+    "bennett",
+    "bennington",
+    "berni",
+    "bianca",
+    "bijou",
+    "bing",
+    "bisei",
+    "biser",
+    "bluebird",
+    "bolida",
+    "borgo",
+    "boston",
+    "boyce",
+    "bradford",
+    "brandenburg",
+    "brigitte",
+    "briton",
+    "brooks",
+    "brosette",
+    "buddy",
+    "burns",
+    "burroughs",
+    "byron",
+    "calanda",
+    "caligraph",
+    "cappel",
+    "cardinal",
+    "carissima",
+    "carlem",
+    "carlton",
+    "carmen",
+    "cawena",
+    "cella",
+    "celtic",
+    "century",
+    "champignon",
+    "cherryland",
+    "chevron",
+    "chicago",
+    "cicero",
+    "cifra",
+    "citizen",
+    "claudia",
+    "cleveland",
+    "clover",
+    "coffman",
+    "cole",
+    "columbia",
+    "commercial",
+    "companion",
+    "concentra",
+    "concord",
+    "concordia",
+    "conover",
+    "constanta",
+    "consul",
+    "conta",
+    "contenta",
+    "contimat",
+    "contina",
+    "continento",
+    "cornelia",
+    "coronado",
+    "cosmopolita",
+    "courier",
+    "craftamatic",
+    "crandall",
+    "crown",
+    "culema",
+    "dactyle",
+    "dankers",
+    "dart",
+    "daugherty",
+    "davis",
+    "dayton",
+    "dea",
+    "delmar",
+    "densmore",
+    "depantio",
+    "diadema",
+    "dial",
+    "diamant",
+    "diana",
+    "dictatype",
+    "diplomat",
+    "diskret",
+    "dolfus",
+    "dollar",
+    "domus",
+    "drake",
+    "draper",
+    "duplex",
+    "durabel",
+    "dynacord",
+    "eagle",
+    "eclipse",
+    "edelmann",
+    "edelweiss",
+    "edison",
+    "edita",
+    "edland",
+    "efka",
+    "eldorado",
+    "electa",
+    "electromatic",
+    "elektro",
+    "elgin",
+    "elliot",
+    "emerson",
+    "emka",
+    "emona",
+    "empire",
+    "engadine",
+    "engler",
+    "erfurt",
+    "erika",
+    "esko",
+    "essex",
+    "eureka",
+    "europa",
+    "everest",
+    "everlux",
+    "excelsior",
+    "express",
+    "fabers",
+    "facit",
+    "fairbanks",
+    "faktotum",
+    "famos",
+    "federal",
+    "felio",
+    "fidat",
+    "filius",
+    "fips",
+    "fish",
+    "fitch",
+    "fleet",
+    "florida",
+    "flott",
+    "flyer",
+    "flying",
+    "fontana",
+    "ford",
+    "forto",
+    "fortuna",
+    "fox",
+    "framo",
+    "franconia",
+    "franklin",
+    "friden",
+    "frolio",
+    "furstenberg",
+    "galesburg",
+    "galiette",
+    "gallia",
+    "garbell",
+    "gardner",
+    "geka",
+    "generation",
+    "genia",
+    "geniatus",
+    "gerda",
+    "gisela",
+    "glashutte",
+    "gloria",
+    "godrej",
+    "gossen",
+    "gourland",
+    "grandjean",
+    "granta",
+    "granville",
+    "graphic",
+    "gritzner",
+    "groma",
+    "guhl",
+    "guidonia",
+    "gundka",
+    "hacabo",
+    "haddad",
+    "halberg",
+    "halda",
+    "hall",
+    "hammond",
+    "hammonia",
+    "hanford",
+    "hansa",
+    "harmony",
+    "harris",
+    "hartford",
+    "hassia",
+    "hatch",
+    "heady",
+    "hebronia",
+    "hebros",
+    "hega",
+    "helios",
+    "helma",
+    "herald",
+    "hercules",
+    "hermes",
+    "herold",
+    "heros",
+    "hesperia",
+    "hogar",
+    "hooven",
+    "hopkins",
+    "horton",
+    "hugin",
+    "hungaria",
+    "hurtu",
+    "iberia",
+    "idea",
+    "ideal",
+    "imperia",
+    "impo",
+    "industria",
+    "industrio",
+    "ingersoll",
+    "international",
+    "invicta",
+    "irene",
+    "iris",
+    "iskra",
+    "ivitsa",
+    "ivriah",
+    "jackson",
+    "janalif",
+    "janos",
+    "jolux",
+    "juki",
+    "junior",
+    "juventa",
+    "juwel",
+    "kamkap",
+    "kamo",
+    "kanzler",
+    "kappel",
+    "karli",
+    "karstadt",
+    "keaton",
+    "kenbar",
+    "keystone",
+    "kim",
+    "klein",
+    "kneist",
+    "knoch",
+    "koh",
+    "kolibri",
+    "kolumbus",
+    "komet",
+    "kondor",
+    "koniger",
+    "konryu",
+    "kontor",
+    "kosmopolit",
+    "krypton",
+    "lambert",
+    "lasalle",
+    "lectra",
+    "leframa",
+    "lemair",
+    "lemco",
+    "liberty",
+    "libia",
+    "liga",
+    "lignose",
+    "lilliput",
+    "lindeteves",
+    "linowriter",
+    "listvitsa",
+    "ludolf",
+    "lutece",
+    "luxa",
+    "lyubava",
+    "mafra",
+    "magnavox",
+    "maher",
+    "majestic",
+    "majitouch",
+    "manhattan",
+    "mapuua",
+    "marathon",
+    "marburger",
+    "maritsa",
+    "maruzen",
+    "maskelyne",
+    "masspro",
+    "matous",
+    "mccall",
+    "mccool",
+    "mcloughlin",
+    "mead",
+    "mechno",
+    "mehano",
+    "meiselbach",
+    "melbi",
+    "melior",
+    "melotyp",
+    "mentor",
+    "mepas",
+    "mercedesia",
+    "mercurius",
+    "mercury",
+    "merkur",
+    "merritt",
+    "merz",
+    "messa",
+    "meteco",
+    "meteor",
+    "micron",
+    "mignon",
+    "mikro",
+    "minerva",
+    "mirian",
+    "mirina",
+    "mitex",
+    "molle",
+    "monac",
+    "monarch",
+    "mondiale",
+    "monica",
+    "monofix",
+    "monopol",
+    "monpti",
+    "monta",
+    "montana",
+    "montgomery",
+    "moon",
+    "morgan",
+    "morris",
+    "morse",
+    "moya",
+    "moyer",
+    "munson",
+    "musicwriter",
+    "nadex",
+    "nakajima",
+    "neckermann",
+    "neubert",
+    "neya",
+    "ninety",
+    "nisa",
+    "noiseless",
+    "noor",
+    "nora",
+    "nord",
+    "norden",
+    "norica",
+    "norma",
+    "norman",
+    "north",
+    "nototyp",
+    "nova",
+    "novalevi",
+    "odell",
+    "odhner",
+    "odo",
+    "odoma",
+    "ohio",
+    "ohtani",
+    "oliva",
+    "oliver",
+    "olivetti",
+    "olympia",
+    "omega",
+    "optima",
+    "orbis",
+    "orel",
+    "orga",
+    "oriette",
+    "orion",
+    "orn",
+    "orplid",
+    "pacior",
+    "pagina",
+    "parisienne",
+    "passat",
+    "pearl",
+    "peerless",
+    "perfect",
+    "perfecta",
+    "perkeo",
+    "perkins",
+    "perlita",
+    "pettypet",
+    "phoenix",
+    "piccola",
+    "picht",
+    "pinnock",
+    "pionier",
+    "plurotyp",
+    "plutarch",
+    "pneumatic",
+    "pocket",
+    "polyglott",
+    "polygraph",
+    "pontiac",
+    "portable",
+    "portex",
+    "pozzi",
+    "premier",
+    "presto",
+    "primavera",
+    "progress",
+    "protos",
+    "pterotype",
+    "pullman",
+    "pulsatta",
+    "quick",
+    "racer",
+    "radio",
+    "rally",
+    "rand",
+    "readers",
+    "reed",
+    "referent",
+    "reff",
+    "regent",
+    "regia",
+    "regina",
+    "rekord",
+    "reliable",
+    "reliance",
+    "remagg",
+    "rembrandt",
+    "remer",
+    "remington",
+    "remsho",
+    "remstar",
+    "remtor",
+    "reporters",
+    "resko",
+    "rex",
+    "rexpel",
+    "rheinita",
+    "rheinmetall",
+    "rival",
+    "roberts",
+    "robotron",
+    "rocher",
+    "rochester",
+    "roebuck",
+    "rofa",
+    "roland",
+    "rooy",
+    "rover",
+    "roxy",
+    "roy",
+    "royal",
+    "rundstatler",
+    "sabaudia",
+    "sabb",
+    "saleem",
+    "salter",
+    "sampo",
+    "sarafan",
+    "saturn",
+    "saxonia",
+    "schade",
+    "schapiro",
+    "schreibi",
+    "scripta",
+    "sears",
+    "secor",
+    "selectric",
+    "selekta",
+    "senator",
+    "sense",
+    "senta",
+    "serd",
+    "shilling",
+    "shimade",
+    "shimer",
+    "sholes",
+    "shuang",
+    "siegfried",
+    "siemag",
+    "silma",
+    "silver",
+    "simplex",
+    "simtype",
+    "singer",
+    "smith",
+    "soemtron",
+    "sonja",
+    "speedwriter",
+    "sphinx",
+    "starlet",
+    "stearns",
+    "steel",
+    "stella",
+    "steno",
+    "sterling",
+    "stoewer",
+    "stolzenberg",
+    "stott",
+    "strangfeld",
+    "sture",
+    "stylotyp",
+    "sun",
+    "superba",
+    "superia",
+    "supermetall",
+    "surety",
+    "swintec",
+    "swissa",
+    "talbos",
+    "talleres",
+    "tatrapoint",
+    "taurus",
+    "taylorix",
+    "tell",
+    "tempotype",
+    "tippco",
+    "titania",
+    "tops",
+    "towa",
+    "toyo",
+    "tradition",
+    "transatlantic",
+    "traveller",
+    "trebla",
+    "triumph",
+    "turia",
+    "typatune",
+    "typen",
+    "typorium",
+    "ugro",
+    "ultima",
+    "unda",
+    "underwood",
+    "unica",
+    "unitype",
+    "ursula",
+    "utax",
+    "varityper",
+    "vasanta",
+    "vendex",
+    "venus",
+    "victor",
+    "victoria",
+    "video",
+    "viking",
+    "vira",
+    "virotyp",
+    "visigraph",
+    "vittoria",
+    "volcan",
+    "vornado",
+    "voss",
+    "vultur",
+    "waltons",
+    "wanamaker",
+    "wanderer",
+    "ward",
+    "warner",
+    "waterloo",
+    "waverley",
+    "wayne",
+    "webster",
+    "wedgefield",
+    "welco",
+    "wellington",
+    "wellon",
+    "weltblick",
+    "westphalia",
+    "wiedmer",
+    "williams",
+    "wilson",
+    "winkel",
+    "winsor",
+    "wizard",
+    "woodstock",
+    "woodwards",
+    "yatran",
+    "yost",
+    "zenit",
+    "zentronik",
+    "zeta",
+    "zeya",
+];
+
+/// Picks a typewriter name that isn't already taken by an existing branch.
+///
+/// Each entry in `existing_branches` is expected to be a full branch name
+/// like `"olivetti-a3f9b2c1"`. The prefix before the last `'-'` is treated
+/// as the taken typewriter name. Branches without a `'-'` are ignored.
+///
+/// Returns `None` when every name in the pool is already taken.
+pub fn pick_typewriter_name(
+    existing_branches: &[&str],
+    rng: &mut impl Rng,
+) -> Option<&'static str> {
+    let disallowed: HashSet<&str> = existing_branches
+        .iter()
+        .filter_map(|branch| branch.rsplit_once('-').map(|(prefix, _)| prefix))
+        .collect();
+
+    let available: Vec<&'static str> = TYPEWRITER_NAMES
+        .iter()
+        .copied()
+        .filter(|name| !disallowed.contains(name))
+        .collect();
+
+    if available.is_empty() {
+        return None;
+    }
+
+    let index = rng.random_range(0..available.len());
+    Some(available[index])
+}
+
+/// Generates a branch name like `"olivetti-a3f9b2c1"` by picking a typewriter
+/// name that isn't already taken and appending an 8-character alphanumeric hash.
+///
+/// Returns `None` when every typewriter name in the pool is already taken.
+pub fn generate_branch_name(existing_branches: &[&str], rng: &mut impl Rng) -> Option<String> {
+    let typewriter_name = pick_typewriter_name(existing_branches, rng)?;
+    let hash: String = (0..8)
+        .map(|_| {
+            let idx: u8 = rng.random_range(0..36);
+            if idx < 10 {
+                (b'0' + idx) as char
+            } else {
+                (b'a' + idx - 10) as char
+            }
+        })
+        .collect();
+    Some(format!("{typewriter_name}-{hash}"))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use rand::rngs::StdRng;
+
+    #[gpui::test(iterations = 10)]
+    fn test_pick_typewriter_name_with_no_disallowed(mut rng: StdRng) {
+        let name = pick_typewriter_name(&[], &mut rng);
+        assert!(name.is_some());
+        assert!(TYPEWRITER_NAMES.contains(&name.unwrap()));
+    }
+
+    #[gpui::test(iterations = 10)]
+    fn test_pick_typewriter_name_excludes_taken_names(mut rng: StdRng) {
+        let branch_names = &["olivetti-abc12345", "selectric-def67890"];
+        let name = pick_typewriter_name(branch_names, &mut rng).unwrap();
+        assert_ne!(name, "olivetti");
+        assert_ne!(name, "selectric");
+    }
+
+    #[gpui::test]
+    fn test_pick_typewriter_name_all_taken(mut rng: StdRng) {
+        let branch_names: Vec<String> = TYPEWRITER_NAMES
+            .iter()
+            .map(|name| format!("{name}-00000000"))
+            .collect();
+        let branch_name_refs: Vec<&str> = branch_names.iter().map(|s| s.as_str()).collect();
+        let name = pick_typewriter_name(&branch_name_refs, &mut rng);
+        assert!(name.is_none());
+    }
+
+    #[gpui::test(iterations = 10)]
+    fn test_pick_typewriter_name_ignores_branches_without_hyphen(mut rng: StdRng) {
+        let branch_names = &["main", "develop", "feature"];
+        let name = pick_typewriter_name(branch_names, &mut rng);
+        assert!(name.is_some());
+        assert!(TYPEWRITER_NAMES.contains(&name.unwrap()));
+    }
+
+    #[gpui::test(iterations = 10)]
+    fn test_generate_branch_name_format(mut rng: StdRng) {
+        let branch_name = generate_branch_name(&[], &mut rng).unwrap();
+        let (prefix, suffix) = branch_name.rsplit_once('-').unwrap();
+        assert!(TYPEWRITER_NAMES.contains(&prefix));
+        assert_eq!(suffix.len(), 8);
+        assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
+    }
+
+    #[gpui::test]
+    fn test_generate_branch_name_returns_none_when_exhausted(mut rng: StdRng) {
+        let branch_names: Vec<String> = TYPEWRITER_NAMES
+            .iter()
+            .map(|name| format!("{name}-00000000"))
+            .collect();
+        let branch_name_refs: Vec<&str> = branch_names.iter().map(|s| s.as_str()).collect();
+        let result = generate_branch_name(&branch_name_refs, &mut rng);
+        assert!(result.is_none());
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_generate_branch_name_never_reuses_taken_prefix(mut rng: StdRng) {
+        let existing = &["olivetti-123abc", "selectric-def456"];
+        let branch_name = generate_branch_name(existing, &mut rng).unwrap();
+        let (prefix, _) = branch_name.rsplit_once('-').unwrap();
+        assert_ne!(prefix, "olivetti");
+        assert_ne!(prefix, "selectric");
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_generate_branch_name_avoids_multiple_taken_prefixes(mut rng: StdRng) {
+        let existing = &[
+            "olivetti-aaa11111",
+            "selectric-bbb22222",
+            "corona-ccc33333",
+            "remington-ddd44444",
+            "underwood-eee55555",
+        ];
+        let taken_prefixes: HashSet<&str> = existing
+            .iter()
+            .filter_map(|b| b.rsplit_once('-').map(|(prefix, _)| prefix))
+            .collect();
+        let branch_name = generate_branch_name(existing, &mut rng).unwrap();
+        let (prefix, _) = branch_name.rsplit_once('-').unwrap();
+        assert!(
+            !taken_prefixes.contains(prefix),
+            "generated prefix {prefix:?} collides with an existing branch"
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_generate_branch_name_with_varied_hash_suffixes(mut rng: StdRng) {
+        let existing = &[
+            "olivetti-aaaaaaaa",
+            "olivetti-bbbbbbbb",
+            "olivetti-cccccccc",
+        ];
+        let branch_name = generate_branch_name(existing, &mut rng).unwrap();
+        let (prefix, _) = branch_name.rsplit_once('-').unwrap();
+        assert_ne!(
+            prefix, "olivetti",
+            "should avoid olivetti regardless of how many variants exist"
+        );
+    }
+
+    #[test]
+    fn test_typewriter_names_are_valid() {
+        let mut seen = HashSet::default();
+        for &name in TYPEWRITER_NAMES {
+            assert!(
+                seen.insert(name),
+                "duplicate entry in TYPEWRITER_NAMES: {name:?}"
+            );
+        }
+
+        for window in TYPEWRITER_NAMES.windows(2) {
+            assert!(
+                window[0] <= window[1],
+                "TYPEWRITER_NAMES is not sorted: {0:?} should come after {1:?}",
+                window[1],
+                window[0],
+            );
+        }
+
+        for &name in TYPEWRITER_NAMES {
+            assert!(
+                !name.contains('-'),
+                "TYPEWRITER_NAMES entry contains a hyphen: {name:?}"
+            );
+        }
+
+        for &name in TYPEWRITER_NAMES {
+            assert!(
+                name.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()),
+                "TYPEWRITER_NAMES entry is not lowercase: {name:?}"
+            );
+        }
+    }
+}

typos.toml ๐Ÿ”—

@@ -4,6 +4,9 @@ ignore-hidden = false
 extend-exclude = [
     ".git/",
 
+    # Typewriter model names used for agent branch names aren't typos.
+    "crates/agent_ui/src/branch_names.rs",
+
     # Contributor names aren't typos.
     ".mailmap",