Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 81 additions & 16 deletions crates/tui/src/commands/groups/core/fleet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <provider> <model> [role…]|remove <provider> <model>|setup|teams|workers|save|save-as|list|status|runs|interrupt <worker-id>|resume <run-id>]",
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 <worker-id>|resume <run-id>]\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 \
Comment on lines +54 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Localize the expanded Fleet help prose

When the UI locale is non-English, /fleet help still emits these newly added English paragraphs—including the literal Enter hint—because help_text() constructs them directly instead of using tr(locale, MessageId::...). Move the new prose into localized message IDs and compose command/key tokens in code so the prioritized Fleet surface follows the TUI localization contract.

AGENTS.md reference: crates/tui/AGENTS.md:L25-L26

Useful? React with 👍 / 👎.

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 <provider> <model> [role…] and /fleet remove <provider> <model> 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",
Comment on lines +66 to +68
Comment on lines +66 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not advertise CLI-only restart as a slash action

When a user opens /fleet help, this heading says all following durable-run entries act on the ledger, but operations_for_domain(ControlDomain::Fleet) also includes FleetRestart, whose descriptor is limited to CLI_ONLY; the generated list therefore presents /fleet restart <worker-id> like a usable slash verb even though it always returns an unavailable receipt. Filter operations not offered on the Slash surface or explicitly label restart as CLI-only.

Useful? React with 👍 / 👎.

);
for descriptor in operations_for_domain(ControlDomain::Fleet) {
out.push_str(&format!(
Expand Down Expand Up @@ -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 <worker-id>, or resume <run-id>.."
"Unknown /fleet target '{other}'. Use {} — or /fleet help for the full \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Missing test for unknown target error message

The unknown target error message changed to use PRIMARY_VERBS and points to /fleet help, but no test covers this new string. Add a test that asserts the error message for an unknown verb includes the primary verbs and the help pointer.

verb list.",
PRIMARY_VERBS[..PRIMARY_VERBS.len() - 1].join(", ")
)),
},
}
Expand Down Expand Up @@ -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"));
}

Expand Down Expand Up @@ -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));
Expand All @@ -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
);
}
}
}
22 changes: 21 additions & 1 deletion crates/tui/src/tui/views/fleet_roster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Built-in general filter does not trim id unlike operator filter

The new presentation filter uses m.id.eq_ignore_ascii_case("general") without .trim(), while the operator row filter uses m.id.trim().eq_ignore_ascii_case("operator"). If a built-in member id ever contained surrounding whitespace, the alias would not be folded. Trimming keeps the alias handling consistent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the same trimmed id comparison as the operator filter so the built-in general alias is consistently folded out of presentation even if ids contain surrounding whitespace.

Suggested change
&& !(m.id.eq_ignore_ascii_case("general")
&& !(m.id.trim().eq_ignore_ascii_case("general")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] Inconsistent trimming for id matching

Operator id is checked with trim(), but the general alias check does not trim. If a built-in general id contains surrounding whitespace, it will not be hidden. Recommend using trim() for consistency.

&& m.origin == ProfileOrigin::BuiltIn)
Comment on lines +212 to +213

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Align the id check with the operator check by trimming whitespace before comparing, so built-in general is hidden even if ids carry stray whitespace.

Suggested change
&& !(m.id.eq_ignore_ascii_case("general")
&& m.origin == ProfileOrigin::BuiltIn)
&& !(m.id.trim().eq_ignore_ascii_case("general")
&& m.origin == ProfileOrigin::BuiltIn)

})
.cloned()
.collect(),
shadowed: roster.shadowed().to_vec(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Built-in general alias may still be shown via shadowed list

The filter only removes built-in general from roster.members(), not from roster.shadowed(). If a user-defined general shadows the built-in alias, the built-in entry could appear in the shadowed section, contradicting the goal of folding it out of presentation. Either filter shadowed members as well or verify that built-in aliases never appear in shadowed.

Expand Down
50 changes: 48 additions & 2 deletions crates/tui/src/tui/views/fleet_roster/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
[
Expand All @@ -401,14 +403,58 @@ fn built_in_party_lists_all_members_in_canonical_order() {
"verifier",
"consultant",
"synthesizer",
"general",
"worker",
"planner",
"custom"
]
);
}

/// #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<AgentProfile> = 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
Expand Down
Loading