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

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
This commit is contained in:
Richard Feldman 2026-03-04 23:47:11 -05:00 committed by GitHub
parent c0f5d21a88
commit 33c78fad33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 915 additions and 35 deletions

View file

@ -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,

View file

@ -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;

View file

@ -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:?}"
);
}
}
}

View file

@ -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",