refactor(tui): clarify provider and Fleet inspectors

Give provider management a quiet inspector with a clickable, shared Alt+V details action. Preserve exact diagnostics and consent facts in the existing pager. Simplify Fleet navigation while retaining member identity, route overrides and full selected details. Open Plugins on an actual item and separate inventory from its selected description.

Validation: combined targeted run 210 tests, 207 passed and 3 stale presentation assertions failed; after updating those assertions, focused recheck 3 passed, 0 failed. New provider diagnostic retention and keyboard/pointer parity regression passes at five sizes. Runtime source remained frozen apart from narrowing the now-test-only BADGE_WIDTH constant. Formatting and diff checks passed. Parent previously ran npm test: 551 passed, 0 failed; check:web passed, not repeated for this Rust-only slice. Fresh production/optimized PTY qualification remains parent-owned and pending.
This commit is contained in:
CodeWhale Bot committed 2026-09-19 05:04:44 -07:00
1 parent 8f0909088a
commit 3eef1d7ebc
5 files changed
+274 -115

No files matched your search

+175 -32
View File
@@ -226,6 +226,8 @@ pub struct ProviderPickerView {
model_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
consent_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
choice_row_hitboxes: RefCell<Vec<(Rect, char)>>,
detail_action_hitbox: RefCell<Option<Rect>>,
detail_action_hovered: bool,
hovered_choice: Option<char>,
last_choice_mouse_selected: Option<(Stage, char)>,
/// Pointer hover positions. Advisory only — hover never moves the
@@ -1771,6 +1773,8 @@ impl ProviderPickerView {
model_row_hitboxes: RefCell::new(Vec::new()),
consent_row_hitboxes: RefCell::new(Vec::new()),
choice_row_hitboxes: RefCell::new(Vec::new()),
detail_action_hitbox: RefCell::new(None),
detail_action_hovered: false,
hovered_choice: None,
last_choice_mouse_selected: None,
hovered_list_idx: None,
@@ -2784,22 +2788,91 @@ impl ProviderPickerView {
}
fn render_provider_detail(&self, area: Rect, buf: &mut Buffer, row: &ProviderDashboardRow) {
*self.detail_action_hitbox.borrow_mut() = None;
if area.width == 0 || area.height == 0 {
return;
}
let block = Block::default()
.title(Line::from(Span::styled(
" Details ",
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default());
let inner = block.inner(area);
block.render(area, buf);
// A quiet inspector shares the canvas with the list. The explicit
// details action opens the existing pager for complete diagnostics.
let action_label = format!(
"{} {}",
crate::tui::shell_key_routing::tool_details_chord(),
self.tr(MessageId::CtxMenuOpenDetails)
);
let action_width =
(unicode_width::UnicodeWidthStr::width(action_label.as_str()) as u16).min(area.width);
let action = Rect::new(
area.right().saturating_sub(action_width),
area.y,
action_width,
1,
);
*self.detail_action_hitbox.borrow_mut() = Some(action);
let action_style = if self.detail_action_hovered {
menu_style::hovered_row_style().fg(palette::WHALE_ACTION)
} else {
Style::default()
.fg(palette::WHALE_ACTION)
.add_modifier(Modifier::UNDERLINED)
};
Paragraph::new(action_label)
.style(action_style)
.render(action, buf);
let title = Rect::new(
area.x,
area.y,
area.width.saturating_sub(action_width + 1),
1,
);
Paragraph::new(crate::tui::ui_text::semantic_truncate(
&row.display_name,
usize::from(title.width),
))
.style(Style::default().fg(palette::TEXT_PRIMARY).bold())
.render(title, buf);
let inner = Rect::new(
area.x,
area.y.saturating_add(2),
area.width,
area.height.saturating_sub(2),
);
Paragraph::new(self.provider_detail_lines(row, inner.width, false))
.wrap(Wrap { trim: true })
.render(inner, buf);
}
fn open_provider_details(&self) -> ViewAction {
if !self.row_visible(self.selected_idx) {
return ViewAction::None;
}
let row = &self.rows[self.selected_idx];
let content = self
.provider_detail_lines(row, u16::MAX, true)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
ViewAction::Emit(ViewEvent::OpenTextPager {
title: format!(
"{} · {}",
row.display_name,
self.tr(MessageId::CtxMenuOpenDetails)
),
content,
})
}
fn provider_detail_lines(
&self,
row: &ProviderDashboardRow,
width: u16,
full: bool,
) -> Vec<Line<'static>> {
let route = if row.default_route.logical_model == row.default_route.wire_model {
row.default_route.logical_model.clone()
} else {
@@ -2809,12 +2882,6 @@ impl ProviderPickerView {
)
};
let mut lines = vec![
Line::from(Span::styled(
row.display_name.clone(),
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
// The maturity tag used to ride the list row's pipe dump. The
// row is short now, so the fact lives here, with the rest of
@@ -2866,11 +2933,19 @@ impl ProviderPickerView {
Style::default().fg(palette::TEXT_MUTED),
)));
}
for message in row.messages.iter().take(2) {
lines.push(Line::from(Span::styled(
format!("Note: {message}"),
Style::default().fg(palette::STATUS_WARNING),
)));
for message in row.messages.iter().take(if full { usize::MAX } else { 2 }) {
let message = if full {
message.clone()
} else {
crate::tui::ui_text::semantic_truncate(
message,
usize::from(width.saturating_sub(2)),
)
};
lines.push(Line::from(vec![
Span::styled("! ", Style::default().fg(palette::STATUS_WARNING)),
Span::styled(message, Style::default().fg(palette::TEXT_PRIMARY)),
]));
}
// #5772: the external block exists only for a persisted consent record
// (see the row constructor), and it names the owning CLI without the
@@ -2952,7 +3027,7 @@ impl ProviderPickerView {
.add_modifier(Modifier::BOLD),
)));
let pane_models = provider_pane_models(&self.route_config, row, 8);
let name_budget = usize::from(inner.width).saturating_sub(22).max(8);
let name_budget = usize::from(width).saturating_sub(22).max(8);
for (model, price, is_default) in &pane_models {
let name = crate::tui::ui_text::truncate_line_to_width(model, name_budget);
let mut spans = vec![
@@ -2984,11 +3059,11 @@ impl ProviderPickerView {
Style::default().fg(palette::TEXT_MUTED),
)));
}
lines.push(Line::from(""));
lines.extend(diagnostics);
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.render(inner, buf);
if full {
lines.push(Line::from(""));
lines.extend(diagnostics);
}
lines
}
fn render_xai_auth_choice(&self, area: Rect, buf: &mut Buffer) {
@@ -4097,6 +4172,9 @@ impl ModalView for ProviderPickerView {
self.hovered_choice = None;
match self.stage {
Stage::List => match key.code {
_ if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) => {
self.open_provider_details()
}
KeyCode::Esc if !self.query.is_empty() => {
self.update_query(String::new());
ViewAction::None
@@ -4603,6 +4681,19 @@ impl ModalView for ProviderPickerView {
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if matches!(self.stage, Stage::List) {
let over_details = self
.detail_action_hitbox
.borrow()
.is_some_and(|rect| rect.contains((mouse.column, mouse.row).into()));
if matches!(mouse.kind, MouseEventKind::Moved) {
self.detail_action_hovered = over_details;
}
if over_details && mouse.kind == MouseEventKind::Down(MouseButton::Left) {
return self.open_provider_details();
}
}
match self.stage {
Stage::List => match mouse.kind {
MouseEventKind::ScrollUp => {
@@ -4690,6 +4781,7 @@ impl ModalView for ProviderPickerView {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.choice_row_hitboxes.borrow_mut().clear();
*self.detail_action_hitbox.borrow_mut() = None;
// Managing routes needs room for both options and their explanation,
// even with only one configured provider. First-run questions and
// credential/consent flows retain their bounded modal presentation.
@@ -4786,6 +4878,51 @@ mod tests {
// provider env vars (EXAMPLE_API_KEY, OPENROUTER_API_KEY, ...) and a panic
// while holding it cascaded PoisonError failures into unrelated tests.
#[test]
fn provider_inspector_keeps_exact_diagnostics_in_clickable_keyboard_pager() {
let _env = crate::test_support::lock_test_env();
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let message = "Recovery fixture · exact diagnostic source ".repeat(12);
let selected = picker.selected_idx;
picker.rows[selected].messages = vec![
message.clone(),
"second diagnostic".into(),
"third diagnostic".into(),
];
for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
let overview = render_text(&picker, width, height);
assert!(!overview.contains(&message));
let hit = picker
.detail_action_hitbox
.borrow()
.expect("visible details action");
let ViewAction::Emit(ViewEvent::OpenTextPager { title, content }) =
picker.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT))
else {
panic!("the shared details shortcut must open the existing pager")
};
assert!(content.contains(&message));
assert!(content.contains("third diagnostic"));
assert!(content.contains("Protocol:"));
let ViewAction::Emit(ViewEvent::OpenTextPager {
title: clicked_title,
content: clicked_content,
}) = picker.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: hit.x,
row: hit.y,
modifiers: KeyModifiers::NONE,
})
else {
panic!("click must open the same pager")
};
assert_eq!(clicked_title, title);
assert_eq!(clicked_content, content);
assert_eq!(picker.selected_idx, selected);
}
}
#[test]
fn workbench_setup_choice_clicks_share_keyboard_confirmation_paths() {
let _env = crate::test_support::lock_test_env();
@@ -6869,7 +7006,7 @@ mod tests {
}
#[test]
fn provider_dashboard_render_includes_route_protocol_usage_and_base_url() {
fn provider_dashboard_keeps_route_and_prices_visible_with_protocol_in_details() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
@@ -6889,7 +7026,13 @@ mod tests {
assert!(rendered.contains("key:configured"));
assert!(!rendered.contains("auth:configured"));
assert!(rendered.contains("Route: custom-model"));
assert!(rendered.contains("chat"));
let ViewAction::Emit(ViewEvent::OpenTextPager { content, .. }) =
picker.open_provider_details()
else {
panic!("protocol details must remain accessible")
};
assert!(content.contains("Protocol: chat"));
assert!(rendered.contains(picker.tr(MessageId::CtxMenuOpenDetails).as_ref()));
// Slice D: provider detail carries no cost; the models pane does.
assert!(!rendered.contains("cost:"));
assert!(!rendered.contains("Usage:"));
@@ -9083,7 +9226,7 @@ mod tests {
let rendered = render_text(&picker, 80, 23);
assert!(rendered.contains("DeepSeek *"));
assert!(rendered.contains("Details"));
assert!(rendered.contains(picker.tr(MessageId::CtxMenuOpenDetails).as_ref()));
assert!(rendered.contains("Route:"));
}
+34 -15
View File
@@ -16,7 +16,7 @@ use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
widgets::{Paragraph, Widget, Wrap},
};
use super::{
@@ -1530,6 +1530,17 @@ impl ExtensionsView {
{
view.selected[tab.index()] = 1;
}
// A plugin manager opens on an actual plugin's inspect/action row;
// group headings remain reachable above it for folding.
if tab == ExtensionsTab::Plugins {
let first_item = view
.visible_entries()
.iter()
.position(|entry| matches!(entry, VisibleEntry::Item(_, _)));
if let Some(index) = first_item {
view.selected[tab.index()] = index;
}
}
view
}
@@ -1950,7 +1961,7 @@ impl ModalView for ExtensionsView {
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(if body.height >= 16 { 3 } else { 1 }),
Constraint::Length(1),
])
.split(body);
@@ -2022,7 +2033,6 @@ impl ModalView for ExtensionsView {
} else if selected >= scroll.saturating_add(list_height.max(1)) {
scroll = selected.saturating_sub(list_height.saturating_sub(1));
}
let spacious = area.width >= 64 && area.height >= 16;
for (visible_offset, (entry_index, entry)) in entries
.iter()
.enumerate()
@@ -2083,12 +2093,6 @@ impl ModalView for ExtensionsView {
}
parts.push((item.label.clone(), None));
parts.push((format!(" [{}]", item.state), Some(item.tone.ink())));
if spacious && !item.description.is_empty() {
parts.push((
format!(" — {}", item.description),
Some(codewhale_palette::ChromeInk::MetadataHint),
));
}
}
VisibleEntry::Problem(problem) => parts.push((
format!("! {problem}"),
@@ -2136,12 +2140,27 @@ impl ModalView for ExtensionsView {
hits.rows.push((row_area, entry_index));
}
let status = truncate_view_text(&self.selected_status(), usize::from(rows[3].width));
Paragraph::new(Line::from(Span::styled(
status,
Style::default().fg(palette::TEXT_MUTED),
)))
.render(rows[3], buf);
let status = if rows[3].height > 1 && self.pending_remove.is_none() {
self.selected_item().map_or_else(
|| self.selected_status(),
|item| {
format!(
"{} · {}\n{}\n{}",
item.label, item.state, item.description, item.detail
)
},
)
} else {
self.selected_status()
};
Paragraph::new(status)
.style(Style::default().fg(if self.pending_remove.is_some() {
palette::STATUS_WARNING
} else {
palette::TEXT_MUTED
}))
.wrap(Wrap { trim: false })
.render(rows[3], buf);
let compact_hints = [
super::ActionHint::new("Tab", tr(self.locale, MessageId::ExtensionsActionTabs)),
super::ActionHint::new("/", tr(self.locale, MessageId::SessionsActionSearch)),
+40 -59
View File
@@ -615,25 +615,15 @@ impl FleetRosterView {
let (text, base_style) = if idx == 0 {
(
format!(
"{pointer}@ {} {}",
tr(self.locale, MessageId::FleetRosterOperatorRow),
self.operator.model
"{pointer}@ {}",
tr(self.locale, MessageId::FleetRosterOperatorRow)
),
Style::default()
.fg(palette::WHALE_ACTION)
.add_modifier(Modifier::BOLD),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)
} else {
let member = &self.members[idx - 1];
let mark = member_role_mark(member);
// #5098: badge rows whose id exists in more than one layer
// so a higher-layer win is visible from the list.
let shadow_badge = member_shadow_badge(self.locale, member, &self.shadowed);
// Whale Teams: the species badge sits between the charter role
// mark and the id, so a Scout, Patch, or Lantern reads at a
// 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 {
@@ -648,46 +638,27 @@ impl FleetRosterView {
|| member.id.clone(),
|name| format!("{name} ({})", member.id),
);
let text = format!(
"{pointer}{edit_marker}{mark} {}{} {}",
member_name,
shadow_badge.as_deref().unwrap_or(""),
member_routing(member)
);
let text = truncate_view_text(&text, list_width.saturating_sub(badge_cells));
let base_style = if is_selected {
menu_style::selected_row_style()
} else if hovered {
Style::default()
.fg(palette::TEXT_PRIMARY)
.patch(hover_tint())
// 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 {
Style::default().fg(palette::TEXT_PRIMARY)
String::new()
};
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 {
(text.as_str(), "")
};
let mut spans = vec![Span::styled(head.to_string(), base_style)];
for span in whales::badge(species, &palette::UI_THEME) {
spans.push(if is_selected {
Span::styled(
span.content,
span.style
.bg(palette::SELECTION_BG)
.add_modifier(Modifier::BOLD),
)
} else if hovered {
Span::styled(span.content, span.style.patch(hover_tint()))
} else {
span
});
}
spans.push(Span::styled(format!(" {tail}"), base_style));
list_lines.push(Line::from(spans));
continue;
(
format!(
"{pointer}{edit_marker}{mark} {member_name}{}{route}",
shadow_badge.as_deref().unwrap_or("")
),
Style::default().fg(palette::TEXT_PRIMARY),
)
};
let style = if is_selected {
menu_style::selected_row_style()
@@ -696,6 +667,17 @@ impl FleetRosterView {
} else {
base_style
};
if is_selected || hovered {
buf.set_style(
Rect::new(
list_area.x,
list_area.y + line_offset as u16,
list_area.width,
1,
),
style,
);
}
list_lines.push(Line::from(Span::styled(
truncate_view_text(&text, list_width),
style,
@@ -799,14 +781,13 @@ fn member_role_mark(member: &AgentProfile) -> &'static str {
/// Shared field renderer for the detail pane.
fn detail_field(lines: &mut Vec<Line<'static>>, label: &str, body: String) {
lines.push(Line::from(Span::styled(
label.to_string(),
Style::default().fg(palette::WHALE_ACTION).bold(),
)));
lines.push(Line::from(Span::styled(
body,
Style::default().fg(palette::TEXT_PRIMARY),
)));
lines.push(Line::from(vec![
Span::styled(
format!("{label} "),
Style::default().fg(palette::TEXT_MUTED).bold(),
),
Span::styled(body, Style::default().fg(palette::TEXT_PRIMARY)),
]));
lines.push(Line::from(""));
}
+23 -8
View File
@@ -156,7 +156,24 @@ fn operator_row_is_pinned_first_with_the_session_model() {
);
assert!(text.contains("deepseek-v4-pro"), "session model shown");
assert!(text.contains("full session access"), "{text}");
assert!(text.contains("leads the Fleet"), "{text}");
// Inline field labels can wrap the role at this width. Read just the
// inspector columns, excluding the independently positioned member list.
let detail_start = rows
.iter()
.find_map(|row| row.find("Role ").map(|index| row[..index].chars().count()))
.expect("role field rendered");
let detail = rows
.iter()
.map(|row| {
row.chars()
.skip(detail_start)
.collect::<String>()
.trim()
.to_owned()
})
.collect::<Vec<_>>()
.join(" ");
assert!(detail.contains("leads the Fleet"), "{text}");
}
#[test]
@@ -730,12 +747,12 @@ fn fleet_roster_is_usable_and_opaque_at_blocker_sizes() {
}
}
/// Whale Teams: member rows carry the species badge and the detail pane
/// opens with the identity block (badge plus `Name · species · job`) with no
/// Whale Teams: the selected member's detail pane carries the identity
/// block (badge plus `Name · species · job`) with no
/// caption labels and no state claim. The hand-drawn portrait art was
/// deleted per the 2026-08-29 founder directive, so no tier ever draws it.
#[test]
fn roster_rows_and_detail_carry_whale_identity_without_claiming_state() {
fn roster_detail_carries_whale_identity_without_claiming_state() {
let wide = render_through_stack(
|| {
let mut v = built_in_view();
@@ -746,9 +763,7 @@ fn roster_rows_and_detail_carry_whale_identity_without_claiming_state() {
32,
)
.join("\n");
assert!(wide.contains("◂▰ scout"), "{wide}");
assert!(wide.contains("▰] builder"), "{wide}");
assert!(wide.contains("◇▰ reviewer"), "{wide}");
assert!(wide.contains("◂▰ Scout"), "{wide}");
assert!(wide.contains("Scout · beaked whale · research"), "{wide}");
assert!(
!wide.contains("Whale identity"),
@@ -794,7 +809,7 @@ fn selection_stays_visible_when_list_scrolls() {
24,
);
let text = rows.join("\n");
assert!(text.contains("▸ · ·▰ custom"), "{text}");
assert!(text.contains("▸ · custom"), "{text}");
}
#[test]
+2 -1
View File
@@ -41,7 +41,8 @@ use codewhale_localization::{Locale, MessageId, tr};
use codewhale_palette::{self as palette, UiTheme};
/// Cells occupied by a badge (species mark + body).
pub const BADGE_WIDTH: usize = 2;
#[cfg(test)]
const BADGE_WIDTH: usize = 2;
/// Working wake loop: four frames over 720 ms, as in the CWC GIFs.
pub const WORKING_FRAME_MS: u64 = 180;
pub const WORKING_FRAMES: usize = 4;