mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
feat(tui): make settings clearer and consistently interactive
Expand ordinary provider management, prioritize model prices over low-level diagnostics, and retain active model-pane content at compact sizes. Share visible focus and hover across models and extensions; make Fleet Workers and four provider setup choice stages respond through existing keyboard actions. Preserve consent, credential and removal guards. Validation: provider-focused 131 passed, 0 failed after final hierarchy correction; previous combined run 504 passed and 1 provider failure, now resolved by that focused run. Four added workbench regressions included and passed. Root npm gate: 551 passed, 0 failed; check:web passed. Four Config/theme fixtures regenerated and reviewed; two focused golden tests each passed. Final display-only Coordinator Enter-hint correction source-checked against activate_selected and formatted; optimized build and exact-binary PTY qualification remain with root integration.
This commit is contained in:
1 parent
5477c9a249
commit
cc42bf1506
10 files changed
+579
-221
No files matched your search
@@ -24,9 +24,9 @@
|
||||
│ │Enter or click again: Enter opens
|
||||
│ │choices
|
||||
│ │
|
||||
│ │
|
||||
system | terminal | shoreline | shoreline-light | underwater | underwater-retro | dark | light | grayscale |…
|
||||
Preview: ▶▶ ask · agent
|
||||
type=filter, Up/Down=select, Enter=edit, Esc=close
|
||||
|
||||
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
Quiet transcript On [x] SAVED █
|
||||
Model reasoning in chat Off [ ] SAVED █
|
||||
Thinking Default Expanded Off [ ] SAVED █
|
||||
Thinking Preview Lines 2 ✎ SAVED │
|
||||
Thinking Preview Lines 2 ✎ SAVED █
|
||||
Reasoning background highlight On [x] SAVED │
|
||||
Help Expand Groups Off [ ] SAVED │
|
||||
Contextual tips On [x] SAVED │
|
||||
Pin Last Prompt On [x] SAVED │
|
||||
system | terminal | shoreline | shoreline-light | underwater |…
|
||||
Enter or click again: Enter opens choices · Theme: current terminal · saved
|
||||
terminal · applies on save
|
||||
Preview: ▶▶ ask · agent
|
||||
type=filter, Up/Down=select, Enter=edit, PgUp/PgDn=scroll, Esc=close
|
||||
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
16. solarized-light
|
||||
17. uwu
|
||||
|
||||
|
||||
[ Apply ] [ Cancel ]
|
||||
↑/↓ or click choose · Enter/Apply · Esc/Cancel · 1-9 jump
|
||||
|
||||
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -17,7 +17,7 @@
|
||||
8. light
|
||||
9. grayscale
|
||||
10. catppuccin-mocha
|
||||
11. tokyo-night
|
||||
[ Apply ] [ Cancel ]
|
||||
↑/↓ or click choose · Enter/Apply · Esc/Cancel · 1-9 jump
|
||||
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -244,6 +244,7 @@ pub struct ModelPickerView {
|
||||
configured_providers: Vec<ApiProvider>,
|
||||
row_hitboxes: RefCell<Vec<(Rect, Pane, usize)>>,
|
||||
last_mouse_selected: Option<(Pane, usize)>,
|
||||
hovered_row: Option<(Pane, usize)>,
|
||||
/// UI locale captured from the app at construction (#4057 wave 2).
|
||||
locale: Locale,
|
||||
pinned_models: Vec<PinnedModel>,
|
||||
@@ -546,6 +547,7 @@ impl ModelPickerView {
|
||||
configured_providers,
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
last_mouse_selected: None,
|
||||
hovered_row: None,
|
||||
locale: app.ui_locale,
|
||||
pinned_models: pins,
|
||||
projection: RefCell::new(None),
|
||||
@@ -1318,6 +1320,26 @@ impl ModelPickerView {
|
||||
state: PaneRenderState,
|
||||
) {
|
||||
self.pane_hitboxes.borrow_mut().push((area, state.pane));
|
||||
// A short stacked picker gives the focused pane the working space.
|
||||
// The other pane remains a clickable summary with its actual choice.
|
||||
if area.height == 1 && !state.focused {
|
||||
let summary = rows.get(state.selected).map_or_else(
|
||||
|| title.to_string(),
|
||||
|row| format!("{title}: {}", row.primary),
|
||||
);
|
||||
Paragraph::new(crate::tui::ui_text::semantic_truncate(
|
||||
&summary,
|
||||
usize::from(area.width),
|
||||
))
|
||||
.style(Style::default().fg(palette::TEXT_MUTED))
|
||||
.render(area, buf);
|
||||
if !rows.is_empty() {
|
||||
self.row_hitboxes
|
||||
.borrow_mut()
|
||||
.push((area, state.pane, state.selected));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let header_height = if state.pane == Pane::Model && area.height >= 3 {
|
||||
2
|
||||
} else {
|
||||
@@ -1403,10 +1425,20 @@ impl ModelPickerView {
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let label_style = if is_selected && !locked {
|
||||
let focused = is_selected && state.focused;
|
||||
let hovered = self.hovered_row == Some((state.pane, idx)) && !focused;
|
||||
let label_style = if focused && !locked {
|
||||
menu_style::selected_row_style()
|
||||
} else if is_selected && locked {
|
||||
} else if focused && locked {
|
||||
menu_style::disabled_selected_row_style()
|
||||
} else if hovered {
|
||||
menu_style::hovered_row_style().fg(if locked {
|
||||
palette::TEXT_MUTED
|
||||
} else {
|
||||
palette::TEXT_PRIMARY
|
||||
})
|
||||
} else if is_selected {
|
||||
Style::default().fg(palette::WHALE_ACTION).bold()
|
||||
} else if locked {
|
||||
Style::default()
|
||||
.fg(palette::TEXT_MUTED)
|
||||
@@ -1414,8 +1446,10 @@ impl ModelPickerView {
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
let hint_style = if is_selected && !locked {
|
||||
let hint_style = if focused && !locked {
|
||||
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
|
||||
} else if hovered {
|
||||
menu_style::hovered_row_style().fg(palette::TEXT_MUTED)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_MUTED)
|
||||
};
|
||||
@@ -1437,6 +1471,9 @@ impl ModelPickerView {
|
||||
state.pane,
|
||||
idx,
|
||||
));
|
||||
if focused || hovered {
|
||||
buf.set_style(Rect::new(inner.x, row_y, inner.width, 1), label_style);
|
||||
}
|
||||
let spans = picker_row_spans(
|
||||
row,
|
||||
marker,
|
||||
@@ -1445,7 +1482,11 @@ impl ModelPickerView {
|
||||
label_style,
|
||||
hint_style,
|
||||
);
|
||||
lines.push(Line::from(spans));
|
||||
lines.push(Line::from(spans).style(if focused || hovered {
|
||||
label_style
|
||||
} else {
|
||||
Style::default()
|
||||
}));
|
||||
}
|
||||
if rows.is_empty() {
|
||||
// A search that matches nothing must say so, not render a bare
|
||||
@@ -3632,6 +3673,7 @@ impl ModalView for ModelPickerView {
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
self.last_mouse_selected = None;
|
||||
self.hovered_row = None;
|
||||
// Movement keys come from the shared vocabulary (#6290); the match
|
||||
// below owns only the picker's own verbs. The live filter means the
|
||||
// typing-safe set — no letter aliases to eat the query.
|
||||
@@ -3758,8 +3800,20 @@ impl ModalView for ModelPickerView {
|
||||
|
||||
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Moved => {
|
||||
self.hovered_row =
|
||||
self.row_hitboxes
|
||||
.borrow()
|
||||
.iter()
|
||||
.find_map(|(rect, pane, idx)| {
|
||||
rect.contains((mouse.column, mouse.row).into())
|
||||
.then_some((*pane, *idx))
|
||||
});
|
||||
ViewAction::None
|
||||
}
|
||||
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
|
||||
self.last_mouse_selected = None;
|
||||
self.hovered_row = None;
|
||||
let pane = self.pane_hitboxes.borrow().iter().find_map(|(rect, pane)| {
|
||||
rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
.then_some(*pane)
|
||||
@@ -3866,14 +3920,19 @@ impl ModelPickerView {
|
||||
),
|
||||
ActionHint::new("Enter", tr(self.locale, self.apply_action_id())),
|
||||
ActionHint::new("⇧A", view_action),
|
||||
ActionHint::new("Ctrl+S", tr(self.locale, MessageId::SessionsActionSort)),
|
||||
];
|
||||
if inner.height >= 16 {
|
||||
footer_hints.push(ActionHint::new(
|
||||
"Ctrl+S",
|
||||
tr(self.locale, MessageId::SessionsActionSort),
|
||||
));
|
||||
}
|
||||
if !self.can_edit_effort() {
|
||||
footer_hints.remove(1);
|
||||
}
|
||||
// A Fleet row has no startup default to save; the chord is a
|
||||
// session-route action only.
|
||||
if self.purpose == ModelPickerPurpose::Session {
|
||||
if self.purpose == ModelPickerPurpose::Session && inner.height >= 16 {
|
||||
footer_hints.insert(
|
||||
4,
|
||||
ActionHint::new(
|
||||
@@ -3884,7 +3943,7 @@ impl ModelPickerView {
|
||||
}
|
||||
// Keep compact route modals focused on the core browse/apply actions;
|
||||
// wider shells have room to disclose the pin action too.
|
||||
if inner.width >= 72 {
|
||||
if inner.width >= 72 && inner.height >= 16 {
|
||||
if self.purpose == ModelPickerPurpose::Session {
|
||||
footer_hints.push(ActionHint::new(
|
||||
"⇧F",
|
||||
@@ -3905,56 +3964,44 @@ impl ModelPickerView {
|
||||
let shell = ratatui::layout::Layout::default()
|
||||
.direction(ratatui::layout::Direction::Vertical)
|
||||
.constraints([
|
||||
ratatui::layout::Constraint::Length(3),
|
||||
ratatui::layout::Constraint::Length(1),
|
||||
ratatui::layout::Constraint::Min(1),
|
||||
])
|
||||
.split(content);
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("─ {} ", tr(self.locale, MessageId::RoutePanelHeader)),
|
||||
Style::default().fg(palette::WHALE_ACTION).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
"──────────────────────── ",
|
||||
Style::default().fg(palette::BORDER_COLOR),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{}{}",
|
||||
self.view.title_label(),
|
||||
catalog_freshness_title_suffix()
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
Span::styled(
|
||||
" ─────────────────",
|
||||
Style::default().fg(palette::BORDER_COLOR),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} ", tr(self.locale, MessageId::RouteProviderLabel)),
|
||||
Style::default().fg(palette::WHALE_ACTION),
|
||||
),
|
||||
Span::styled(
|
||||
self.resolved_provider()
|
||||
.unwrap_or(self.initial_provider)
|
||||
.display_name(),
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" · {}", tr(self.locale, MessageId::RouteModelFirstAtomic)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
]),
|
||||
])
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} ", tr(self.locale, MessageId::RouteProviderLabel)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
Span::styled(
|
||||
self.resolved_provider()
|
||||
.unwrap_or(self.initial_provider)
|
||||
.display_name(),
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(
|
||||
catalog_freshness_title_suffix(),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
]))
|
||||
.render(shell[0], buf);
|
||||
|
||||
let mut layout = widen_model_pane(ListDetailLayout::split(shell[1], 24));
|
||||
if !self.can_edit_effort() {
|
||||
layout.list = shell[1];
|
||||
} else if layout.stacked && shell[1].height < 12 {
|
||||
let model_height = if self.focus == Pane::Model {
|
||||
shell[1].height.saturating_sub(1)
|
||||
} else {
|
||||
u16::from(shell[1].height > 0)
|
||||
};
|
||||
layout.list = Rect::new(shell[1].x, shell[1].y, shell[1].width, model_height);
|
||||
layout.detail = Rect::new(
|
||||
shell[1].x,
|
||||
shell[1].y + model_height,
|
||||
shell[1].width,
|
||||
shell[1].height.saturating_sub(model_height),
|
||||
);
|
||||
}
|
||||
|
||||
self.ensure_projection();
|
||||
@@ -4313,6 +4360,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbench_hover_preserves_model_selection_and_clears_on_keyboard_input() {
|
||||
let mut picker = test_picker();
|
||||
picker
|
||||
.model_rows
|
||||
.push(model_row(ApiProvider::Deepseek, true));
|
||||
let area = Rect::new(0, 0, 100, 32);
|
||||
let mut buf = Buffer::empty(area);
|
||||
picker.render(area, &mut buf);
|
||||
let hit = picker
|
||||
.row_hitboxes
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|(_, pane, idx)| *pane == Pane::Model && *idx == 1)
|
||||
.unwrap()
|
||||
.0;
|
||||
picker.handle_mouse(MouseEvent {
|
||||
kind: MouseEventKind::Moved,
|
||||
column: hit.x,
|
||||
row: hit.y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
assert_eq!(picker.hovered_row, Some((Pane::Model, 1)));
|
||||
assert_eq!(picker.selected_model_idx, 0);
|
||||
picker.render(area, &mut buf);
|
||||
assert_eq!(buf[(hit.right() - 1, hit.y)].bg, palette::SURFACE_ELEVATED);
|
||||
picker.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
assert_eq!(picker.hovered_row, None);
|
||||
}
|
||||
|
||||
fn test_picker() -> ModelPickerView {
|
||||
ModelPickerView {
|
||||
initial_model: "model".to_string(),
|
||||
@@ -4334,6 +4411,7 @@ mod tests {
|
||||
configured_providers: Vec::new(),
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
last_mouse_selected: None,
|
||||
hovered_row: None,
|
||||
locale: Locale::En,
|
||||
pinned_models: Vec::new(),
|
||||
projection: RefCell::new(None),
|
||||
|
||||
@@ -51,7 +51,7 @@ use crate::tui::list_nav::{self, Motion};
|
||||
use crate::tui::menu_style;
|
||||
use crate::tui::views::{
|
||||
ActionHint, EmptyState, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent,
|
||||
centered_modal_area, render_modal_footer, render_modal_surface,
|
||||
centered_modal_area, render_modal_footer, render_modal_surface, render_underwater_surface,
|
||||
};
|
||||
use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot};
|
||||
use codewhale_config::provider::{CredentialAcquisition, WireFormat};
|
||||
@@ -225,6 +225,9 @@ pub struct ProviderPickerView {
|
||||
list_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
|
||||
model_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
|
||||
consent_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
|
||||
choice_row_hitboxes: RefCell<Vec<(Rect, char)>>,
|
||||
hovered_choice: Option<char>,
|
||||
last_choice_mouse_selected: Option<(Stage, char)>,
|
||||
/// Pointer hover positions. Advisory only — hover never moves the
|
||||
/// keyboard selection; it renders with the shared
|
||||
/// [`crate::tui::menu_style::hovered_row_style`] primitive.
|
||||
@@ -1767,6 +1770,9 @@ impl ProviderPickerView {
|
||||
list_row_hitboxes: RefCell::new(Vec::new()),
|
||||
model_row_hitboxes: RefCell::new(Vec::new()),
|
||||
consent_row_hitboxes: RefCell::new(Vec::new()),
|
||||
choice_row_hitboxes: RefCell::new(Vec::new()),
|
||||
hovered_choice: None,
|
||||
last_choice_mouse_selected: None,
|
||||
hovered_list_idx: None,
|
||||
hovered_model_idx: None,
|
||||
hovered_consent_idx: None,
|
||||
@@ -2540,18 +2546,23 @@ impl ProviderPickerView {
|
||||
(false, ProviderListView::Local) => " Provider · local only ".to_string(),
|
||||
}
|
||||
};
|
||||
let outer = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(palette::WHALE_ACTION)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::WHALE_BG));
|
||||
let inner = outer.inner(area);
|
||||
outer.render(area, buf);
|
||||
let inner = if self.onboarding_mode {
|
||||
let outer = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(palette::WHALE_ACTION)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::WHALE_BG));
|
||||
let inner = outer.inner(area);
|
||||
outer.render(area, buf);
|
||||
inner
|
||||
} else {
|
||||
render_underwater_surface(area, buf, title.trim())
|
||||
};
|
||||
|
||||
let view_action = match self.view {
|
||||
ProviderListView::Configured => self.tr(MessageId::PickerActionBrowseAll),
|
||||
@@ -2597,6 +2608,21 @@ impl ProviderPickerView {
|
||||
ActionHint::new("A", view_action.clone()),
|
||||
],
|
||||
)
|
||||
} else if inner.height < 16 {
|
||||
// Keep the selection and recovery actions visible before teaching
|
||||
// secondary shortcuts; the full rail returns with vertical room.
|
||||
render_modal_footer(
|
||||
inner,
|
||||
buf,
|
||||
&[
|
||||
ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
|
||||
ActionHint::new("Enter", enter_action),
|
||||
ActionHint::new("R", self.tr(MessageId::PickerActionEditKey)),
|
||||
ActionHint::new("M", self.tr(MessageId::PickerActionModels)),
|
||||
ActionHint::new("A", view_action),
|
||||
ActionHint::new("Esc", self.tr(MessageId::PickerActionCancel)),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
render_modal_footer(
|
||||
inner,
|
||||
@@ -2701,12 +2727,9 @@ impl ProviderPickerView {
|
||||
| CredentialState::Legacy
|
||||
);
|
||||
let hint_style = if is_selected {
|
||||
let hint_fg = if has_usable_auth {
|
||||
palette::TEXT_MUTED
|
||||
} else {
|
||||
palette::STATUS_WARNING
|
||||
};
|
||||
menu_style::selected_row_style_with_fg(hint_fg)
|
||||
// The credential label carries its meaning in text; focus
|
||||
// ink must remain readable against the selection background.
|
||||
menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
|
||||
} else if has_usable_auth {
|
||||
Style::default().fg(palette::TEXT_MUTED)
|
||||
} else {
|
||||
@@ -2728,6 +2751,9 @@ impl ProviderPickerView {
|
||||
Span::styled(" ", spacer_style),
|
||||
Span::styled(hint, hint_style),
|
||||
]);
|
||||
if is_hovered && !is_selected {
|
||||
line.style = menu_style::hovered_row_style();
|
||||
}
|
||||
if is_selected {
|
||||
line.style = menu_style::selected_row_bg_style();
|
||||
let target_width = usize::from(layout.list.width);
|
||||
@@ -2740,6 +2766,12 @@ impl ProviderPickerView {
|
||||
}
|
||||
}
|
||||
let row_y = layout.list.y.saturating_add(lines.len() as u16);
|
||||
if is_hovered && !is_selected {
|
||||
buf.set_style(
|
||||
Rect::new(layout.list.x, row_y, layout.list.width, 1),
|
||||
menu_style::hovered_row_style(),
|
||||
);
|
||||
}
|
||||
self.list_row_hitboxes
|
||||
.borrow_mut()
|
||||
.push((Rect::new(layout.list.x, row_y, layout.list.width, 1), *idx));
|
||||
@@ -2810,6 +2842,11 @@ impl ProviderPickerView {
|
||||
format!("Endpoint: {}", row.base_url),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)),
|
||||
];
|
||||
// Protocol/capability details explain a route, but must not crowd out
|
||||
// its model choices and prices. Credential warnings and consent stay
|
||||
// ahead of the model inventory; technical diagnostics follow it.
|
||||
let diagnostics = vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Protocol: {}", row.supported_protocols.join("+")),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
@@ -2903,8 +2940,8 @@ impl ProviderPickerView {
|
||||
}
|
||||
// Slice D two-pane picker: the selected provider's models live
|
||||
// beside (wide) or under (narrow) the provider strip, each with its
|
||||
// own $/mtok in/out from the catalog. Last on purpose: when the pane
|
||||
// is short, clipping eats models — never the consent block above.
|
||||
// own $/mtok in/out from the catalog. Credential and consent facts
|
||||
// lead; model choices come before low-level route diagnostics.
|
||||
// Display-only — choosing a model happens in the model picker (`M`)
|
||||
// or the guided setup flow.
|
||||
lines.push(Line::from(""));
|
||||
@@ -2947,6 +2984,8 @@ 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);
|
||||
@@ -2975,23 +3014,17 @@ impl ProviderPickerView {
|
||||
ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
|
||||
],
|
||||
);
|
||||
let marker = |choice| crate::tui::glyphs::selection_marker(self.xai_auth_choice == choice);
|
||||
Paragraph::new(vec![
|
||||
Line::from(self.tr(MessageId::XaiAuthChoiceIntro)),
|
||||
Line::from(""),
|
||||
Line::from(format!(
|
||||
"{} 1. {}",
|
||||
marker(XaiAuthChoice::ApiKey),
|
||||
self.tr(MessageId::XaiAuthChoiceApiKeyOption),
|
||||
)),
|
||||
Line::from(format!(
|
||||
"{} 2. {}",
|
||||
marker(XaiAuthChoice::DeviceOAuth),
|
||||
self.tr(MessageId::XaiAuthChoiceDeviceOAuthOption),
|
||||
)),
|
||||
])
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(content, buf);
|
||||
self.render_setup_choices(
|
||||
content,
|
||||
buf,
|
||||
vec![Line::from(self.tr(MessageId::XaiAuthChoiceIntro))],
|
||||
[
|
||||
self.tr(MessageId::XaiAuthChoiceApiKeyOption).into_owned(),
|
||||
self.tr(MessageId::XaiAuthChoiceDeviceOAuthOption)
|
||||
.into_owned(),
|
||||
],
|
||||
usize::from(self.xai_auth_choice == XaiAuthChoice::DeviceOAuth),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_chatgpt_auth_choice(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -3017,24 +3050,17 @@ impl ProviderPickerView {
|
||||
ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
|
||||
],
|
||||
);
|
||||
let marker =
|
||||
|choice| crate::tui::glyphs::selection_marker(self.chatgpt_auth_choice == choice);
|
||||
Paragraph::new(vec![
|
||||
Line::from(self.tr(MessageId::ChatgptAuthChoiceIntro)),
|
||||
Line::from(""),
|
||||
Line::from(format!(
|
||||
"{} 1. {}",
|
||||
marker(ChatgptAuthChoice::SignInWithChatgpt),
|
||||
self.tr(MessageId::ChatgptAuthChoicePkceOption),
|
||||
)),
|
||||
Line::from(format!(
|
||||
"{} 2. {}",
|
||||
marker(ChatgptAuthChoice::ImportCodexCli),
|
||||
self.tr(MessageId::ChatgptAuthChoiceImportOption),
|
||||
)),
|
||||
])
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(content, buf);
|
||||
self.render_setup_choices(
|
||||
content,
|
||||
buf,
|
||||
vec![Line::from(self.tr(MessageId::ChatgptAuthChoiceIntro))],
|
||||
[
|
||||
self.tr(MessageId::ChatgptAuthChoicePkceOption).into_owned(),
|
||||
self.tr(MessageId::ChatgptAuthChoiceImportOption)
|
||||
.into_owned(),
|
||||
],
|
||||
usize::from(self.chatgpt_auth_choice == ChatgptAuthChoice::ImportCodexCli),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_key_entry(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -3588,23 +3614,21 @@ impl ProviderPickerView {
|
||||
ActionHint::new("Esc", "back"),
|
||||
],
|
||||
);
|
||||
let selected = self.kimi_code_plan_tier;
|
||||
let marker = |tier| crate::tui::glyphs::selection_marker(selected == tier);
|
||||
Paragraph::new(vec![
|
||||
Line::from("Kimi Code plan limits determine the context window used for k3."),
|
||||
Line::from("Choose the tier you actually have; the safe floor is selected by default."),
|
||||
Line::from(""),
|
||||
Line::from(format!(
|
||||
"{} 1. 262K context (safe default)",
|
||||
marker(KimiCodePlanTier::Safe262k)
|
||||
)),
|
||||
Line::from(format!(
|
||||
"{} 2. 1M context (only with an eligible plan)",
|
||||
marker(KimiCodePlanTier::OneMillion)
|
||||
)),
|
||||
])
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(content, buf);
|
||||
self.render_setup_choices(
|
||||
content,
|
||||
buf,
|
||||
vec![
|
||||
Line::from("Kimi Code plan limits determine the context window used for k3."),
|
||||
Line::from(
|
||||
"Choose the tier you actually have; the safe floor is selected by default.",
|
||||
),
|
||||
],
|
||||
[
|
||||
"262K context (safe default)".into(),
|
||||
"1M context (only with an eligible plan)".into(),
|
||||
],
|
||||
usize::from(self.kimi_code_plan_tier == KimiCodePlanTier::OneMillion),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_stepfun_billing_route(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -3629,29 +3653,89 @@ impl ProviderPickerView {
|
||||
ActionHint::new("Esc", "back"),
|
||||
],
|
||||
);
|
||||
let selected = self.stepfun_billing_route;
|
||||
let marker = |route| crate::tui::glyphs::selection_marker(selected == route);
|
||||
// The endpoint is shown next to each choice: it is the whole
|
||||
// difference between the two billing tracks, and it is what gets
|
||||
// written to `[providers.stepfun] base_url` on confirm.
|
||||
Paragraph::new(vec![
|
||||
Line::from(self.tr(MessageId::StepfunBillingRouteIntro).to_string()),
|
||||
Line::from(""),
|
||||
Line::from(format!(
|
||||
"{} 1. {} — {}",
|
||||
marker(StepfunBillingRoute::PayAsYouGo),
|
||||
self.tr(MessageId::StepfunBillingRoutePaygOption),
|
||||
StepfunBillingRoute::PayAsYouGo.base_url(),
|
||||
)),
|
||||
Line::from(format!(
|
||||
"{} 2. {} — {}",
|
||||
marker(StepfunBillingRoute::StepPlan),
|
||||
self.tr(MessageId::StepfunBillingRoutePlanOption),
|
||||
StepfunBillingRoute::StepPlan.base_url(),
|
||||
)),
|
||||
])
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(content, buf);
|
||||
// Keep the actual endpoint with each route; choosing only stages
|
||||
// the value and still follows the existing confirmation flow.
|
||||
self.render_setup_choices(
|
||||
content,
|
||||
buf,
|
||||
vec![Line::from(self.tr(MessageId::StepfunBillingRouteIntro))],
|
||||
[
|
||||
format!(
|
||||
"{} — {}",
|
||||
self.tr(MessageId::StepfunBillingRoutePaygOption),
|
||||
StepfunBillingRoute::PayAsYouGo.base_url()
|
||||
),
|
||||
format!(
|
||||
"{} — {}",
|
||||
self.tr(MessageId::StepfunBillingRoutePlanOption),
|
||||
StepfunBillingRoute::StepPlan.base_url()
|
||||
),
|
||||
],
|
||||
usize::from(self.stepfun_billing_route == StepfunBillingRoute::StepPlan),
|
||||
);
|
||||
}
|
||||
|
||||
/// One geometry for the four two-choice setup screens. Pointer hitboxes
|
||||
/// cover only painted rows, including wrapped labels; no auth or billing
|
||||
/// action lives here. Small terminals give options room before prose.
|
||||
fn render_setup_choices(
|
||||
&self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
intro: Vec<Line<'static>>,
|
||||
labels: [String; 2],
|
||||
selected: usize,
|
||||
) {
|
||||
self.choice_row_hitboxes.borrow_mut().clear();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let choices: Vec<_> = labels
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, label)| {
|
||||
let key = if idx == 0 { '1' } else { '2' };
|
||||
let style = if selected == idx {
|
||||
menu_style::selected_row_style()
|
||||
} else if self.hovered_choice == Some(key) {
|
||||
menu_style::hovered_row_style().fg(palette::TEXT_PRIMARY)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
Paragraph::new(format!(
|
||||
"{} {key}. {label}",
|
||||
crate::tui::glyphs::selection_marker(selected == idx)
|
||||
))
|
||||
.style(style)
|
||||
.wrap(Wrap { trim: false })
|
||||
})
|
||||
.collect();
|
||||
let needed = choices
|
||||
.iter()
|
||||
.map(|p| p.line_count(area.width) as u16)
|
||||
.sum::<u16>();
|
||||
let intro = Paragraph::new(intro)
|
||||
.style(Style::default().fg(palette::TEXT_MUTED))
|
||||
.wrap(Wrap { trim: false });
|
||||
let intro_height =
|
||||
(intro.line_count(area.width) as u16).min(area.height.saturating_sub(needed));
|
||||
intro.render(Rect::new(area.x, area.y, area.width, intro_height), buf);
|
||||
let mut y = area.y + intro_height;
|
||||
for (idx, choice) in choices.into_iter().enumerate() {
|
||||
let remaining = area.bottom().saturating_sub(y);
|
||||
let reserve = u16::from(idx == 0 && remaining > 1);
|
||||
let height =
|
||||
(choice.line_count(area.width) as u16).min(remaining.saturating_sub(reserve));
|
||||
if height == 0 {
|
||||
continue;
|
||||
}
|
||||
let row = Rect::new(area.x, y, area.width, height);
|
||||
choice.render(row, buf);
|
||||
self.choice_row_hitboxes
|
||||
.borrow_mut()
|
||||
.push((row, if idx == 0 { '1' } else { '2' }));
|
||||
y += height;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_confirm(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -4009,6 +4093,8 @@ impl ModalView for ProviderPickerView {
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
self.last_choice_mouse_selected = None;
|
||||
self.hovered_choice = None;
|
||||
match self.stage {
|
||||
Stage::List => match key.code {
|
||||
KeyCode::Esc if !self.query.is_empty() => {
|
||||
@@ -4564,8 +4650,36 @@ impl ModalView for ProviderPickerView {
|
||||
Stage::PlanTier
|
||||
| Stage::StepfunBillingRoute
|
||||
| Stage::XaiAuthChoice
|
||||
| Stage::ChatgptAuthChoice
|
||||
| Stage::KeyEntry
|
||||
| Stage::ChatgptAuthChoice => {
|
||||
let hit = self
|
||||
.choice_row_hitboxes
|
||||
.borrow()
|
||||
.iter()
|
||||
.find_map(|(rect, key)| {
|
||||
rect.contains((mouse.column, mouse.row).into())
|
||||
.then_some(*key)
|
||||
});
|
||||
match mouse.kind {
|
||||
MouseEventKind::Moved => self.hovered_choice = hit,
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if let Some(key) = hit {
|
||||
let stage = self.stage;
|
||||
let activate = self.last_choice_mouse_selected == Some((stage, key));
|
||||
let _ = self
|
||||
.handle_key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
|
||||
self.last_choice_mouse_selected = Some((stage, key));
|
||||
if activate {
|
||||
return self
|
||||
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
}
|
||||
} else {
|
||||
self.last_choice_mouse_selected = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Stage::KeyEntry
|
||||
| Stage::ExternalConsentConfirm
|
||||
| Stage::ExternalConsentRevokeConfirm
|
||||
| Stage::Confirm
|
||||
@@ -4575,6 +4689,14 @@ impl ModalView for ProviderPickerView {
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.choice_row_hitboxes.borrow_mut().clear();
|
||||
// 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.
|
||||
if matches!(self.stage, Stage::List) && !self.onboarding_mode {
|
||||
self.render_list(area, buf);
|
||||
return;
|
||||
}
|
||||
let preferred_height = match self.stage {
|
||||
Stage::List => (self.rows.len() as u16).saturating_add(2),
|
||||
Stage::XaiAuthChoice => 12,
|
||||
@@ -4664,6 +4786,53 @@ 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 workbench_setup_choice_clicks_share_keyboard_confirmation_paths() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
for stage in [
|
||||
Stage::PlanTier,
|
||||
Stage::StepfunBillingRoute,
|
||||
Stage::XaiAuthChoice,
|
||||
Stage::ChatgptAuthChoice,
|
||||
] {
|
||||
for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
|
||||
let config = Config::default();
|
||||
let mut pointer = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
||||
let mut keyboard = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
||||
pointer.stage = stage;
|
||||
keyboard.stage = stage;
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
pointer.render(area, &mut buf);
|
||||
let hit = pointer
|
||||
.choice_row_hitboxes
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|(_, key)| *key == '2')
|
||||
.expect("both choices visible")
|
||||
.0;
|
||||
let event = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: hit.x,
|
||||
row: hit.y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
assert!(matches!(pointer.handle_mouse(event), ViewAction::None));
|
||||
assert_eq!(pointer.stage, stage, "first click only selects");
|
||||
keyboard.handle_key(key(KeyCode::Char('2')));
|
||||
let actual = pointer.handle_mouse(event);
|
||||
let expected = keyboard.handle_key(key(KeyCode::Enter));
|
||||
assert_eq!(format!("{actual:?}"), format!("{expected:?}"));
|
||||
assert_eq!(pointer.stage, keyboard.stage);
|
||||
assert_eq!(pointer.pending_base_url, keyboard.pending_base_url);
|
||||
assert_eq!(
|
||||
pointer.selected_context_window,
|
||||
keyboard.selected_context_window
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use super::{
|
||||
render_underwater_surface, truncate_view_text,
|
||||
};
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::menu_style;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_palette as palette;
|
||||
|
||||
@@ -1477,6 +1478,8 @@ pub struct ExtensionsView {
|
||||
/// grammar the rest of the chrome uses instead of raw palette constants.
|
||||
theme: codewhale_palette::UiTheme,
|
||||
hits: RefCell<HitAreas>,
|
||||
hovered_row: Option<usize>,
|
||||
hovered_tab: Option<ExtensionsTab>,
|
||||
/// Last time `tick` asked the host for a fresh snapshot. Bounds the poll
|
||||
/// so a per-frame tick cannot turn into a rebuild every frame.
|
||||
last_poll: std::time::Instant,
|
||||
@@ -1510,6 +1513,8 @@ impl ExtensionsView {
|
||||
folded_groups: BTreeSet::new(),
|
||||
theme: codewhale_palette::UI_THEME,
|
||||
hits: RefCell::new(HitAreas::default()),
|
||||
hovered_row: None,
|
||||
hovered_tab: None,
|
||||
last_poll: std::time::Instant::now(),
|
||||
pending_remove: None,
|
||||
};
|
||||
@@ -1687,6 +1692,8 @@ impl ExtensionsView {
|
||||
}
|
||||
|
||||
fn set_tab(&mut self, tab: ExtensionsTab) {
|
||||
self.hovered_row = None;
|
||||
self.hovered_tab = None;
|
||||
self.pending_remove = None;
|
||||
self.active_tab = tab;
|
||||
self.clamp_selection();
|
||||
@@ -1741,6 +1748,8 @@ impl ModalView for ExtensionsView {
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
self.hovered_row = None;
|
||||
self.hovered_tab = None;
|
||||
// One navigation grammar (grokbuild, the stated authority): Tab and
|
||||
// Shift+Tab / BackTab move across the tab bar, always — even during
|
||||
// a search, which keeps its query on the new tab. `/` searches, Esc
|
||||
@@ -1841,6 +1850,19 @@ impl ModalView for ExtensionsView {
|
||||
|
||||
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Moved => {
|
||||
let hits = self.hits.borrow();
|
||||
let point = (mouse.column, mouse.row).into();
|
||||
self.hovered_row = hits
|
||||
.rows
|
||||
.iter()
|
||||
.find_map(|(rect, row)| rect.contains(point).then_some(*row));
|
||||
self.hovered_tab = hits
|
||||
.tabs
|
||||
.iter()
|
||||
.find_map(|(rect, tab)| rect.contains(point).then_some(*tab));
|
||||
return ViewAction::None;
|
||||
}
|
||||
// The wheel moves this list, not the transcript behind it.
|
||||
MouseEventKind::ScrollUp => {
|
||||
self.pending_remove = None;
|
||||
@@ -1913,6 +1935,7 @@ impl ModalView for ExtensionsView {
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
*self.hits.borrow_mut() = HitAreas::default();
|
||||
let body = render_underwater_surface(
|
||||
area,
|
||||
buf,
|
||||
@@ -1949,10 +1972,9 @@ impl ModalView for ExtensionsView {
|
||||
let active = tab == self.active_tab;
|
||||
let focused = active && self.focus == ExtensionsFocus::Tabs;
|
||||
let style = if focused {
|
||||
Style::default()
|
||||
.fg(palette::WHALE_BG)
|
||||
.bg(palette::WHALE_ACTION)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
menu_style::selected_row_style()
|
||||
} else if self.hovered_tab == Some(tab) {
|
||||
menu_style::hovered_row_style().fg(palette::TEXT_PRIMARY)
|
||||
} else if active {
|
||||
Style::default()
|
||||
.fg(palette::WHALE_ACTION)
|
||||
@@ -2015,10 +2037,11 @@ impl ModalView for ExtensionsView {
|
||||
1,
|
||||
);
|
||||
let is_selected = entry_index == selected;
|
||||
let hovered = self.hovered_row == Some(entry_index);
|
||||
let style = if is_selected && self.focus == ExtensionsFocus::List {
|
||||
Style::default()
|
||||
.fg(palette::WHALE_BG)
|
||||
.bg(palette::WHALE_ACTION)
|
||||
menu_style::selected_row_style()
|
||||
} else if hovered {
|
||||
menu_style::hovered_row_style().fg(palette::TEXT_PRIMARY)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(palette::WHALE_ACTION)
|
||||
@@ -2092,7 +2115,7 @@ impl ModalView for ExtensionsView {
|
||||
.map(|(text, _)| text.as_str())
|
||||
.collect::<String>();
|
||||
let clipped = truncate_view_text(&joined, usize::from(row_area.width));
|
||||
let spans = if is_selected || clipped.len() != joined.len() {
|
||||
let spans = if is_selected || hovered || clipped.len() != joined.len() {
|
||||
vec![Span::styled(clipped, style)]
|
||||
} else {
|
||||
parts
|
||||
@@ -2107,7 +2130,9 @@ impl ModalView for ExtensionsView {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Paragraph::new(Line::from(spans)).render(row_area, buf);
|
||||
Paragraph::new(Line::from(spans))
|
||||
.style(style)
|
||||
.render(row_area, buf);
|
||||
hits.rows.push((row_area, entry_index));
|
||||
}
|
||||
|
||||
@@ -2195,6 +2220,34 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::mcp::McpRecoveryKind;
|
||||
|
||||
#[test]
|
||||
fn workbench_extension_hover_preserves_selection_and_small_resize_clears_targets() {
|
||||
let mut view = view_on_item(ExtensionAction::Command {
|
||||
label: "enable".into(),
|
||||
command: "/plugin enable demo".into(),
|
||||
disposition: RowActionDisposition::InPlace,
|
||||
});
|
||||
let area = Rect::new(0, 0, 80, 24);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
let (hit, row) = view.hits.borrow().rows[0];
|
||||
let selected = view.selected;
|
||||
view.handle_mouse(MouseEvent {
|
||||
kind: MouseEventKind::Moved,
|
||||
column: hit.x,
|
||||
row: hit.y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
assert_eq!(view.hovered_row, Some(row));
|
||||
assert_eq!(view.selected, selected);
|
||||
view.render(area, &mut buf);
|
||||
assert_eq!(buf[(hit.right() - 1, hit.y)].bg, palette::SURFACE_ELEVATED);
|
||||
let tiny = Rect::new(0, 0, 20, 4);
|
||||
view.render(tiny, &mut Buffer::empty(tiny));
|
||||
assert!(view.hits.borrow().rows.is_empty());
|
||||
assert!(view.hits.borrow().tabs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_contributed_server_is_not_mutable_from_this_panel() {
|
||||
// The row's name is synthesized and never appears in the config file
|
||||
|
||||
@@ -155,6 +155,8 @@ pub struct FleetRosterView {
|
||||
/// Row under the pointer, tinted with the shared hover style. Hover
|
||||
/// never moves the keyboard selection; only painted rows answer.
|
||||
hovered_row: Cell<Option<usize>>,
|
||||
workers_hitbox: Cell<Option<Rect>>,
|
||||
hovered_workers: Cell<bool>,
|
||||
/// Canonical active-theme surface captured from `App`; Terminal owns
|
||||
/// `Color::Reset`, while explicit themes retain their resolved surface.
|
||||
surface_bg: Color,
|
||||
@@ -211,6 +213,8 @@ impl FleetRosterView {
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
last_mouse_selected: None,
|
||||
hovered_row: Cell::new(None),
|
||||
workers_hitbox: Cell::new(None),
|
||||
hovered_workers: Cell::new(false),
|
||||
surface_bg: palette::UI_THEME.surface_bg,
|
||||
locale: Locale::En,
|
||||
}
|
||||
@@ -336,13 +340,18 @@ impl FleetRosterView {
|
||||
} else {
|
||||
"setup"
|
||||
};
|
||||
vec![
|
||||
ActionHint::new("↑↓", "move"),
|
||||
ActionHint::new("Enter", edit_label),
|
||||
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));
|
||||
}
|
||||
hints.extend([
|
||||
ActionHint::new("Tab", tr(self.locale, MessageId::FleetRosterWorkers)),
|
||||
ActionHint::new("f", "saved teams"),
|
||||
ActionHint::new("Esc", "close"),
|
||||
]
|
||||
]);
|
||||
hints
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +368,8 @@ impl ModalView for FleetRosterView {
|
||||
// A keyboard gesture ends any pending mouse double-click sequence so
|
||||
// a later single click can never activate a stale row.
|
||||
self.last_mouse_selected = None;
|
||||
self.hovered_workers.set(false);
|
||||
self.hovered_row.set(None);
|
||||
// Shift-modified paging scrolls the detail pane; bare keys drive the
|
||||
// row list through the shared vocabulary (#6290, #6014-style split).
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
@@ -403,6 +414,11 @@ impl ModalView for FleetRosterView {
|
||||
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
|
||||
match mouse.kind {
|
||||
MouseEventKind::Moved => {
|
||||
self.hovered_workers.set(
|
||||
self.workers_hitbox
|
||||
.get()
|
||||
.is_some_and(|rect| rect.contains((mouse.column, mouse.row).into())),
|
||||
);
|
||||
let hovered = self
|
||||
.row_hitboxes
|
||||
.borrow()
|
||||
@@ -423,6 +439,14 @@ impl ModalView for FleetRosterView {
|
||||
ViewAction::None
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if self
|
||||
.workers_hitbox
|
||||
.get()
|
||||
.is_some_and(|rect| rect.contains((mouse.column, mouse.row).into()))
|
||||
{
|
||||
self.last_mouse_selected = None;
|
||||
return ViewAction::Emit(ViewEvent::FleetRosterOpenWorkersRequested);
|
||||
}
|
||||
let action = self
|
||||
.row_hitboxes
|
||||
.borrow()
|
||||
@@ -448,63 +472,69 @@ impl ModalView for FleetRosterView {
|
||||
let hints = self.footer_hints();
|
||||
let content = render_modal_footer(area, buf, &hints);
|
||||
|
||||
// Hairline shell shared with the HTML route/config/Fleet surfaces.
|
||||
// This replaces the centered legacy card: Fleet is a product room,
|
||||
// not a popup floating over an unrelated transcript.
|
||||
// A compact, honest header: the roster is the current room, Workers
|
||||
// is a real destination. Editing belongs to the selected member,
|
||||
// rather than a decorative Setup tab that never handled clicks.
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(1)])
|
||||
.constraints([Constraint::Length(2), Constraint::Min(1)])
|
||||
.split(content);
|
||||
let header = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("─ {} ", tr(self.locale, MessageId::FleetRosterHeaderLabel)),
|
||||
Style::default().fg(palette::WHALE_ACTION).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
"──────────────────────── ",
|
||||
Style::default().fg(palette::BORDER_COLOR),
|
||||
),
|
||||
Span::styled(
|
||||
tr(self.locale, MessageId::FleetRosterTabRoster),
|
||||
Style::default().fg(palette::WHALE_ACTION).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
" {} {} ",
|
||||
tr(self.locale, MessageId::FleetRosterTabSetup),
|
||||
tr(self.locale, MessageId::FleetRosterWorkers)
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
Span::styled("─".repeat(24), Style::default().fg(palette::BORDER_COLOR)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {}", self.selected_fleet_line()),
|
||||
Style::default().fg(palette::TEXT_SECONDARY),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
" · {}",
|
||||
tr(self.locale, MessageId::FleetRosterMembersCount)
|
||||
.replace("{count}", &(self.members.len() + 1).to_string())
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
" · {}",
|
||||
tr(self.locale, MessageId::FleetRosterOperatorFirst)
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
]),
|
||||
];
|
||||
Paragraph::new(header)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(chunks[0], buf);
|
||||
let roster_label = format!(
|
||||
" {} · {} ",
|
||||
tr(self.locale, MessageId::FleetRosterHeaderLabel),
|
||||
tr(self.locale, MessageId::FleetRosterTabRoster)
|
||||
);
|
||||
let workers_label = format!(" {} ", tr(self.locale, MessageId::FleetRosterWorkers));
|
||||
let roster_width = unicode_width::UnicodeWidthStr::width(roster_label.as_str()) as u16;
|
||||
let workers_width = unicode_width::UnicodeWidthStr::width(workers_label.as_str()) as u16;
|
||||
self.workers_hitbox.set(None);
|
||||
// Place Workers at the right edge so even a compact screen retains
|
||||
// its full action target; the room label yields first.
|
||||
let workers_width = workers_width.min(chunks[0].width);
|
||||
let workers = Rect::new(
|
||||
chunks[0].right().saturating_sub(workers_width),
|
||||
chunks[0].y,
|
||||
workers_width,
|
||||
u16::from(chunks[0].height > 0),
|
||||
);
|
||||
if workers.width > 0 && workers.height > 0 {
|
||||
self.workers_hitbox.set(Some(workers));
|
||||
}
|
||||
let title = Rect::new(
|
||||
chunks[0].x,
|
||||
chunks[0].y,
|
||||
roster_width.min(chunks[0].width.saturating_sub(workers_width)),
|
||||
workers.height,
|
||||
);
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
roster_label,
|
||||
Style::default().fg(palette::TEXT_PRIMARY).bold(),
|
||||
)))
|
||||
.render(title, buf);
|
||||
let workers_style = if self.hovered_workers.get() {
|
||||
menu_style::hovered_row_style().fg(palette::WHALE_ACTION)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(palette::WHALE_ACTION)
|
||||
.add_modifier(Modifier::UNDERLINED)
|
||||
};
|
||||
Paragraph::new(Line::from(Span::styled(workers_label, workers_style))).render(workers, buf);
|
||||
if chunks[0].height > 1 {
|
||||
let summary = format!(
|
||||
" {} · {}",
|
||||
self.selected_fleet_line(),
|
||||
tr(self.locale, MessageId::FleetRosterMembersCount)
|
||||
.replace("{count}", &(self.members.len() + 1).to_string())
|
||||
);
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
truncate_view_text(&summary, usize::from(chunks[0].width)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)))
|
||||
.render(
|
||||
Rect::new(chunks[0].x, chunks[0].y + 1, chunks[0].width, 1),
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
self.render_body(chunks[1], buf);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ fn view_with_overrides() -> FleetRosterView {
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
last_mouse_selected: None,
|
||||
hovered_row: Cell::new(None),
|
||||
workers_hitbox: Cell::new(None),
|
||||
hovered_workers: Cell::new(false),
|
||||
surface_bg: palette::UI_THEME.surface_bg,
|
||||
locale: Locale::En,
|
||||
}
|
||||
@@ -794,3 +796,25 @@ fn selection_stays_visible_when_list_scrolls() {
|
||||
let text = rows.join("\n");
|
||||
assert!(text.contains("▸ · ·▰ custom"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbench_workers_header_is_clickable_without_moving_selection() {
|
||||
for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
|
||||
let mut view = built_in_view();
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
let hit = view
|
||||
.workers_hitbox
|
||||
.get()
|
||||
.expect("visible Workers destination");
|
||||
assert!(area.contains((hit.x, hit.y).into()));
|
||||
view.handle_mouse(mouse(MouseEventKind::Moved, hit));
|
||||
assert!(view.hovered_workers.get());
|
||||
assert_eq!(view.selected, 0);
|
||||
assert!(matches!(
|
||||
view.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), hit)),
|
||||
ViewAction::Emit(ViewEvent::FleetRosterOpenWorkersRequested)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -4093,8 +4093,12 @@ impl ModalView for ConfigView {
|
||||
// Spacer rows are secondary chrome: give them up before the
|
||||
// editable value line falls below the wrapped footer on compact
|
||||
// terminals (#40x12).
|
||||
let spacious =
|
||||
usize::from(inner.height).saturating_sub(reserved_footer_lines + CONTROL_ROWS) >= 8;
|
||||
let body_rows =
|
||||
usize::from(inner.height).saturating_sub(reserved_footer_lines + CONTROL_ROWS);
|
||||
// The expanded header costs six rows before the options. Reserve
|
||||
// at least three choices plus their detail before adding spacers;
|
||||
// a slightly taller compact shell must not show fewer options.
|
||||
let spacious = body_rows >= if edit.choices.is_some() { 10 } else { 8 };
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let edit_label = config_label_for_key_for_locale(self.locale, &edit.key);
|
||||
let edit_title = if edit_label == edit.key {
|
||||
|
||||
Reference in new issue
Block a user