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:
CodeWhale Bot committed 2026-09-19 04:42:29 -07:00
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
──────────────────────────────────────────────────────────────────────────────
+127 -49
View File
@@ -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),
+267 -98
View File
@@ -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)
}
+62 -9
View File
@@ -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
+88 -58
View 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)
));
}
}
+6 -2
View File
@@ -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 {