mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
3a50fdca2b
commit
6110159bd0
8 files changed
+138
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in new issue
Block a user