From 89b005b4b33bcc0fc8fccaebfeed3be746a5071b Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Sat, 5 Sep 2026 17:47:31 -0700 Subject: [PATCH] feat(tui): prioritize the Fleet menu surface (#5888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /fleet usage line advertised 14 verbs at once, mixing member/model setup, saved teams, session workers, and durable run control. It now presents the prioritized core only — /fleet [members|setup|teams| workers|help] — and every other verb stays dispatchable, documented one level deeper in /fleet help under explicit groups: team model routes (models/add/remove), saved routes (save/save-as), and the durable run control plane (data-driven via operations_for_domain). The unknown-target error now points at the core set. The roster's default surface showed the legacy built-in general alias next to worker — the same posture twice. from_parts now folds only the untouched built-in alias out of presentation (12 -> 11 rows); a user-authored general (config/personal/project origin, incl. saved-team members) stays visible, and dispatch keeps resolving general through FleetRoster::get and the identity selector alias. Local gates, honestly: cargo check -p codewhale-tui CLEAN. The three directly modified command tests pass (fleet_aliases_are_registered_on_ command_info, fleet_usage_presents_a_prioritized_core_and_help_keeps_ the_rest_reachable, slash_command_and_cli_agree_on_fleet_verb_ids). This machine's 8MB hard stack limit aborts several pre-existing fleet tests that construct the live config/catalog (two proven byte-identical at baseline via stash; CI runs the same suite green on all three OSes), so hosted CI is the authoritative gate for this PR. Signed-off-by: CodeWhale Bot --- crates/tui/src/commands/groups/core/fleet.rs | 97 ++++++++++++++++--- crates/tui/src/tui/views/fleet_roster.rs | 22 ++++- .../tui/src/tui/views/fleet_roster/tests.rs | 50 +++++++++- 3 files changed, 150 insertions(+), 19 deletions(-) diff --git a/crates/tui/src/commands/groups/core/fleet.rs b/crates/tui/src/commands/groups/core/fleet.rs index 8f97766f94..e4c8c9e44e 100644 --- a/crates/tui/src/commands/groups/core/fleet.rs +++ b/crates/tui/src/commands/groups/core/fleet.rs @@ -8,6 +8,11 @@ //! `/fleet list|status|interrupt|resume` are control-plane verbs that run //! against the **durable** workspace ledger through the shared contract in //! `codewhale-lane`, exactly as `codewhale fleet …` does (#1888, #4022). +//! #5888: the usage line presents only the prioritized core verbs — open the +//! team, author it, switch saved teams, watch session workers, ask for help. +//! Model-route verbs, saved-route verbs, and the durable control plane stay +//! dispatchable and live one level deeper, documented in `/fleet help`, +//! instead of all fourteen competing in one usage string. //! //! `/fleet status` used to show the current TUI session's sub-agents. That was //! a different thing wearing the same name: session sub-agents are not the @@ -26,26 +31,41 @@ use crate::tui::app::{App, AppAction}; use super::CommandResult; +/// The verbs `/fleet` advertises up front (#5888): open the team, author it, +/// switch saved teams, watch current-session workers, ask for the rest. +/// Every other verb stays dispatchable; it is documented one level deeper in +/// `/fleet help` instead of competing for attention in the usage line. +const PRIMARY_VERBS: [&str; 5] = ["members", "setup", "teams", "workers", "help"]; + +const PRIMARY_USAGE: &str = "/fleet [members|setup|teams|workers|help]"; + pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { name: "fleet", aliases: &[], - usage: "/fleet [members|models|add [role…]|remove |setup|teams|workers|save|save-as|list|status|runs|interrupt |resume ]", + usage: PRIMARY_USAGE, description_id: MessageId::CmdFleetDescription, }; pub(in crate::commands) struct FleetCmd; fn help_text() -> String { - let mut out = String::from( - "Usage: /fleet [members|setup|teams|workers|save|save-as|list|status|runs|interrupt |resume ]\n\n\ - The fleet is who is working right now. /fleet (or /fleet members) opens the roster — \ - each member's role, model, and access. /fleet setup opens the authoring wizard. \ - /fleet teams (or fleets/saved/manage) switches between named saved teams.\n\n\ - /fleet list, status, interrupt, and resume act on the durable .codewhale/fleet.jsonl \ - ledger for this workspace — the same records `codewhale fleet` reads and writes. \ - /fleet workers (and /subagents) shows sub-agents in the current TUI session only, which \ - is a different set: it does not include durable fleet runs. the ledger file, saved rosters, and config \ - tables keep the Fleet name.\n", + let mut out = format!( + "Usage: {PRIMARY_USAGE}\n\n\ + The fleet is who is working right now. The primary verbs cover the daily loop:\n\ + /fleet (or /fleet members) opens the roster — each member's role, model, and access; \ + Enter on a member row opens that member's editor. /fleet setup opens the authoring \ + wizard. /fleet teams (or fleets/saved/manage) switches between named saved teams. \ + /fleet workers (and /subagents) shows sub-agents in the current TUI session only, \ + which is a different set: it does not include durable fleet runs.\n\n\ + Advanced — team model routes (still typed, one level deeper):\n\ + /fleet models lists the selected team's models. \ + /fleet add [role…] and /fleet remove edit them.\n\n\ + Advanced — saved routes:\n\ + /fleet save persists the current session route into the selected team's operator. \ + /fleet save-as saves it as a new team.\n\n\ + Durable runs — these act on the durable .codewhale/fleet.jsonl ledger for this \ + workspace, the same records `codewhale fleet` reads and writes. the ledger file, \ + saved rosters, and config tables keep the Fleet name:\n", ); for descriptor in operations_for_domain(ControlDomain::Fleet) { out.push_str(&format!( @@ -282,8 +302,9 @@ impl RegisterCommand for FleetCmd { other => match ControlOperation::parse_verb(ControlDomain::Fleet, other) { Some(operation) => run_control(app, operation, target), None => CommandResult::error(format!( - "Unknown /fleet target '{other}'. Use members, setup, teams, list, status, \ - workers, interrupt , or resume .." + "Unknown /fleet target '{other}'. Use {} — or /fleet help for the full \ + verb list.", + PRIMARY_VERBS[..PRIMARY_VERBS.len() - 1].join(", ") )), }, } @@ -640,7 +661,14 @@ mod tests { assert!(FleetCmd::info().aliases.is_empty()); assert!(FleetCmd::info().usage.contains("teams")); assert!(FleetCmd::info().usage.contains("workers")); - assert!(FleetCmd::info().usage.contains("save-as")); + // #5888: the usage line is the prioritized core; advanced verbs stay + // dispatchable but are documented in /fleet help, not here. + for advanced in ["save-as", "interrupt", "resume", "models", "status"] { + assert!( + !FleetCmd::info().usage.contains(advanced), + "usage must not advertise {advanced}" + ); + } assert!(!FleetCmd::info().usage.contains("pods")); } @@ -670,9 +698,11 @@ mod tests { for descriptor in operations_for_domain(ControlDomain::Fleet) { assert_eq!(descriptor.slash_command, COMMAND_INFO.name); assert_eq!(descriptor.hotbar_action_id(), "slash.fleet"); + // #5888: usage presents only the prioritized core; every durable + // control verb is documented one level deeper in /fleet help. assert!( - COMMAND_INFO.usage.contains(descriptor.verb) || descriptor.verb == "restart", - "/fleet usage must document {} or declare it CLI-only", + help_text().contains(&format!("/fleet {}", descriptor.verb)), + "/fleet help must document {} now that usage does not", descriptor.verb ); assert!(descriptor.offers(ControlSurface::Cli)); @@ -682,4 +712,39 @@ mod tests { "/fleet must stay directly runnable from the palette and hotbar" ); } + + /// #5888: the primary surface is a small, prioritized set, and every + /// advanced verb remains named by `/fleet help` — nothing is + /// unreachable, it just lives one level deeper. + #[test] + fn fleet_usage_presents_a_prioritized_core_and_help_keeps_the_rest_reachable() { + let verbs: Vec<&str> = FleetCmd::info() + .usage + .trim_start_matches("/fleet [") + .trim_end_matches(']') + .split('|') + .collect(); + assert_eq!( + verbs, PRIMARY_VERBS, + "the usage line is exactly the primary set" + ); + + let help = help_text(); + for advanced in [ + "/fleet models", + "/fleet add", + "/fleet remove", + "/fleet save", + "/fleet save-as", + ] { + assert!(help.contains(advanced), "help must name {advanced}"); + } + for descriptor in operations_for_domain(ControlDomain::Fleet) { + assert!( + help.contains(&format!("/fleet {}", descriptor.verb)), + "help must keep {} reachable", + descriptor.verb + ); + } + } } diff --git a/crates/tui/src/tui/views/fleet_roster.rs b/crates/tui/src/tui/views/fleet_roster.rs index 973c03e385..ed5cfed446 100644 --- a/crates/tui/src/tui/views/fleet_roster.rs +++ b/crates/tui/src/tui/views/fleet_roster.rs @@ -14,6 +14,11 @@ //! named Fleet is selected (the operator row is display-only). Switch named //! saved Fleets with `/fleet fleets` (`/fleet fleets` remains compatible). //! +//! #5888: the default lineup folds the built-in `general` alias out of +//! presentation — it is the same posture as `worker` and stays dispatchable +//! (roster lookup and the identity selector both still resolve it) — so the +//! default surface is 11 rows: the live operator plus ten members. +//! //! NOTE: like `fleet_setup.rs`, the copy below is intentionally English for //! now (#3167 reworks Fleet UI localization); the command entry //! (`CmdFleetDescription`) is already localized. @@ -191,7 +196,22 @@ impl FleetRosterView { members: roster .members() .iter() - .filter(|m| !m.id.trim().eq_ignore_ascii_case("operator")) + .filter(|m| { + !m.id.trim().eq_ignore_ascii_case("operator") + // #5888: `general` is the legacy alias of the `worker` + // posture. The engine roster keeps it dispatchable — + // Agent tool type tokens, saved configs, and replayed + // transcripts resolve `general`, and the identity + // selector maps the alias to the worker member — but + // the default lineup presents one row per posture. + // Only the untouched built-in alias folds away: a + // user-authored `general` (config/personal/project + // origin, including saved-team members, which carry + // Personal/Workspace origin by construction) is the + // user's own member and stays visible. + && !(m.id.eq_ignore_ascii_case("general") + && m.origin == ProfileOrigin::BuiltIn) + }) .cloned() .collect(), shadowed: roster.shadowed().to_vec(), diff --git a/crates/tui/src/tui/views/fleet_roster/tests.rs b/crates/tui/src/tui/views/fleet_roster/tests.rs index c6fcb510ea..fb099fbb86 100644 --- a/crates/tui/src/tui/views/fleet_roster/tests.rs +++ b/crates/tui/src/tui/views/fleet_roster/tests.rs @@ -390,7 +390,9 @@ fn built_in_party_lists_all_members_in_canonical_order() { let view = built_in_view(); let ids: Vec<&str> = view.members.iter().map(|m| m.id.as_str()).collect(); // The operator is rendered as the pinned session row, not a member - // (#dogfood 0.8.67), so it is intentionally absent from this list. + // (#dogfood 0.8.67), so it is intentionally absent from this list. The + // built-in `general` alias is folded out of presentation (#5888) — same + // posture as `worker`, still dispatchable (see the alias tests below). assert_eq!( ids, [ @@ -401,7 +403,6 @@ fn built_in_party_lists_all_members_in_canonical_order() { "verifier", "consultant", "synthesizer", - "general", "worker", "planner", "custom" @@ -409,6 +410,51 @@ fn built_in_party_lists_all_members_in_canonical_order() { ); } +/// #5888: the default lineup folds the legacy built-in `general` alias — the +/// same posture as `worker` — out of presentation, while dispatch (roster +/// lookup, identity selector alias) keeps resolving it. +#[test] +fn default_roster_folds_the_legacy_general_alias_out_of_presentation() { + let view = built_in_view(); + let ids: Vec<&str> = view.members.iter().map(|m| m.id.as_str()).collect(); + assert!( + !ids.contains(&"general"), + "the alias must not be presented: {ids:?}" + ); + assert!(ids.contains(&"worker"), "the posture's primary name stays"); + assert_eq!(view.row_count(), 11, "operator plus ten members"); + + // Engine compat is untouched: the alias still resolves for dispatch. + let roster = FleetRoster::built_ins_only(); + assert!(roster.get("general").is_some(), "alias stays dispatchable"); + assert!(roster.get("worker").is_some()); +} + +/// #5888: only the untouched built-in alias folds away. A `general` the user +/// actually defined — a saved-team member carries Personal/Workspace origin, +/// like `roster_from_fleet` produces — is the user's own member and stays +/// visible. +#[test] +fn user_defined_general_member_stays_visible() { + let mut members: Vec = FleetRoster::built_ins_only() + .members() + .iter() + .filter(|m| m.id == "worker" || m.id == "general") + .cloned() + .collect(); + for member in members.iter_mut().filter(|m| m.id == "general") { + member.origin = ProfileOrigin::Personal; + member.source = PathBuf::from("fleets/Default.toml"); + } + let view = FleetRosterView::from_parts(operator(), FleetRoster::from_members(members), None); + let ids: Vec<&str> = view.members.iter().map(|m| m.id.as_str()).collect(); + assert!( + ids.contains(&"general") && ids.contains(&"worker"), + "a user-defined general is presented: {ids:?}" + ); + assert_eq!(view.row_count(), 3, "operator plus both members"); +} + #[test] fn detail_shows_access_model_and_saved_for() { // Built-in reviewer: read-only files, full shell for its bounded