Make Fleet role model and thinking assignment direct and guarded

Reuse the shared model picker from the roster, preserve role context on cancellation, and review profile changes before writing. Preserve non-routing fields and reject stale or externally managed profiles. Consolidate launch fixture writes below the original blocking-call budget and include the PTY output tail on startup failures.

Validation: 139 unique focused Fleet tests qualified (137 passed initially; 33 correction/regression tests passed); TUI Clippy passed. npm test: 551 passed, 0 failed; check:web: 821 routes built. Blocking-call budget passed, launch fixture 4 sites <= original 8. New real-PTY coverage awaits the stamped optimized build.
This commit is contained in:
CodeWhale Bot committed 2026-09-19 23:43:29 -07:00
1 parent 5b30305f8b
commit a500ab4a0b
10 files changed
+1011 -136

No files matched your search

+178 -41
View File
@@ -185,6 +185,10 @@ enum Pane {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ModelPickerPurpose {
Session,
FleetProfileRoute {
editor_id: uuid::Uuid,
initial_reasoning: Option<ReasoningEffort>,
},
FleetRoute {
target: FleetRouteTarget,
editor_id: uuid::Uuid,
@@ -257,6 +261,7 @@ pub struct ModelPickerView {
catalog_action_hitbox: RefCell<Option<Rect>>,
catalog_action_hovered: bool,
purpose: ModelPickerPurpose,
assignment_context: Option<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -366,14 +371,54 @@ impl ModelPickerView {
target: FleetRouteTarget,
editor_id: uuid::Uuid,
selection: FleetRouteSelection,
) -> Self {
Self::new_for_assignment(
app,
config,
ModelPickerPurpose::FleetRoute {
target,
editor_id,
initial_reasoning: selection.reasoning,
allow_inherit: selection.allow_inherit,
},
selection,
)
}
pub fn new_for_fleet_profile(
app: &App,
config: &Config,
editor_id: uuid::Uuid,
selection: FleetRouteSelection,
) -> Self {
Self::new_for_assignment(
app,
config,
ModelPickerPurpose::FleetProfileRoute {
editor_id,
initial_reasoning: selection.reasoning,
},
selection,
)
}
pub fn with_assignment_context(
mut self,
title: impl Into<String>,
scope: impl Into<String>,
) -> Self {
self.assignment_context = Some((title.into(), scope.into()));
self
}
fn new_for_assignment(
app: &App,
config: &Config,
purpose: ModelPickerPurpose,
selection: FleetRouteSelection,
) -> Self {
let mut picker = Self::new(app, config);
picker.purpose = ModelPickerPurpose::FleetRoute {
target,
editor_id,
initial_reasoning: selection.reasoning,
allow_inherit: selection.allow_inherit,
};
picker.purpose = purpose;
// Session browsing memory is unrelated to the row being edited.
picker.query.clear();
picker.view = ModelListView::Configured;
@@ -410,8 +455,10 @@ impl ModelPickerView {
}
fn apply_fleet_route_rows(&mut self, app: &App, config: &Config) {
let ModelPickerPurpose::FleetRoute { allow_inherit, .. } = self.purpose else {
return;
let allow_inherit = match self.purpose {
ModelPickerPurpose::Session => return,
ModelPickerPurpose::FleetRoute { allow_inherit, .. } => allow_inherit,
ModelPickerPurpose::FleetProfileRoute { .. } => true,
};
self.route_config.provider = Some(self.initial_provider_identity.clone());
self.configured_providers = configured_providers(config, self.initial_provider)
@@ -559,6 +606,7 @@ impl ModelPickerView {
catalog_action_hitbox: RefCell::new(None),
catalog_action_hovered: false,
purpose: ModelPickerPurpose::Session,
assignment_context: None,
};
view.restore_memory(app.model_picker_memory.as_ref());
view
@@ -692,7 +740,7 @@ impl ModelPickerView {
let mut rows: Vec<_> = visible
.iter()
.map(|row| PaneRow {
primary: if matches!(self.purpose, ModelPickerPurpose::FleetRoute { .. })
primary: if self.purpose != ModelPickerPurpose::Session
&& row.id == "auto"
&& row.provider.is_none()
{
@@ -846,7 +894,7 @@ impl ModelPickerView {
);
// The ordinary setup wizard switches the session after auth. A
// Fleet edit must keep that session route intact.
if matches!(self.purpose, ModelPickerPurpose::FleetRoute { .. }) {
if self.purpose != ModelPickerPurpose::Session {
return ViewAction::Emit(ViewEvent::StatusMessage { message });
}
// Prefer opening provider setup so the user can remediate in one step.
@@ -895,6 +943,9 @@ impl ModelPickerView {
}
if let ModelPickerPurpose::FleetRoute {
initial_reasoning, ..
}
| ModelPickerPurpose::FleetProfileRoute {
initial_reasoning, ..
} = self.purpose
&& self.resolved_model() == "auto"
{
@@ -1150,38 +1201,53 @@ impl ModelPickerView {
/// Fleet row gets its absolute route — provider resolved, `Custom` named
/// by its exact identity — and has no startup default to save.
fn build_apply_event(&self, save_as_startup_default: bool) -> ViewEvent {
match self.purpose {
let (initial_reasoning, allow_inherit) = match self.purpose {
ModelPickerPurpose::Session => {
self.build_event_with_startup_default(save_as_startup_default)
return self.build_event_with_startup_default(save_as_startup_default);
}
ModelPickerPurpose::FleetRoute {
target,
editor_id,
initial_reasoning,
allow_inherit,
} => {
let provider = self.resolved_provider().unwrap_or(self.initial_provider);
let provider_id = (provider == ApiProvider::Custom).then(|| {
self.resolved_provider_identity()
.unwrap_or_else(|| self.route_config.provider_identity_for(provider))
});
ViewEvent::FleetRoutePicked {
target,
..
} => (initial_reasoning, allow_inherit),
ModelPickerPurpose::FleetProfileRoute {
initial_reasoning, ..
} => (initial_reasoning, true),
};
let provider = self.resolved_provider().unwrap_or(self.initial_provider);
let provider_id = (provider == ApiProvider::Custom).then(|| {
self.resolved_provider_identity()
.unwrap_or_else(|| self.route_config.provider_identity_for(provider))
});
let model = self.resolved_model();
let reasoning = if !allow_inherit {
None
} else if model == "auto" || self.selected_effort_request == self.initial_effort {
initial_reasoning
} else {
Some(self.selected_effort_request)
};
match self.purpose {
ModelPickerPurpose::FleetRoute {
target, editor_id, ..
} => ViewEvent::FleetRoutePicked {
target,
editor_id,
provider,
provider_id,
model,
reasoning,
},
ModelPickerPurpose::FleetProfileRoute { editor_id, .. } => {
ViewEvent::FleetProfileRoutePicked {
editor_id,
provider,
provider_id,
model: self.resolved_model(),
reasoning: if !allow_inherit {
None
} else if self.resolved_model() == "auto"
|| self.selected_effort_request == self.initial_effort
{
initial_reasoning
} else {
Some(self.selected_effort_request)
},
model,
reasoning,
}
}
ModelPickerPurpose::Session => unreachable!("session handled above"),
}
}
@@ -1189,13 +1255,15 @@ impl ModelPickerView {
fn apply_action_id(&self) -> MessageId {
match self.purpose {
ModelPickerPurpose::Session => MessageId::PickerActionApply,
ModelPickerPurpose::FleetRoute { .. } => MessageId::PickerActionAssignRoute,
ModelPickerPurpose::FleetRoute { .. }
| ModelPickerPurpose::FleetProfileRoute { .. } => MessageId::PickerActionAssignRoute,
}
}
fn can_edit_effort(&self) -> bool {
match self.purpose {
ModelPickerPurpose::Session => true,
ModelPickerPurpose::FleetProfileRoute { .. } => self.resolved_model() != "auto",
// Shortlisted rows pin a model only; inherited routes retain
// their existing reasoning until a concrete model is selected.
ModelPickerPurpose::FleetRoute { allow_inherit, .. } => {
@@ -3696,8 +3764,17 @@ impl ModalView for ModelPickerView {
}
// Esc carries the browsing context out so the next open can
// restore it (#4109 picker memory).
KeyCode::Esc if matches!(self.purpose, ModelPickerPurpose::FleetRoute { .. }) => {
ViewAction::Close
KeyCode::Esc if !self.query.is_empty() => {
self.update_query(String::new());
ViewAction::None
}
KeyCode::Esc if self.purpose != ModelPickerPurpose::Session => {
let editor_id = match self.purpose {
ModelPickerPurpose::FleetRoute { editor_id, .. }
| ModelPickerPurpose::FleetProfileRoute { editor_id, .. } => editor_id,
ModelPickerPurpose::Session => unreachable!(),
};
ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { editor_id })
}
KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed {
catalog_view: self.view.browses_all_providers(),
@@ -3923,8 +4000,14 @@ impl ModelPickerView {
ModelListView::Configured => tr(self.locale, MessageId::RouteBrowseCatalog),
other => other.next().title_label().into(),
};
let title = tr(self.locale, MessageId::RouteSurfaceTitle)
.replace("{view}", self.view.title_label());
let title = self
.assignment_context
.as_ref()
.map(|(role, _)| format!("Model · {role}"))
.unwrap_or_else(|| {
tr(self.locale, MessageId::RouteSurfaceTitle)
.replace("{view}", self.view.title_label())
});
// The catalog is a visible action on the existing title rail, with
// no extra row taken from short terminals. Keep the shortcut too.
let action_label = crate::tui::ui_text::semantic_truncate(
@@ -3932,7 +4015,13 @@ impl ModelPickerView {
usize::from(area.width.saturating_sub(20)),
);
let action_width = unicode_width::UnicodeWidthStr::width(action_label.as_str()) as u16;
let show_action = area.width >= 28 && area.height > 0;
let show_action = area.width >= 28
&& area.height > 0
&& (self.assignment_context.is_none()
|| unicode_width::UnicodeWidthStr::width(title.as_str())
+ usize::from(action_width)
+ 8
<= usize::from(area.width));
let title = if show_action {
crate::tui::ui_text::semantic_truncate(
&title,
@@ -4017,7 +4106,12 @@ impl ModelPickerView {
.split(content);
Paragraph::new(Line::from(vec![
Span::styled(
format!("{} ", tr(self.locale, MessageId::RouteProviderLabel)),
self.assignment_context
.as_ref()
.map(|(_, scope)| format!("{scope} · "))
.unwrap_or_else(|| {
format!("{} ", tr(self.locale, MessageId::RouteProviderLabel))
}),
Style::default().fg(palette::TEXT_MUTED),
),
Span::styled(
@@ -4035,7 +4129,11 @@ impl ModelPickerView {
Style::default().fg(palette::STATUS_WARNING),
),
Span::styled(
catalog_freshness_title_suffix(),
if self.assignment_context.is_some() {
""
} else {
catalog_freshness_title_suffix()
},
Style::default().fg(palette::TEXT_MUTED),
),
]))
@@ -4495,6 +4593,7 @@ mod tests {
catalog_action_hitbox: RefCell::new(None),
catalog_action_hovered: false,
purpose: ModelPickerPurpose::Session,
assignment_context: None,
}
}
@@ -4571,6 +4670,44 @@ mod tests {
));
}
#[test]
fn profile_role_picker_assigns_only_its_owner_and_search_cancels_before_closing() {
let editor_id = uuid::Uuid::new_v4();
let mut picker = test_picker().with_assignment_context("reviewer", "Choose where to save");
picker.purpose = ModelPickerPurpose::FleetProfileRoute {
editor_id,
initial_reasoning: None,
};
assert!(
matches!(picker.build_apply_event(false), ViewEvent::FleetProfileRoutePicked {
editor_id: owner, provider: ApiProvider::Openai, model, ..
} if owner == editor_id && model == "model")
);
for (width, height) in [(40, 12), (80, 24), (140, 40)] {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
picker.render(area, &mut buf);
let text: String = buf.content().iter().map(|cell| cell.symbol()).collect();
assert!(text.contains("reviewer"), "{text}");
assert!(text.contains("Choose where to save"), "{text}");
}
picker.update_query("model".into());
assert!(matches!(
picker.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
ViewAction::None
));
assert!(picker.query.is_empty());
assert!(
matches!(picker.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { editor_id: owner }) if owner == editor_id)
);
picker.model_rows[0].selectable = false;
*picker.projection.get_mut() = None;
assert!(matches!(
picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
ViewAction::Emit(ViewEvent::StatusMessage { .. })
));
}
#[test]
fn fleet_locked_builtin_explains_setup_without_switching_the_session() {
let mut picker = test_picker();
@@ -4643,7 +4780,7 @@ mod tests {
assert_eq!((app.api_provider, app.model.clone()), session_route);
assert!(matches!(
picker.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
ViewAction::Close
ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { .. })
));
for query in [
"openrouter:new-fixture-model",
+82 -2
View File
@@ -157,7 +157,7 @@ use super::slash_menu::{
apply_slash_menu_selection, partial_inline_skill_mention_at_cursor,
try_autocomplete_slash_command, visible_slash_menu_entries,
};
use super::views::{ConfigView, ContextMenuAction, HelpView, ModalKind, ViewEvent};
use super::views::{ConfigView, ContextMenuAction, HelpView, ModalKind, ViewAction, ViewEvent};
use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview};
use super::widgets::{ChatWidget, ComposerWidget, Renderable};
@@ -832,7 +832,7 @@ fn open_fleet_setup_target(app: &mut App, config: &Config, member_id: Option<&st
if app.view_stack.top_kind() == Some(ModalKind::FleetDetail) {
return;
}
let Some(view) = crate::tui::views::fleet_detail::FleetDetailView::open_for_member(
let Some(mut view) = crate::tui::views::fleet_detail::FleetDetailView::open_for_member(
app, config, &name, scope, member_id,
) else {
app.set_sticky_status(
@@ -844,7 +844,22 @@ fn open_fleet_setup_target(app: &mut App, config: &Config, member_id: Option<&st
return;
};
let fleet_name = crate::safe_label::SafeLabel::phrase(&name);
let picker = if member_id.is_some() {
let (editor_id, target) = view.direct_assignment();
let (role, scope) = view.assignment_context();
view.route_selection(editor_id, target).map(|selection| {
crate::tui::model_picker::ModelPickerView::new_for_fleet_route(
app, config, target, editor_id, selection,
)
.with_assignment_context(role, scope)
})
} else {
None
};
app.view_stack.push(view);
if let Some(picker) = picker {
app.view_stack.push(picker);
}
app.status_message = Some(format!(
"Editing selected team `{fleet_name}` ({}) — legacy profiles will not be changed.",
scope.label()
@@ -854,6 +869,30 @@ fn open_fleet_setup_target(app: &mut App, config: &Config, member_id: Option<&st
if app.view_stack.top_kind() == Some(ModalKind::FleetSetup) {
return;
}
if let Some(member_id) = member_id {
match crate::tui::views::fleet_setup::FleetSetupView::new_for_route_assignment(
app, config, member_id,
) {
Ok(view) => {
if let ViewAction::Emit(ViewEvent::FleetProfileRoutePickRequested {
editor_id,
}) = view.route_pick_request()
&& let Some(selection) = view.route_selection(editor_id)
{
let (role, scope) = view.assignment_context();
let picker =
crate::tui::model_picker::ModelPickerView::new_for_fleet_profile(
app, config, editor_id, selection,
)
.with_assignment_context(role, scope);
app.view_stack.push(view);
app.view_stack.push(picker);
}
}
Err(reason) => app.set_sticky_status(reason, StatusToastLevel::Error, None),
}
return;
}
let _ = app.next_draft_gen();
let view = match member_id {
Some(member_id) => crate::tui::views::fleet_setup::FleetSetupView::new_for_role(
@@ -2311,3 +2350,44 @@ fn completed_turn_cost_route_receipt(
#[cfg(test)]
mod tests;
#[cfg(test)]
#[test]
fn fleet_role_entry_opens_shared_picker_and_cancel_restores_parked_roster() {
use crate::tui::views::ModalView;
let _env = crate::test_support::lock_test_env();
let workspace = tempfile::tempdir().unwrap();
let config = Config::default();
let mut app = App::new(
crate::test_support::test_tui_options(workspace.path()),
&config,
);
app.view_stack
.push(crate::tui::views::fleet_roster::FleetRosterView::new(
&app, &config,
));
open_fleet_setup_target(&mut app, &config, Some("manager"));
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker));
let mut picker = app.view_stack.pop().unwrap();
let action = picker
.as_any_mut()
.downcast_mut::<crate::tui::model_picker::ModelPickerView>()
.unwrap()
.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));
let ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { editor_id }) = action
else {
panic!("assignment cancel must identify its editor")
};
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::FleetSetup));
handlers::dismiss_fleet_assignment(&mut app, editor_id);
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::FleetRoster));
assert!(
!workspace
.path()
.join(".codewhale/agents/manager.toml")
.exists()
);
}
+115 -3
View File
@@ -50,6 +50,27 @@ pub(crate) fn refresh_parked_fleet_roster(app: &mut App, config: &Config) {
app.view_stack.push_boxed(view);
}
pub(super) fn dismiss_fleet_assignment(app: &mut App, editor_id: uuid::Uuid) {
if let Some(mut boxed) = app.view_stack.pop() {
let remove = if let Some(view) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>(
) {
view.route_selection(editor_id).is_some()
} else if let Some(view) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::views::fleet_detail::FleetDetailView>(
) {
view.is_direct_assignment(editor_id)
} else {
false
};
if !remove {
app.view_stack.push_boxed(boxed);
}
}
}
/// Once per event-loop iteration: deliver a pending fleet mutation to the
/// engine and clear the flag.
pub(crate) fn flush_stale_fleet_roster(
@@ -1704,6 +1725,84 @@ pub(crate) async fn handle_view_events(
)
.await;
}
ViewEvent::FleetRosterOpenCoordinatorRequested => {
app.view_stack.push(
crate::tui::model_picker::ModelPickerView::new(app, config)
.with_assignment_context("Coordinator", "Current session"),
);
}
ViewEvent::FleetProfileRoutePickRequested { editor_id } => {
if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
&& let Some(mut boxed) = app.view_stack.pop()
{
let selection = boxed
.as_any_mut()
.downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
.and_then(|view| {
view.route_selection(editor_id)
.map(|selection| (selection, view.assignment_context()))
});
app.view_stack.push_boxed(boxed);
if let Some((selection, (role, scope))) = selection {
app.view_stack.push(
crate::tui::model_picker::ModelPickerView::new_for_fleet_profile(
app, config, editor_id, selection,
)
.with_assignment_context(role, scope),
);
}
}
}
ViewEvent::FleetProfileRoutePicked {
editor_id,
provider,
provider_id,
model,
reasoning,
} => {
if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
&& let Some(mut boxed) = app.view_stack.pop()
{
if let Some(view) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>(
) {
view.accept_route(
editor_id,
provider_id.unwrap_or_else(|| provider.as_str().into()),
model,
reasoning,
);
}
app.view_stack.push_boxed(boxed);
}
}
ViewEvent::FleetProfileRouteCommitRequested { editor_id } => {
if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
&& let Some(mut boxed) = app.view_stack.pop()
{
let result = boxed
.as_any_mut()
.downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
.map(|view| view.commit_route_assignment(editor_id, app, config));
match result {
Some(Ok(message)) => {
sync_fleet_roster(app, config, engine_handle);
refresh_parked_fleet_roster(app, config);
app.push_status_toast(message, StatusToastLevel::Success, Some(8_000));
}
Some(Err(reason)) => {
app.view_stack.push_boxed(boxed);
app.set_sticky_status(reason, StatusToastLevel::Error, None);
}
None => app.view_stack.push_boxed(boxed),
}
}
}
ViewEvent::FleetAssignmentPickerDismissed { editor_id } => {
dismiss_fleet_assignment(app, editor_id);
refresh_parked_fleet_roster(app, config);
}
ViewEvent::FleetRosterOpenSetupRequested { member_id } => {
// The shared router opens the selected v2 Fleet's exact editor
// (focused on this member) or the legacy wizard when no named
@@ -1738,17 +1837,21 @@ pub(crate) async fn handle_view_events(
let selection = editor
.as_any_mut()
.downcast_mut::<crate::tui::views::fleet_detail::FleetDetailView>()
.and_then(|view| view.route_selection(editor_id, target));
.and_then(|view| {
view.route_selection(editor_id, target)
.map(|selection| (selection, view.assignment_context()))
});
app.view_stack.push_boxed(editor);
selection
} else {
None
};
if let Some(selection) = selection {
if let Some((selection, (role, scope))) = selection {
app.view_stack.push(
crate::tui::model_picker::ModelPickerView::new_for_fleet_route(
app, config, target, editor_id, selection,
),
)
.with_assignment_context(role, scope),
);
}
}
@@ -1787,6 +1890,13 @@ pub(crate) async fn handle_view_events(
app.view_stack.push_boxed(boxed);
match outcome {
Some(Ok(message)) => {
if let Some(mut editor) = app.view_stack.pop() {
let direct = editor.as_any_mut().downcast_mut::<crate::tui::views::fleet_detail::FleetDetailView>()
.is_some_and(|view| view.is_direct_assignment(editor_id));
if !direct {
app.view_stack.push_boxed(editor);
}
}
app.push_status_toast(message, StatusToastLevel::Success, Some(8_000));
sync_fleet_roster(app, config, engine_handle);
refresh_parked_fleet_roster(app, config);
@@ -2198,6 +2308,7 @@ pub(crate) async fn handle_view_events(
save_as_startup_default,
)
.await;
refresh_parked_fleet_roster(app, config);
}
ViewEvent::ModelPickerDismissed {
catalog_view,
@@ -2210,6 +2321,7 @@ pub(crate) async fn handle_view_events(
view: Some(view),
selected_row_id,
});
refresh_parked_fleet_roster(app, config);
}
ViewEvent::ModelPickerRefresh => {
// Re-resolve readiness from the live credential state and
+25 -4
View File
@@ -91,6 +91,7 @@ struct RouteRow {
pub struct FleetDetailView {
fleet: FleetFile,
editor_id: uuid::Uuid,
pub(crate) return_to_roster: bool,
saved_source: Option<String>,
locale: Locale,
scope: FleetScope,
@@ -164,13 +165,12 @@ impl FleetDetailView {
&session_provider,
&session_model,
);
if let Some(member_id) = member_id.map(str::trim).filter(|id| !id.is_empty())
&& let Some(index) = view
if let Some(member_id) = member_id.map(str::trim).filter(|id| !id.is_empty()) {
let index = view
.fleet
.members
.iter()
.position(|member| member.id.eq_ignore_ascii_case(member_id))
{
.position(|member| member.id.eq_ignore_ascii_case(member_id))?;
view.selected = index + 1;
}
Some(view)
@@ -193,6 +193,7 @@ impl FleetDetailView {
let mut view = Self {
fleet,
editor_id: uuid::Uuid::new_v4(),
return_to_roster: false,
saved_source,
locale,
scope,
@@ -402,6 +403,26 @@ impl FleetDetailView {
}
}
pub(crate) fn direct_assignment(&mut self) -> (uuid::Uuid, FleetRouteTarget) {
self.return_to_roster = true;
(self.editor_id, self.selected_route_target())
}
pub(crate) fn assignment_context(&self) -> (String, String) {
let role = self
.selected_member()
.map(|member| member.id.clone())
.unwrap_or_else(|| "Coordinator".into());
(
role,
format!("{} · {}", self.fleet.name, self.scope.label()),
)
}
pub(crate) fn is_direct_assignment(&self, editor_id: uuid::Uuid) -> bool {
self.return_to_roster && self.editor_id == editor_id
}
pub(crate) fn route_selection(
&self,
editor_id: uuid::Uuid,
+53 -46
View File
@@ -9,9 +9,9 @@
//! `[fleet.profiles]` config < `$CODEWHALE_HOME/agents/*.toml` personal <
//! `.codewhale/agents/*.toml` project members)
//! as a scrollable list with a detail pane for the selected row. The view
//! never writes anything; `s` / Enter on a selected-v2 member opens that
//! Fleet's exact editor, while the legacy profile wizard is used only when no
//! named Fleet is selected (the operator row is display-only). Switch named
//! never writes anything; Enter opens the shared model/thinking picker for
//! the selected role, retaining this roster underneath. The existing saved
//! team/profile owner validates and persists assignments. Switch named
//! saved Fleets with `/fleet fleets` (`/fleet fleets` remains compatible).
//!
//! #5888: the default lineup folds the built-in `general` alias out of
@@ -311,11 +311,9 @@ impl FleetRosterView {
// Carry the exact member the operator already chose. The host
// focuses it in the selected v2 Fleet editor, or starts legacy
// setup from its member id when no Fleet is selected.
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { member_id })
ViewAction::Emit(ViewEvent::FleetRosterOpenSetupRequested { member_id })
} else {
// The operator is not a wizard-authored profile; its route changes
// via /model or /provider (the detail pane says so).
ViewAction::None
ViewAction::Emit(ViewEvent::FleetRosterOpenCoordinatorRequested)
}
}
@@ -335,17 +333,10 @@ impl FleetRosterView {
/// destination this room has that is not a tab. Detail scrolling
/// (PgUp/PgDn) works but is not advertised — the pane is short now.
fn footer_hints(&self) -> Vec<ActionHint> {
let edit_label = if self.selected_fleet.is_some() {
"edit"
} else {
"setup"
};
let mut hints = vec![ActionHint::new("↑↓", "move")];
// The Coordinator is display-only, matching activate_selected.
// Advertise edit/setup only when Enter has a real member target.
if self.selected_member().is_some() {
hints.push(ActionHint::new("Enter", edit_label));
}
let mut hints = vec![
ActionHint::new("↑↓", "move"),
ActionHint::new("Enter", "model & thinking"),
];
hints.extend([
ActionHint::new("Tab", tr(self.locale, MessageId::FleetRosterWorkers)),
ActionHint::new("f", "saved teams"),
@@ -612,6 +603,9 @@ impl FleetRosterView {
let hovered = !is_selected && self.hovered_row.get() == Some(idx);
let hover_tint = || menu_style::hovered_row_style();
let pointer = format!("{} ", crate::tui::glyphs::selection_marker(is_selected));
let shadow_badge = idx.checked_sub(1).and_then(|index| {
member_shadow_badge(self.locale, &self.members[index], &self.shadowed)
});
let (text, base_style) = if idx == 0 {
(
format!(
@@ -623,12 +617,6 @@ impl FleetRosterView {
} else {
let member = &self.members[idx - 1];
let mark = member_role_mark(member);
let shadow_badge = member_shadow_badge(self.locale, member, &self.shadowed);
let edit_marker = if is_selected && self.selected_fleet.is_some() {
"[edit] "
} else {
""
};
let member_name = member
.display_name
.as_deref()
@@ -638,28 +626,43 @@ impl FleetRosterView {
|| member.id.clone(),
|name| format!("{name} ({})", member.id),
);
// The list identifies a member. Repeating inherited session
// routing on every row buries that identity; the selected
// inspector always states the full route. Only overrides earn
// secondary list ink. Whale identity lives in that inspector.
let has_model_override = member
.profile
.model
.as_deref()
.is_some_and(|model| !model.trim().is_empty());
let route = if has_model_override || member.profile.loadout.as_str() != "inherit" {
format!(" · {}", member_routing(member))
} else {
String::new()
};
(
format!(
"{pointer}{edit_marker}{mark} {member_name}{}{route}",
"{pointer}{mark} {member_name}{}",
shadow_badge.as_deref().unwrap_or("")
),
Style::default().fg(palette::TEXT_PRIMARY),
)
};
let text = if list_width >= 28 && shadow_badge.is_none() {
let route = if idx == 0 {
self.operator.model.as_str()
} else {
let profile = &self.members[idx - 1].profile;
profile
.model
.as_deref()
.filter(|model| !model.trim().is_empty())
.unwrap_or_else(|| {
if profile.loadout.as_str() == "inherit" {
"follow Coordinator"
} else {
profile.loadout.as_str()
}
})
};
let role_width = (list_width / 2).clamp(14, 24);
let label = truncate_view_text(&text, role_width);
let pad = role_width
.saturating_sub(unicode_width::UnicodeWidthStr::width(label.as_str()));
format!(
"{label}{} {}",
" ".repeat(pad),
truncate_view_text(route, list_width.saturating_sub(role_width + 2))
)
} else {
text
};
let style = if is_selected {
menu_style::selected_row_style()
} else if hovered {
@@ -821,9 +824,8 @@ fn operator_detail_lines(operator: &OperatorInfo) -> Vec<Line<'static>> {
detail_field(
&mut lines,
"Description",
"The Coordinator is this Fleet's leader — your main session model. Every \
member below works for it. Change the model with /model or /provider; \
persist with /fleet save."
"The Coordinator leads this session. Press Enter to change its model and thinking. \
Roles set to follow the Coordinator use this route; pinned roles keep their own models."
.to_string(),
);
lines.push(Line::from(Span::styled(
@@ -865,10 +867,6 @@ fn member_access_summary(member: &AgentProfile) -> String {
/// When the loadout is `fast`, show that the runtime picks the **fast sibling
/// of the active session model** — not a stale on-disk profile name — so the
/// roster matches what Fleet will actually launch.
fn member_routing(member: &AgentProfile) -> String {
member_routing_with_session(member, None)
}
fn member_routing_with_session(member: &AgentProfile, session_model: Option<&str>) -> String {
if let Some(model) = member
.profile
@@ -991,6 +989,15 @@ fn member_detail_lines_with_session(
None => model,
};
detail_field(&mut lines, "Model", route);
detail_field(
&mut lines,
"Thinking",
member
.profile
.reasoning_effort
.clone()
.unwrap_or_else(|| "Follow Coordinator".to_string()),
);
// Slot is internal dispatch vocabulary and duplicates Role — never shown.
detail_field(&mut lines, "Access", member_access_summary(member));
+21 -18
View File
@@ -22,9 +22,7 @@ fn mouse(kind: MouseEventKind, area: Rect) -> MouseEvent {
fn setup_member_id(action: ViewAction) -> Option<String> {
match action {
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { member_id }) => {
Some(member_id)
}
ViewAction::Emit(ViewEvent::FleetRosterOpenSetupRequested { member_id }) => Some(member_id),
_ => None,
}
}
@@ -227,20 +225,22 @@ fn bare_paging_drives_rows_not_the_detail_pane() {
}
#[test]
fn enter_opens_the_setup_wizard_for_members_only() {
// Operator row: display-only, no wizard hand-off.
fn enter_opens_role_assignment_and_keeps_the_roster_underneath() {
// Coordinator changes the current session through the shared picker.
let mut view = built_in_view();
assert!(view.operator_selected());
assert!(
matches!(view.handle_key(key(KeyCode::Enter)), ViewAction::None),
"Enter must be inert on the operator row"
matches!(
view.handle_key(key(KeyCode::Enter)),
ViewAction::Emit(ViewEvent::FleetRosterOpenCoordinatorRequested)
),
"Enter opens the Coordinator model picker"
);
// Member row: hands off to the setup wizard.
// Member assignment retains this roster on the view stack.
view.handle_key(key(KeyCode::Down));
let action = view.handle_key(key(KeyCode::Enter));
let ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { member_id }) = action
else {
let ViewAction::Emit(ViewEvent::FleetRosterOpenSetupRequested { member_id }) = action else {
panic!("Enter should hand off to the setup wizard");
};
assert_eq!(member_id, "manager");
@@ -381,12 +381,9 @@ fn selected_named_fleet_member_shows_edit_affordance() {
30,
);
let text = rows.join("\n");
assert!(
text.contains("[edit]"),
"focused member should advertise editing: {text}"
);
assert!(text.contains("Thinking"), "{text}");
assert!(text.contains("Team `Default`"), "{text}");
assert!(text.contains("Enter edit"), "{text}");
assert!(text.contains("Enter model & thinking"), "{text}");
assert!(text.contains("Tab workers"), "{text}");
assert!(text.contains("saved teams"), "{text}");
// The letter-key wall is gone: one grammar, no `s`, `m`, `w`, PgUp hints.
@@ -561,7 +558,10 @@ fn detail_shows_access_model_and_saved_for() {
.unwrap()
.clone();
assert!(member_access_summary(&reviewer).contains("read-only files"));
assert_eq!(member_routing(&reviewer), "same model as this session");
assert_eq!(
member_routing_with_session(&reviewer, None),
"same model as this session"
);
// Built-in scout: same Access shape as reviewer.
let scout = FleetRoster::built_ins_only().get("scout").unwrap().clone();
@@ -577,7 +577,7 @@ fn detail_shows_access_model_and_saved_for() {
// An explicit model beats the saved-set label, with no "(pinned)" jargon.
let mut pinned = reviewer.clone();
pinned.profile.model = Some("glm-5.2".to_string());
assert_eq!(member_routing(&pinned), "model glm-5.2");
assert_eq!(member_routing_with_session(&pinned, None), "model glm-5.2");
}
include!("../fleet_roster_capability_tests.rs");
@@ -634,7 +634,10 @@ fn roster_loads_config_members_through_the_shared_merge() {
FleetRosterView::from_parts(operator(), FleetRoster::load(&config, tmp.path()), None);
let extra = view.members.iter().find(|m| m.id == "docs-writer").unwrap();
assert_eq!(extra.origin, ProfileOrigin::Config);
assert_eq!(member_routing(extra), "fast model, picked at launch");
assert_eq!(
member_routing_with_session(extra, None),
"fast model, picked at launch"
);
}
#[test]
+457 -3
View File
@@ -704,8 +704,32 @@ fn deterministic_composition_advisory(
Some(CompositionAdvisory { request, proposal })
}
/// A role assignment edits only route keys in the original document. The
/// source and possible destinations are captured before opening the picker.
struct RouteAssignment {
editor_id: uuid::Uuid,
id: String,
template: toml::Table,
original_member: crate::fleet::profile::AgentProfile,
source: Option<(PathBuf, String)>,
source_scope: Option<FleetProfileScope>,
destinations: Vec<(PathBuf, Option<String>)>,
provider: Option<String>,
model: Option<String>,
reasoning: Option<String>,
}
fn assignment_source(path: &Path) -> Result<Option<String>, String> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(format!("Cannot read {}: {err}", path.display())),
}
}
pub struct FleetSetupView {
snapshot: FleetSetupSnapshot,
assignment: Option<RouteAssignment>,
step: Step,
role_idx: usize,
model_idx: usize,
@@ -825,6 +849,261 @@ impl FleetSetupView {
Self::from_snapshot_for_role(FleetSetupSnapshot::from_app(app, config), role)
}
pub(crate) fn new_for_route_assignment(
app: &App,
config: &Config,
id: &str,
) -> Result<Self, String> {
let roster = crate::fleet::identity::load_effective_roster(
&config.fleet_config(),
&app.workspace,
Some(app.plugin_registry.as_ref()),
);
let member = roster
.members()
.iter()
.find(|member| member.id == id)
.ok_or_else(|| "This role is no longer available. Reopen Fleet.".to_string())?;
if matches!(
member.origin,
crate::fleet::roster::ProfileOrigin::Plugin
| crate::fleet::roster::ProfileOrigin::Config
) {
return Err(format!(
"{} is managed by {} ({}). Change its model there; copying it into a profile would discard its original controls.",
member.id,
member.origin,
member.source.display()
));
}
if member.origin == crate::fleet::roster::ProfileOrigin::BuiltIn
&& (member.profile.permissions != Default::default()
|| member.profile.delegation != Default::default())
{
return Err("This role has controls that cannot be copied into a profile. Edit its defining configuration.".into());
}
let mut view = Self::new_for_role(app, config, id);
let source_scope = match member.origin {
crate::fleet::roster::ProfileOrigin::Workspace => Some(FleetProfileScope::Project),
crate::fleet::roster::ProfileOrigin::Personal => Some(FleetProfileScope::Personal),
_ => None,
};
let source = if source_scope.is_some() {
Some((
member.source.clone(),
assignment_source(&member.source)?
.ok_or_else(|| "The saved role disappeared. Reopen Fleet.".to_string())?,
))
} else {
None
};
let template = if let Some((_, text)) = &source {
toml::from_str::<toml::Table>(text).map_err(|err| err.to_string())?
} else {
let draft = crate::fleet::profile::FleetProfileDraft {
id: member.id.clone(),
display_name: member.display_name.clone(),
description: member.description.clone(),
role_hint: member.profile.role.name.clone(),
model_class_hint: Some(member.profile.loadout.as_str().to_string()),
model: member.profile.model.clone(),
provider: member.profile.provider.clone(),
reasoning_effort: member.profile.reasoning_effort.clone(),
instructions: member.profile.role.instructions.clone(),
};
toml::from_str::<toml::Table>(&draft.render_toml()).map_err(|err| err.to_string())?
};
view.assignment = Some(RouteAssignment {
editor_id: uuid::Uuid::new_v4(),
id: member.id.clone(),
template,
original_member: member.clone(),
source,
source_scope,
destinations: Vec::new(),
provider: member.profile.provider.clone(),
model: member.profile.model.clone(),
reasoning: member.profile.reasoning_effort.clone(),
});
view.refresh_destinations();
let targets = view
.destinations
.as_ref()
.into_iter()
.flatten()
.filter(|destination| destination.unavailable_reason.is_none())
.map(|destination| {
Ok((
destination.target.clone(),
assignment_source(&destination.target)?,
))
})
.collect::<Result<Vec<_>, String>>()?;
view.assignment.as_mut().unwrap().destinations = targets;
view.step = Step::Destination;
Ok(view)
}
pub(crate) fn route_pick_request(&self) -> ViewAction {
self.assignment
.as_ref()
.map_or(ViewAction::None, |assignment| {
ViewAction::Emit(ViewEvent::FleetProfileRoutePickRequested {
editor_id: assignment.editor_id,
})
})
}
pub(crate) fn assignment_context(&self) -> (String, String) {
(self.selected_role(), self.saves_to_line())
}
pub(crate) fn route_selection(
&self,
editor_id: uuid::Uuid,
) -> Option<super::fleet_detail::FleetRouteSelection> {
let assignment = self
.assignment
.as_ref()
.filter(|assignment| assignment.editor_id == editor_id)?;
Some(super::fleet_detail::FleetRouteSelection {
provider: assignment.provider.clone(),
model: assignment.model.clone(),
reasoning: assignment.reasoning.as_deref().and_then(|value| {
crate::reasoning_preference::ReasoningEffort::parse_strict(value).ok()
}),
allow_inherit: true,
})
}
pub(crate) fn accept_route(
&mut self,
editor_id: uuid::Uuid,
provider: String,
model: String,
reasoning: Option<crate::reasoning_preference::ReasoningEffort>,
) -> bool {
let Some(assignment) = self
.assignment
.as_mut()
.filter(|assignment| assignment.editor_id == editor_id)
else {
return false;
};
assignment.provider = (model != "auto").then_some(provider);
assignment.model = (model != "auto").then_some(model);
assignment.reasoning = reasoning.map(|effort| effort.as_setting().to_string());
self.step = if self.scope_decided {
Step::Review
} else {
Step::Destination
};
self.refresh_destinations();
true
}
pub(crate) fn commit_route_assignment(
&self,
editor_id: uuid::Uuid,
app: &App,
config: &Config,
) -> Result<String, String> {
let assignment = self
.assignment
.as_ref()
.filter(|assignment| assignment.editor_id == editor_id)
.ok_or_else(|| "This assignment is no longer open.".to_string())?;
if !matches!(
resolve_fleet_setup_edit_target(&app.workspace),
Ok(FleetSetupEditTarget::LegacyProfiles)
) {
return Err("The selected team changed. Reopen Fleet before saving.".into());
}
let roster = crate::fleet::identity::load_effective_roster(
&config.fleet_config(),
&app.workspace,
Some(app.plugin_registry.as_ref()),
);
if !roster
.members()
.iter()
.any(|member| member == &assignment.original_member)
{
return Err(
"This role changed while you were choosing. Reopen Fleet before saving.".into(),
);
}
if !self.scope_decided || !self.selected_destination_available() {
return Err("Choose an available save destination first.".into());
}
if self.profile_scope == FleetProfileScope::Project
&& !crate::fleet::roster::project_agent_profiles_enabled()
{
return Err("Project profiles are disabled for this launch.".into());
}
if let Some(provider) = assignment.provider.as_deref()
&& let Some(reason) = crate::commands::fleet_provider_rejection(app, config, provider)
{
return Err(reason);
}
if let Some((path, expected)) = &assignment.source
&& assignment_source(path)?.as_ref() != Some(expected)
{
return Err("This role changed on disk. Reopen Fleet before saving.".into());
}
let target = &self
.destination_for(self.profile_scope)
.ok_or("Save destination unavailable")?
.target;
let expected = assignment
.destinations
.iter()
.find(|(path, _)| path == target)
.ok_or("Save destination changed. Reopen Fleet.")?;
if assignment_source(target)? != expected.1 {
return Err("The destination changed on disk. Reopen Fleet before saving.".into());
}
let dir = target.parent().ok_or("Invalid profile destination")?;
let identities = crate::fleet::profile::load_agent_profile_identities_from_dir(dir)
.map_err(|err| err.to_string())?;
if identities.iter().any(|profile| {
profile.id.eq_ignore_ascii_case(&assignment.id) && profile.source != *target
}) {
return Err("Another file already defines this role. Reopen Fleet.".into());
}
let mut table = assignment.template.clone();
for alias in [
"model",
"model_hint",
"model_id",
"provider",
"reasoning_effort",
"thinking",
"reasoning",
] {
table.remove(alias);
}
for (key, value) in [
("model", &assignment.model),
("provider", &assignment.provider),
("reasoning_effort", &assignment.reasoning),
] {
if let Some(value) = value {
table.insert(key.into(), toml::Value::String(value.clone()));
}
}
table.insert("loadout".into(), toml::Value::String("inherit".into()));
let text = toml::to_string_pretty(&table).map_err(|err| err.to_string())?;
let mut transaction = codewhale_config::persistence::SetupTransaction::new();
transaction.stage(target.clone(), text.into_bytes());
transaction.commit().map_err(|err| err.to_string())?;
Ok(format!(
"{} model saved · {}",
assignment.id,
target.display()
))
}
fn from_snapshot_for_role(snapshot: FleetSetupSnapshot, role: &str) -> Self {
let mut view = Self::from_snapshot(snapshot);
let role = public_role_label(role);
@@ -912,6 +1191,7 @@ impl FleetSetupView {
let composition = deterministic_composition_advisory(&snapshot.available_models);
Self {
snapshot,
assignment: None,
step: Step::Role,
role_idx: 0,
model_idx: 0,
@@ -986,7 +1266,10 @@ impl FleetSetupView {
/// The planner role chosen (drives the profile file name and `role_hint`).
fn selected_role(&self) -> String {
ROLES[self.role_idx.min(ROLES.len() - 1)].label.to_string()
self.assignment
.as_ref()
.map(|assignment| assignment.id.clone())
.unwrap_or_else(|| ROLES[self.role_idx.min(ROLES.len() - 1)].label.to_string())
}
fn has_composition_for_selected_role(&self) -> bool {
@@ -1197,6 +1480,9 @@ impl FleetSetupView {
/// independent of the parent/current provider (#4093) — or `None` when
/// `inherit` is selected (reuse the session route).
fn selected_route(&self) -> Option<(String, String)> {
if let Some(assignment) = &self.assignment {
return assignment.provider.clone().zip(assignment.model.clone());
}
let real_idx = self.real_model_idx();
if real_idx == 0 {
return None;
@@ -1227,6 +1513,9 @@ impl FleetSetupView {
}
fn selected_reasoning_effort(&self) -> Option<String> {
if let Some(assignment) = &self.assignment {
return assignment.reasoning.clone();
}
if self.thinking_idx == 0 {
return None;
}
@@ -1389,11 +1678,19 @@ impl FleetSetupView {
fn refresh_destinations(&mut self) {
let file = format!("{}.toml", profile_file_stem(&self.selected_role()));
let statuses = DESTINATION_ORDER.map(|scope| {
let file = self
.assignment
.as_ref()
.filter(|assignment| assignment.source_scope == Some(scope))
.and_then(|assignment| assignment.source.as_ref())
.and_then(|(path, _)| path.file_name())
.and_then(|name| name.to_str())
.unwrap_or(&file);
destination_status(
scope,
&self.snapshot.workspace,
&self.snapshot.personal_profile_dir,
&file,
file,
self.snapshot.project_profiles_enabled,
self.snapshot.locale,
)
@@ -1515,6 +1812,11 @@ impl FleetSetupView {
self.replace_armed = true;
return ViewAction::None;
}
if let Some(assignment) = &self.assignment {
return ViewAction::Emit(ViewEvent::FleetProfileRouteCommitRequested {
editor_id: assignment.editor_id,
});
}
match self.model_draft.clone() {
Some(draft) => ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested {
draft,
@@ -1527,6 +1829,9 @@ impl FleetSetupView {
/// Step back toward the first screen. Returns `None` at the first step (the
/// host closes the modal via Esc instead).
fn back(&mut self) -> ViewAction {
if self.assignment.is_some() {
return self.route_pick_request();
}
match self.step {
Step::Role => ViewAction::None,
Step::Composition => {
@@ -1624,7 +1929,9 @@ impl FleetSetupView {
hints.push(ActionHint::new("Enter", "activate"));
hints.push(ActionHint::new("↑/↓", "scroll"));
hints.push(ActionHint::new("t", "thinking"));
if self.model_draft.is_some() {
if self.assignment.is_some() {
// Route changes do not regenerate role instructions.
} else if self.model_draft.is_some() {
hints.push(ActionHint::new("m", "redraft"));
} else if self.snapshot.provider_ready {
hints.push(ActionHint::new("m", "model draft"));
@@ -1742,6 +2049,12 @@ impl ModalView for FleetSetupView {
{
return ViewAction::None;
}
if self.assignment.is_some() && matches!(key.code, KeyCode::Char('t')) {
return self.route_pick_request();
}
if self.assignment.is_some() && matches!(key.code, KeyCode::Char('m')) {
return ViewAction::None;
}
match key.code {
KeyCode::Esc if self.step != Step::Role => self.back(),
KeyCode::Esc => ViewAction::Close,
@@ -1981,6 +2294,12 @@ impl FleetSetupView {
Cow::Owned(tr(self.snapshot.locale, MessageId::FleetDestStepTitle).into_owned()),
Cow::Owned(tr(self.snapshot.locale, MessageId::FleetDestStepSubtitle).into_owned()),
),
Step::Review if self.assignment.is_some() => (
Cow::Owned(format!("Review {} model", self.selected_role())),
Cow::Borrowed(
"Save this role's model and thinking; the session model stays unchanged.",
),
),
Step::Review if self.model_draft.is_some() => (
Cow::Borrowed("Save profile"),
Cow::Borrowed(
@@ -2158,6 +2477,25 @@ impl FleetSetupView {
self.render_review_actions(rows[0], buf);
let body = rows[1];
if let Some(assignment) = &self.assignment {
let model = assignment
.model
.as_deref()
.unwrap_or("Follow current session");
let provider = assignment.provider.as_deref().unwrap_or("session provider");
let thinking = assignment
.reasoning
.as_deref()
.unwrap_or("Follow current session");
let text = format!(
"Role: {}\nModel: {model} · {provider}\nThinking: {thinking}\n{}\n\nOnly the model and thinking assignment changes. Existing name, description, instructions, tools and permissions are preserved. The current session model stays unchanged.",
assignment.id,
self.saves_to_line()
);
render_scrollable_text(body, buf, &text, self.review_scroll);
return;
}
// A ratify-ready draft is on screen: show the exact TOML preview
// inline, scrolled by the same `review_scroll` state, so the save
// control in THIS view ratifies it directly — no separate pager in the
@@ -2638,6 +2976,122 @@ mod tests {
const BLOCKER_SIZES: [(u16, u16); 5] = [(80, 24), (89, 50), (100, 30), (120, 32), (160, 40)];
#[test]
fn role_assignment_preserves_profile_fields_and_rejects_stale_source() {
let _env = crate::test_support::lock_test_env();
let workspace = tempfile::tempdir().unwrap();
let directory = workspace
.path()
.join(crate::fleet::profile::WORKSPACE_AGENT_PROFILE_DIR);
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("different-file-name.toml");
let original = r#"id = "custom-reviewer"
display_name = "My reviewer"
description = "Keep this description"
role_hint = "reviewer"
loadout = "inherit"
model = "previous-model"
provider = "deepseek"
[instructions]
text = "Keep these precise instructions"
[tools]
posture = "read-only"
[permissions]
allow_shell = false
trust = false
approval_required = true
"#;
std::fs::write(&path, original).unwrap();
let config = Config::default();
let app = App::new(
crate::test_support::test_tui_options(workspace.path()),
&config,
);
let mut view =
FleetSetupView::new_for_route_assignment(&app, &config, "custom-reviewer").unwrap();
let editor_id = view.assignment.as_ref().unwrap().editor_id;
assert_eq!(view.selected_role(), "custom-reviewer");
assert_eq!(
view.destination_for(FleetProfileScope::Project)
.unwrap()
.target,
path
);
assert!(view.accept_route(
editor_id,
"deepseek".into(),
"auto".into(),
Some(crate::reasoning_preference::ReasoningEffort::High)
));
assert_eq!(view.step, Step::Review);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
original,
"picker/review must not write"
);
view.commit_route_assignment(editor_id, &app, &config)
.unwrap();
let before: toml::Table = toml::from_str(original).unwrap();
let after: toml::Table = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
for name in [
"id",
"display_name",
"description",
"role_hint",
"instructions",
"tools",
"permissions",
] {
assert_eq!(
after.get(name),
before.get(name),
"{name} changed during route assignment"
);
}
assert!(!after.contains_key("model") && !after.contains_key("provider"));
assert_eq!(after["reasoning_effort"].as_str(), Some("high"));
let mut stale =
FleetSetupView::new_for_route_assignment(&app, &config, "custom-reviewer").unwrap();
let stale_id = stale.assignment.as_ref().unwrap().editor_id;
stale.accept_route(stale_id, "deepseek".into(), "auto".into(), None);
let changed = format!(
"{}\n# Another editor changed this file\n",
std::fs::read_to_string(&path).unwrap()
);
std::fs::write(&path, &changed).unwrap();
assert!(
stale
.commit_route_assignment(stale_id, &app, &config)
.is_err()
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), changed);
}
#[test]
fn builtin_role_assignment_requires_destination_and_rejects_new_override() {
let _env = crate::test_support::lock_test_env();
let workspace = tempfile::tempdir().unwrap();
let config = Config::default();
let app = App::new(
crate::test_support::test_tui_options(workspace.path()),
&config,
);
let mut view = FleetSetupView::new_for_route_assignment(&app, &config, "manager").unwrap();
let id = view.assignment.as_ref().unwrap().editor_id;
view.accept_route(id, "deepseek".into(), "auto".into(), None);
assert_eq!(view.step, Step::Destination);
assert!(!view.scope_decided);
assert!(view.commit_route_assignment(id, &app, &config).is_err());
let directory = workspace
.path()
.join(crate::fleet::profile::WORKSPACE_AGENT_PROFILE_DIR);
std::fs::create_dir_all(&directory).unwrap();
std::fs::write(directory.join("manager.toml"), "id = \"manager\"\n").unwrap();
view.choose_destination(FleetProfileScope::Personal);
view.refresh_destinations();
assert!(view.commit_route_assignment(id, &app, &config).is_err());
}
fn snapshot() -> FleetSetupSnapshot {
FleetSetupSnapshot {
workspace: PathBuf::from("/tmp/codewhale-test-workspace"),
+17
View File
@@ -793,6 +793,23 @@ pub enum ViewEvent {
/// Enter on a Fleet editor row: open the standard `/model` picker for
/// that row (the editor stays underneath) instead of the editor's own
/// inline route list.
FleetProfileRoutePickRequested {
editor_id: uuid::Uuid,
},
FleetProfileRoutePicked {
editor_id: uuid::Uuid,
provider: crate::config::ApiProvider,
provider_id: Option<String>,
model: String,
reasoning: Option<crate::reasoning_preference::ReasoningEffort>,
},
FleetProfileRouteCommitRequested {
editor_id: uuid::Uuid,
},
FleetAssignmentPickerDismissed {
editor_id: uuid::Uuid,
},
FleetRosterOpenCoordinatorRequested,
FleetDetailRoutePickRequested {
target: crate::tui::views::fleet_detail::FleetRouteTarget,
editor_id: uuid::Uuid,
+59 -18
View File
@@ -48,19 +48,21 @@ fn start_with_options(
animated: bool,
) -> (SealedWorkspace, Harness) {
let workspace = make_sealed_workspace().unwrap();
if let Some(theme) = theme {
std::fs::write(
workspace.home().join(".codewhale/settings.toml"),
format!("theme = {theme:?}\n"),
)
.unwrap();
}
std::fs::write(workspace.home().join(".codewhale/.onboarded"), "").unwrap();
let trust = workspace.workspace().join(".deepseek");
std::fs::create_dir_all(&trust).unwrap();
std::fs::write(trust.join("trusted"), "").unwrap();
let sessions = workspace.home().join(".codewhale/sessions");
std::fs::create_dir_all(&sessions).unwrap();
for directory in [&trust, &sessions] {
std::fs::create_dir_all(directory).unwrap();
}
let mut fixtures = vec![
(workspace.home().join(".codewhale/.onboarded"), Vec::new()),
(trust.join("trusted"), Vec::new()),
];
if let Some(theme) = theme {
fixtures.push((
workspace.home().join(".codewhale/settings.toml"),
format!("theme = {theme:?}\n").into_bytes(),
));
}
for (index, title) in titles.iter().enumerate() {
let id = format!(
"11111111-2222-4333-8444-{:012}",
@@ -82,22 +84,23 @@ fn start_with_options(
"messages": [{"role": "user", "content": [{"type": "text", "text": SAVED_TEXT}]}],
"system_prompt": null
});
std::fs::write(
fixtures.push((
sessions.join(format!("{id}.json")),
serde_json::to_vec(&session).unwrap(),
)
.unwrap();
));
}
if with_mcp {
// A local failing server gives the summary a real row without any network.
let mcp = serde_json::json!({"mcpServers": {"launch-proof": {
"command": "/usr/bin/false", "required": true
}}});
std::fs::write(
fixtures.push((
workspace.home().join(".codewhale/mcp.json"),
serde_json::to_vec(&mcp).unwrap(),
)
.unwrap();
));
}
for (path, contents) in fixtures {
std::fs::write(path, contents).unwrap();
}
let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui"))
@@ -134,7 +137,13 @@ fn start_with_options(
fn wait(tui: &mut Harness, text: &str) {
if let Err(error) = tui.wait_for(|frame| frame.contains(text), WAIT) {
panic!("waiting for {text:?}: {error}\n{}", tui.diagnostics());
let transcript = tui.transcript();
let tail = &transcript[transcript.len().saturating_sub(4096)..];
panic!(
"waiting for {text:?}: {error}\n{}\nPTY tail: {:?}",
tui.diagnostics(),
String::from_utf8_lossy(tail)
);
}
}
@@ -403,3 +412,35 @@ fn settings_catalog_controls_and_provider_search_work_with_mouse_and_keyboard()
tui.shutdown();
}
}
#[test]
fn fleet_roles_open_the_shared_model_picker_and_escape_returns_to_the_same_role() {
for (rows, cols) in SIZES {
let (_workspace, mut tui) = start(rows, cols, false);
tui.paste("/fleet").unwrap();
tui.send(keys::key::enter()).unwrap();
wait(&mut tui, "Coordinator");
capture(&mut tui, "fleet-assignments");
tui.send(keys::key::enter()).unwrap();
wait(&mut tui, "Model · Coordinator");
wait(&mut tui, "Current session");
capture(&mut tui, "fleet-coordinator-model");
tui.send(keys::key::esc()).unwrap();
wait(&mut tui, "saved teams");
tui.send(keys::key::down()).unwrap();
tui.send(keys::key::enter()).unwrap();
wait(&mut tui, "Model · manager");
capture(&mut tui, "fleet-role-model");
tui.send("search-proof").unwrap();
tui.send(keys::key::esc()).unwrap();
wait(&mut tui, "Model · manager");
tui.send(keys::key::esc()).unwrap();
wait(&mut tui, "saved teams");
tui.send(keys::key::enter()).unwrap();
wait(&mut tui, "Model · manager");
// Following Coordinator is a selectable local choice even without credentials.
tui.send(keys::key::enter()).unwrap();
capture(&mut tui, "fleet-role-destination");
tui.shutdown();
}
}
+4 -1
View File
@@ -452,6 +452,9 @@
"crates/tui/src/tui/views/fleet_list.rs": {
"std_fs": 3
},
"crates/tui/src/tui/views/fleet_setup.rs": {
"std_fs": 1
},
"crates/tui/src/tui/window_control.rs": {
"thread_sleep": 1
},
@@ -482,7 +485,7 @@
"std_fs": 1
},
"crates/tui/tests/cucumber/launch_card_pty.rs": {
"std_fs": 9
"std_fs": 8
},
"crates/tui/tests/cucumber/plugin_e2e_acceptance.rs": {
"std_fs": 15,