From c9cfdb5668d6074f310510cc7e0a9683d6a61c6b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 31 Aug 2026 22:40:49 +0100 Subject: [PATCH] feat(maestro): one-word handles, quieter project boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session handles are one word from a single 218-entry list — instruments, forms, notation and theory, tempo and dynamics — replacing the adjective-noun pair. A batch is shuffled, so it repeats only once the wordlist runs out. Project boxes show the parent directory instead of the full path: the last segment is already the name line above it. The last command is gone from the box, and picking a project no longer pre-selects it — the directory and what runs in it are independent choices. Both changes land in the TUI picker too, which mirrors the web form's rules. Co-Authored-By: Claude Opus 5 (1M context) --- crates/maestro-daemon/tests/local_api.rs | 11 +- crates/maestro-names/src/lib.rs | 262 ++++++++++++++++++++--- crates/maestro-protocol/src/lib.rs | 2 +- crates/maestro-tui/src/draft.rs | 46 ++-- web/src/multiview.test.ts | 2 +- web/src/paths.test.ts | 18 +- web/src/paths.ts | 9 + web/src/projectPicker.test.ts | 24 ++- web/src/spawnForm.ts | 24 ++- web/src/spawnFormNames.test.ts | 4 +- 10 files changed, 325 insertions(+), 77 deletions(-) diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index 74a615e..bc44136 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -232,17 +232,14 @@ async fn projects_returns_empty_for_a_disabled_scanner() { assert!(projects.is_empty()); } -/// #53: every returned handle splits into a known adjective and noun. +/// #53: every returned handle is one word from the shared list. fn assert_valid_names(batch: &maestro_protocol::NameBatch) { for name in &batch.names { - let (adj, noun) = name.split_once('-').unwrap_or_else(|| { - panic!("handle {name:?} is not adjective-noun"); - }); + assert!(!name.contains('-'), "handle {name:?} is not one word"); assert!( - maestro_names::ADJECTIVES.contains(&adj), - "{adj} not in ADJECTIVES" + maestro_names::WORDS.contains(&name.as_str()), + "{name} not in WORDS" ); - assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS"); } } diff --git a/crates/maestro-names/src/lib.rs b/crates/maestro-names/src/lib.rs index 9f88c72..862f750 100644 --- a/crates/maestro-names/src/lib.rs +++ b/crates/maestro-names/src/lib.rs @@ -1,10 +1,195 @@ -//! The one home of the session-handle wordlists (#53). +//! The one home of the session-handle wordlist (#53). //! //! Previously duplicated in `web/src/names.ts`; every client now gets handles //! from here, served by `GET /api/names`. -/// Musical adjectives — tempo, dynamics, and articulation markings. -pub const ADJECTIVES: &[&str] = &[ +/// Every word a handle can be: instruments, forms, and the vocabulary of +/// tempo, dynamics, notation and theory. One word per handle — the list is +/// wide enough that a single word still reads as distinct. +pub const WORDS: &[&str] = &[ + // Instruments + "cello", + "violin", + "viola", + "oboe", + "flute", + "piccolo", + "clarinet", + "bassoon", + "trumpet", + "trombone", + "tuba", + "horn", + "cornet", + "bugle", + "flugelhorn", + "euphonium", + "saxophone", + "recorder", + "timpani", + "harp", + "piano", + "organ", + "lyre", + "lute", + "mandolin", + "banjo", + "ukulele", + "guitar", + "sitar", + "koto", + "shamisen", + "balalaika", + "zither", + "dulcimer", + "kantele", + "erhu", + "guzheng", + "pipa", + "duduk", + "ocarina", + "kalimba", + "harmonica", + "accordion", + "bandoneon", + "bagpipe", + "harpsichord", + "clavichord", + "celesta", + "theremin", + "marimba", + "xylophone", + "vibraphone", + "glockenspiel", + "snare", + "cymbal", + "tambourine", + "castanet", + "djembe", + "tabla", + "bodhran", + "gong", + "chimes", + "contrabass", + "shawm", + "crumhorn", + "sackbut", + "didgeridoo", + // Forms and pieces + "sonata", + "fugue", + "canon", + "prelude", + "overture", + "rondo", + "nocturne", + "scherzo", + "etude", + "ballade", + "barcarolle", + "bagatelle", + "caprice", + "chaconne", + "passacaglia", + "toccata", + "intermezzo", + "interlude", + "minuet", + "gavotte", + "sarabande", + "gigue", + "allemande", + "courante", + "mazurka", + "polonaise", + "waltz", + "bolero", + "habanera", + "tarantella", + "fandango", + "serenade", + "aubade", + "requiem", + "cantata", + "oratorio", + "madrigal", + "motet", + "chorale", + "hymn", + "anthem", + "lullaby", + "elegy", + "rhapsody", + "symphony", + "concerto", + "fantasia", + "divertimento", + "quartet", + "quintet", + "sextet", + "septet", + "octet", + "trio", + // Structure, notation and theory + "octave", + "chord", + "cadence", + "cadenza", + "coda", + "motif", + "refrain", + "verse", + "chorus", + "bridge", + "finale", + "arpeggio", + "glissando", + "ostinato", + "counterpoint", + "descant", + "harmony", + "melody", + "rhythm", + "tempo", + "timbre", + "treble", + "clef", + "staff", + "quaver", + "crotchet", + "semibreve", + "fermata", + "trill", + "mordent", + "appoggiatura", + "portamento", + "vibrato", + "triplet", + "tuplet", + "downbeat", + "upbeat", + "anacrusis", + "syncopation", + "modulation", + "transposition", + "unison", + "semitone", + "tritone", + "diapason", + "tonic", + "dominant", + "mediant", + "ionian", + "dorian", + "phrygian", + "lydian", + "mixolydian", + "aeolian", + "locrian", + "pentatonic", + "chromatic", + "diatonic", + // Tempo, dynamics and articulation "allegro", "andante", "adagio", @@ -15,6 +200,7 @@ pub const ADJECTIVES: &[&str] = &[ "lento", "moderato", "crescendo", + "diminuendo", "staccato", "legato", "forte", @@ -35,60 +221,78 @@ pub const ADJECTIVES: &[&str] = &[ "sotto", "vivo", "feroce", -]; - -/// Musical nouns — instruments and forms from the concert hall. -pub const NOUNS: &[&str] = &[ - "cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone", - "tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata", - "octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda", - "motif", "overture", + "giocoso", + "affettuoso", + "risoluto", + "leggiero", + "pesante", + "morendo", + "accelerando", + "ritardando", ]; /// Upper bound for a `GET /api/names?count=N` batch. pub const MAX_BATCH: usize = 100; -/// A fresh `adjective-noun` handle, e.g. `allegro-cello`. +/// A fresh one-word handle, e.g. `nocturne`. pub fn generate() -> String { use rand::seq::SliceRandom; let mut rng = rand::thread_rng(); - format!( - "{}-{}", - ADJECTIVES.choose(&mut rng).unwrap(), - NOUNS.choose(&mut rng).unwrap() - ) + (*WORDS.choose(&mut rng).unwrap()).to_string() } -/// `count` fresh handles (the caller clamps; this only trusts its input). +/// `count` fresh handles, distinct while the wordlist lasts (the caller +/// clamps; this only trusts its input). pub fn generate_batch(count: usize) -> Vec { - (0..count).map(|_| generate()).collect() + use rand::seq::SliceRandom; + let mut rng = rand::thread_rng(); + let mut pool: Vec<&str> = WORDS.to_vec(); + pool.shuffle(&mut rng); + (0..count) + .map(|i| match pool.get(i) { + // Past the wordlist a batch has to repeat itself. + Some(word) => (*word).to_string(), + None => generate(), + }) + .collect() } #[cfg(test)] mod tests { use super::*; + use std::collections::HashSet; #[test] - fn generated_name_is_adjective_dash_noun() { + fn generated_name_is_a_single_known_word() { let name = generate(); - let (adj, noun) = name.split_once('-').expect("name contains a dash"); - assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES"); - assert!(NOUNS.contains(&noun), "{noun} not in NOUNS"); + assert!(!name.contains('-'), "{name} is not one word"); + assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS"); } #[test] - fn batch_returns_the_requested_count() { + fn the_wordlist_has_no_duplicates_and_covers_a_full_batch() { + let unique: HashSet<&&str> = WORDS.iter().collect(); + assert_eq!(unique.len(), WORDS.len(), "WORDS has duplicates"); + assert!( + WORDS.len() >= MAX_BATCH, + "a full batch would have to repeat" + ); + } + + #[test] + fn batch_returns_the_requested_count_without_repeats() { assert_eq!(generate_batch(1).len(), 1); - assert_eq!(generate_batch(50).len(), 50); - for name in generate_batch(10) { - let (adj, noun) = name.split_once('-').unwrap(); - assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun)); + let batch = generate_batch(MAX_BATCH); + assert_eq!(batch.len(), MAX_BATCH); + for name in &batch { + assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS"); } + let unique: HashSet<&String> = batch.iter().collect(); + assert_eq!(unique.len(), batch.len(), "batch repeated a handle"); } #[test] fn rerolls_can_differ() { - // 30x30=900 combinations; 20 draws all matching has probability ~1e-40. let first = generate(); let differs = (0..20).any(|_| generate() != first); assert!(differs); diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 2e6cdfa..9a1c81b 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -506,7 +506,7 @@ pub struct DaemonInfo { } /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh -/// `adjective-noun` session handles generated by the `maestro-names` crate. +/// one-word session handles generated by the `maestro-names` crate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NameBatch { pub names: Vec, diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 94eb770..420842d 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -18,8 +18,9 @@ //! //! The picker itself is two steps (#96), verbatim from the rules in //! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment: -//! 1. Step one is one box per repository (name, then full path). A worktree -//! is a box only when its main checkout is missing from the scan. +//! 1. Step one is one box per repository (name, then its parent directory). +//! A worktree is a box only when its main checkout is missing from the +//! scan. //! 2. The filter keeps a repository when the query matches it or any of its //! worktrees, so a worktree name still finds its box. //! 3. Picking a box reveals step two: "+ new", then that repository's @@ -32,6 +33,8 @@ //! these are separate acts. //! 6. Neither step probes `/api/gitroot`; the scan already reports //! repository membership. Only the free-text hatch pays for a probe. +//! 7. Neither step touches the command: the directory and what runs in it +//! are independent choices. use std::collections::VecDeque; use std::path::{Path, PathBuf}; @@ -39,6 +42,7 @@ use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; use maestro_config::{builtin_presets, custom_preset}; +use maestro_protocol::display::abbreviate_dir; use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; @@ -128,6 +132,15 @@ pub struct DraftWorktreeRow { pub stale: bool, } +/// A box's second column: the directory holding the project, `~`-abbreviated. +/// Only the stem — the last segment is the name column beside it. +fn parent_dir_label(path: &Path, home: &str) -> String { + match path.parent() { + Some(parent) => abbreviate_dir(parent, home), + None => String::new(), + } +} + /// The repository key a [`DraftGroup`] was formed under — same key /// `projects::group_projects` groups by, kept separately because the main /// box's own path only equals it when the main checkout itself was found. @@ -893,13 +906,6 @@ impl DraftPane { self.worktree_choice = choice.clone(); self.picked_cwd = Some(target.path.clone()); self.cwd_raw_visible = false; - if let Some(cmd) = target.last_command.as_deref() { - if !cmd.is_empty() { - if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) { - self.selected_preset = idx; - } - } - } match choice { WorktreeChoice::New => { self.worktree_enabled = true; @@ -1200,9 +1206,9 @@ impl DraftPane { cursor = Some((col, filter_row.y)); } - // Step one (#96 rule 1): one box per repository, name then the FULL - // path — not the abbreviated form other lists use — so two projects - // sharing a basename stay distinguishable. + // Step one (#96 rule 1): one box per repository, name then the parent + // directory it sits in. The last segment is the name column already; + // the stem is what keeps two projects sharing a basename apart. let visible = self.visible_groups(); let sel = self.selection(); let count = visible.len() + 1; @@ -1241,7 +1247,10 @@ impl DraftPane { Span::styled(if active { ">" } else { " " }, base), Span::raw(" "), Span::styled(format!("{: { it("splits on the last slash, keeping it with the directory", () => { @@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => { expect(longestCommonPrefix(["Alpha", "alpha"])).toBe(""); }); }); + +describe("parentDir", () => { + it("drops the last segment", () => { + expect(parentDir("/home/user/tea/maestro")).toBe("/home/user/tea"); + expect(parentDir("/home/user/tea/maestro/")).toBe("/home/user/tea"); + }); + + it("keeps the root for a top-level path", () => { + expect(parentDir("/maestro")).toBe("/"); + expect(parentDir("/")).toBe("/"); + }); + + it("is empty for a bare name", () => { + expect(parentDir("maestro")).toBe(""); + }); +}); diff --git a/web/src/paths.ts b/web/src/paths.ts index 6551906..54a50b5 100644 --- a/web/src/paths.ts +++ b/web/src/paths.ts @@ -19,3 +19,12 @@ export function longestCommonPrefix(items: string[]): string { } return lcp; } + +/** Everything above the last segment of an absolute path: `/a/b/c` → `/a/b`. + * A top-level path keeps `/`, and a trailing slash is ignored. */ +export function parentDir(path: string): string { + const trimmed = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path; + const slash = trimmed.lastIndexOf("/"); + if (slash <= 0) return slash === 0 ? "/" : ""; + return trimmed.slice(0, slash); +} diff --git a/web/src/projectPicker.test.ts b/web/src/projectPicker.test.ts index 6932566..8766b67 100644 --- a/web/src/projectPicker.test.ts +++ b/web/src/projectPicker.test.ts @@ -27,7 +27,7 @@ function stubFetch(info: unknown, projects: Project[]): void { : url.includes("/api/projects") ? projects : url.includes("/api/names") - ? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] } + ? { names: ["cello", "fugue", "lyre"] } : undefined; return new Response(JSON.stringify(body), { status: 200 }); }), @@ -79,14 +79,22 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => expect(boxes[0].classList.contains("is-stale")).toBe(false); }); - it("shows the name as the primary line and the full path unabbreviated", async () => { + it("shows the name as the primary line and its parent directory below", async () => { const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" }); const form = (await mount([p])).el; const box = form.querySelector(".project-box")!; expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config"); - // Not `~/t/nixos-config`: the abbreviation hid what distinguishes two - // projects sharing a basename (#95). - expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config"); + // Only the stem: repeating the last segment would just echo the name. Not + // `~/t`, since the stem is what distinguishes two projects sharing a + // basename (#95). + expect(box.querySelector(".project-path")!.textContent).toBe("~/tea"); + }); + + it("keeps the last command out of the box", async () => { + const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" }); + const form = (await mount([p])).el; + const box = form.querySelector(".project-box")!; + expect(box.textContent).not.toContain("claude"); }); it("lists only main checkouts as boxes — worktrees belong to step two", async () => { @@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => expect(form.querySelector(".picker-empty")).toBeTruthy(); }); - it("picking a project selects its cwd and pre-selects its last command", async () => { + it("picking a project selects its cwd and leaves the command alone", async () => { const p = project({ name: "maestro", path: "/home/user/maestro", - last_command: "claude", + last_command: "aider", }); const onChange = vi.fn(); stubFetch( @@ -157,6 +165,8 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => const values = form.querySelector("input[name=name]")!.value; const last = onChange.mock.lastCall![0]; expect(last.cwd).toBe("/home/user/maestro"); + // The directory and the command are independent choices: the preset stays + // where it was, whatever the project was last run with. expect(last.command).toBe("claude"); expect(values).toBeTruthy(); // The raw input stays hidden — the picked box is the source of truth. diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 96fa2a1..9a45e5f 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -8,8 +8,10 @@ import { gitRoot, } from "./api.js"; import { attachDirAutocomplete } from "./dirAutocomplete.js"; +import { parentDir } from "./paths.js"; import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; import type { ProjectGroup } from "./projectList.js"; +import { abbreviateCwd } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; /** Where a worktree's branch comes from: freshly created (default) or picked @@ -440,8 +442,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // ---- working-directory picker (#95) ---------------------------------- // Two steps, and the TUI mirror (#96) must match these rules verbatim: - // 1. Step one is one box per repository (name, then full path). A worktree - // is a box only when its main checkout is missing from the scan. + // 1. Step one is one box per repository (name, then its parent directory). + // A worktree is a box only when its main checkout is missing from the + // scan. // 2. The filter keeps a repository when the query matches it or any of its // worktrees, so a worktree name still finds its box. // 3. Picking a box reveals step two: "+ new", then that repository's @@ -454,6 +457,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // these are separate acts — #91's rule, now an explicit choice. // 6. Neither step probes /api/gitroot; the scan already reports repository // membership. Only the free-text hatch pays for a probe. + // 7. Neither step touches the command: the directory and what runs in it + // are independent choices. /** "new" (create a worktree) or the path of an existing worktree to run in. */ type WorktreeChoice = "new" | string; @@ -544,9 +549,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { wtHint.hidden = true; } emit(); - // The chosen directory pre-selects the command it was last used with. - const match = presets.find((p) => p.command === target.last_command && p.command !== ""); - if (match) selectPreset(match.id); } /** Groups with at least one member (itself or a worktree) matching `query`. */ @@ -634,14 +636,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`; box.dataset.cwd = project.path; const trees = worktreesOf(group).length; - // The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what - // tells two projects sharing a basename apart (#95). - const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""] - .filter(Boolean) - .join(" · "); + // The parent directory only — the last segment is the name line above it. + // Unabbreviated beyond `~`, since the stem is what tells two projects + // sharing a basename apart (#95). + const parent = abbreviateCwd(parentDir(project.path), homeDir); + const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : ""; box.innerHTML = ` ${escapeHtml(project.name)} - ${escapeHtml(project.path)} + ${escapeHtml(parent)} ${escapeHtml(meta)}`; box.addEventListener("click", () => selectProject(group)); return box; diff --git a/web/src/spawnFormNames.test.ts b/web/src/spawnFormNames.test.ts index 50aab84..ebe69db 100644 --- a/web/src/spawnFormNames.test.ts +++ b/web/src/spawnFormNames.test.ts @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; -const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"]; -const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"]; +const BATCH_A = ["cello", "fugue", "lyre"]; +const BATCH_B = ["oboe", "tuba", "harp"]; /** Stub the three fetches the form's initial load makes. Records every * /api/names URL so tests can assert how many batches were fetched. */