Use historical typewriter names for agent branch names

Richard Feldman created

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

When picking a name, existing branch names are checked so that taken
typewriter prefixes are excluded. The name selection function accepts
a list of disallowed names for testability, and the RNG is injected
so tests can use a deterministic StdRng via #[gpui::test].

Change summary

crates/agent_ui/src/agent_panel.rs | 161 +++++++++++++++++++++++++++++--
1 file changed, 149 insertions(+), 12 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -52,6 +52,7 @@ use assistant_slash_command::SlashCommandWorkingSet;
 use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
 use client::UserStore;
 use cloud_api_types::Plan;
+use collections::HashSet;
 use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
 use extension::ExtensionEvents;
 use extension_host::ExtensionStore;
@@ -67,7 +68,7 @@ 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 rand::Rng;
 use rules_library::{RulesLibrary, open_rules_library};
 use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, update_settings_file};
@@ -92,6 +93,96 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
 const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
 const DEFAULT_THREAD_TITLE: &str = "New Thread";
 
+const TYPEWRITER_NAMES: &[&str] = &[
+    "adler",
+    "blick",
+    "caligraph",
+    "clipper",
+    "consul",
+    "continental",
+    "coronet",
+    "corsair",
+    "densmore",
+    "dora",
+    "electra",
+    "erika",
+    "everest",
+    "facit",
+    "galaxie",
+    "groma",
+    "halda",
+    "hammond",
+    "hansen",
+    "hermes",
+    "imperial",
+    "kolibri",
+    "lettera",
+    "lexikon",
+    "monarch",
+    "monica",
+    "nakajima",
+    "noiseless",
+    "olivetti",
+    "olympia",
+    "optima",
+    "pluma",
+    "praxis",
+    "remington",
+    "robotron",
+    "royal",
+    "selectric",
+    "skyriter",
+    "splendid",
+    "sterling",
+    "studio",
+    "tippa",
+    "torpedo",
+    "traveller",
+    "triumph",
+    "underwood",
+    "valentine",
+    "voss",
+    "woodstock",
+];
+
+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])
+}
+
+fn generate_agent_branch_name_from_rng(
+    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}"))
+}
+
 fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
     let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
     let key = i64::from(workspace_id).to_string();
@@ -2044,17 +2135,8 @@ 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}")
+        generate_agent_branch_name_from_rng(&[], &mut rng)
+            .expect("should always succeed with no disallowed names")
     }
 
     /// Partitions the project's visible worktrees into git-backed repositories
@@ -4304,9 +4386,64 @@ mod tests {
     use fs::FakeFs;
     use gpui::{TestAppContext, VisualTestContext};
     use project::Project;
+    use rand::rngs::StdRng;
     use serde_json::json;
     use workspace::MultiWorkspace;
 
+    #[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_agent_branch_name_format(mut rng: StdRng) {
+        let branch_name = generate_agent_branch_name_from_rng(&[], &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_agent_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_agent_branch_name_from_rng(&branch_name_refs, &mut rng);
+        assert!(result.is_none());
+    }
+
     #[gpui::test]
     async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
         init_test(cx);