feat(tui): make Fleet roster editing discoverable (#5604)

Add a direct m shortcut from selected named-Fleet members to their model picker, advertise editability in the focused row and footer, and keep the coordinator row read-only. Add provider-free roster coverage for #5589.

Co-authored-by: Isabel Wu <[email protected]>
This commit is contained in:
Isabel WuandIsabel Wu authored and GitHub committed 2026-08-25 01:28:16 -07:00
1 parent 3a50fdca2b
commit 6110159bd0
8 files changed
+138 -4

No files matched your search

+3
View File
@@ -134,6 +134,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Heavy pull requests now run the real Linux workspace nextest, doctest, and
lockfile lanes directly on GitHub Actions instead of reporting a green
placeholder while waiting for a branch-specific CNB mirror (#5547).
- Fleet roster members in a selected Fleet now expose a visible edit affordance
and a direct `m` model-picker shortcut, while the coordinator row remains
read-only (#5589).
- The goal-continuation quiet period (`[goal] continuation_delay_seconds`,
added in #5508) now applies on every dispatch path. Previously the
within-turn dispatch hook fired the next continuation prompt immediately
+3
View File
@@ -134,6 +134,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Heavy pull requests now run the real Linux workspace nextest, doctest, and
lockfile lanes directly on GitHub Actions instead of reporting a green
placeholder while waiting for a branch-specific CNB mirror (#5547).
- Fleet roster members in a selected Fleet now expose a visible edit affordance
and a direct `m` model-picker shortcut, while the coordinator row remains
read-only (#5589).
- The goal-continuation quiet period (`[goal] continuation_delay_seconds`,
added in #5508) now applies on every dispatch path. Previously the
within-turn dispatch hook fired the next continuation prompt immediately
+37
View File
@@ -809,6 +809,43 @@ fn open_fleet_setup_target(app: &mut App, config: &Config, member_id: Option<&st
}
}
fn open_fleet_model_target(app: &mut App, config: &Config, member_id: &str) {
use crate::tui::views::fleet_setup::{FleetSetupEditTarget, resolve_fleet_setup_edit_target};
match resolve_fleet_setup_edit_target(&app.workspace) {
Ok(FleetSetupEditTarget::SelectedFleet { name, scope }) => {
if app.view_stack.top_kind() == Some(ModalKind::FleetDetail) {
return;
}
let Some(mut view) = crate::tui::views::fleet_detail::FleetDetailView::open_for_member(
app,
config,
&name,
scope,
Some(member_id),
) else {
app.set_sticky_status(
"Selected Fleet is invalid or unreadable; open /fleet fleets to repair or clear the selection."
.to_string(),
StatusToastLevel::Error,
None,
);
return;
};
view.open_model_picker();
app.view_stack.push(view);
let fleet_name = crate::safe_label::SafeLabel::phrase(&name);
app.status_message = Some(format!(
"Editing member `{member_id}` in Fleet `{fleet_name}` — choose a model route.",
));
}
Ok(FleetSetupEditTarget::LegacyProfiles) => {
open_fleet_setup_target(app, config, Some(member_id));
}
Err(message) => app.set_sticky_status(message, StatusToastLevel::Error, None),
}
}
pub(crate) struct ProviderFallbackRollback {
identity: ProviderIdentity,
chain: Option<codewhale_config::ProviderChain>,
+3
View File
@@ -1270,6 +1270,9 @@ pub(crate) async fn handle_view_events(
// Fleet is selected.
open_fleet_setup_target(app, config, Some(&member_id));
}
ViewEvent::FleetRosterOpenModelRequested { member_id } => {
open_fleet_model_target(app, config, &member_id);
}
ViewEvent::FleetListOpenDetailRequested { name, scope } => {
if app.view_stack.top_kind() != Some(ModalKind::FleetDetail) {
if let Some(view) = crate::tui::views::fleet_detail::FleetDetailView::open(
+11
View File
@@ -269,6 +269,17 @@ impl FleetDetailView {
}
}
/// Open this member-focused editor directly on its model route picker.
/// The roster uses this for its `m` shortcut so model switching does not
/// require a second, undiscoverable key once the detail view opens.
pub(crate) fn open_model_picker(&mut self) {
let target = match self.selected_member_idx() {
Some(idx) => PickTarget::Member(idx),
None => PickTarget::Operator,
};
self.open_route_picker(target);
}
fn apply_route_pick(&mut self) -> Option<ViewAction> {
let route = self.routes.get(self.pick_row)?;
match self.pick_target {
+21 -4
View File
@@ -202,14 +202,18 @@ impl FleetRosterView {
} else {
"setup profile"
};
vec![
let mut hints = vec![
ActionHint::new("↑/↓", "move"),
ActionHint::new("s/Enter", edit_label),
ActionHint::new("f", "saved Fleets"),
ActionHint::new("w", tr(self.locale, MessageId::FleetRosterWorkers)),
ActionHint::new("PgUp/PgDn", "scroll detail"),
ActionHint::new("Esc", "close"),
]
];
if self.selected_fleet.is_some() && !self.operator_selected() {
hints.insert(2, ActionHint::new("m", "model"));
}
hints
}
}
@@ -247,6 +251,14 @@ impl ModalView for FleetRosterView {
ViewAction::None
}
}
KeyCode::Char('m') if self.selected_fleet.is_some() => {
let Some(member) = self.selected_member() else {
return ViewAction::None;
};
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenModelRequested {
member_id: member.id.clone(),
})
}
KeyCode::Char('w') => {
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested)
}
@@ -418,6 +430,11 @@ impl FleetRosterView {
// glance even before the detail pane opens.
let species = member_species(member);
let badge_cells = whales::BADGE_WIDTH + 1;
let edit_marker = if is_selected && self.selected_fleet.is_some() {
"[edit] "
} else {
""
};
let member_name = member
.display_name
.as_deref()
@@ -428,7 +445,7 @@ impl FleetRosterView {
|name| format!("{name} ({})", member.id),
);
let text = format!(
"{pointer}{mark} {}{} {}",
"{pointer}{edit_marker}{mark} {}{} {}",
member_name,
shadow_badge.as_deref().unwrap_or(""),
member_routing(member)
@@ -439,7 +456,7 @@ impl FleetRosterView {
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let split = pointer.len() + mark.len() + 1;
let split = pointer.len() + edit_marker.len() + mark.len() + 1;
let (head, tail) = if text.len() >= split && text.is_char_boundary(split) {
text.split_at(split)
} else {
@@ -52,6 +52,15 @@ fn view_with_overrides() -> FleetRosterView {
}
}
fn selected_fleet_view() -> FleetRosterView {
let mut view = view_with_overrides();
view.selected_fleet = Some(SelectedFleetSummary {
name: "Default".to_string(),
scope: crate::fleet::store::FleetScope::Personal,
});
view
}
fn render_through_stack(make: impl Fn() -> FleetRosterView, w: u16, h: u16) -> Vec<String> {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
@@ -176,6 +185,50 @@ fn enter_and_s_open_the_setup_wizard_for_members_only() {
}
}
#[test]
fn m_opens_the_selected_member_model_picker_only_for_a_named_fleet() {
let mut view = built_in_view();
view.handle_key(key(KeyCode::Down));
assert!(matches!(
view.handle_key(key(KeyCode::Char('m'))),
ViewAction::None
));
let mut view = selected_fleet_view();
assert!(matches!(
view.handle_key(key(KeyCode::Char('m'))),
ViewAction::None
));
view.handle_key(key(KeyCode::Down));
assert!(matches!(
view.handle_key(key(KeyCode::Char('m'))),
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenModelRequested { ref member_id })
if member_id == "manager"
));
}
#[test]
fn selected_named_fleet_member_shows_edit_affordance() {
let rows = render_through_stack(
|| {
let mut view = selected_fleet_view();
view.selected = 1;
view
},
100,
30,
);
let text = rows.join("\n");
assert!(
text.contains("[edit]"),
"focused member should advertise editing: {text}"
);
assert!(
text.contains("m model"),
"footer should advertise the model shortcut: {text}"
);
}
#[test]
fn w_opens_the_live_workers_tab() {
let mut view = built_in_view();
+7
View File
@@ -927,6 +927,13 @@ pub enum ViewEvent {
/// identify which row the operator selected.
member_id: String,
},
/// Emitted by the `/fleet` roster `m` shortcut to open the selected
/// member's exact Fleet editor directly on its model picker.
FleetRosterOpenModelRequested {
/// Exact Fleet member id; roles are not unique and therefore cannot
/// identify which row the operator selected.
member_id: String,
},
/// Open the live workers tab from the unified Fleet surface.
FleetRosterOpenWorkersRequested,